简介:一份面向 Qt 初中级开发者的界面动效学习素材,围绕侧边栏抽屉式展开与收起,演示如何用 QPropertyAnimation 对 geometry、opacity 等属性做动画,实现平滑的渐入渐出交互。压缩包共 12 个文件,以 4 个 cpp 源文件、3 个 h 头文件、3 个 ui 界面文件和 1 个 pro 工程文件为主,另有 1 个 user 配置,整体约 9KB,体量轻便,便于直接打开编译调试。目前已有 1242 人学习。示例中可看到自定义 SideWidget 类、onSlideOut() 与 onSlideIn() 槽函数、信号与槽的连接方式,以及通过 duration、easingCurve 调整动画节奏的思路,也涉及按钮点击、鼠标事件等触发方式。读者可据此理解 Qt 动画框架的基本用法,并将其迁移到导航栏、设置面板等常见界面,作为 UI 动效入门与改造的参考模板。
1. 一块贴着屏幕边缘滑出来的面板,为什么值得单独做成工程
上个月给一个桌面工具加功能,产品要的是「主窗口之外再挂一块东西」:放快捷入口、未读消息流,再加几个调试开关。试过 QDockWidget,停靠区一开主窗口布局就全乱;试过 QMenu,鼠标一移开就没了,没法在里面操作;试过 QDialog,一弹出来就把焦点抢走,主窗口的输入框直接失焦。最后落到最土也最靠谱的做法上:一个无边框的独立 QWidget,贴着屏幕右边,鼠标靠近时从屏幕外滑进来、透明度从 0 到 1,离开后自己滑回去。这套 Qt 侧边栏渐入渐出的效果,其实就一个 QWidget 加两个 QPropertyAnimation,但它牵扯到窗口标志、透明背景、多屏坐标、焦点策略一堆细节,随便哪个没处理对,动画就会抖、会闪、会残影。SideWidget 这个工程就是把这一套单独抽出来做成最小可跑的例子,纯 Qt Widgets,不用 QML,没有第三方依赖,适合正在写桌面工具、需要一个「第二块界面」又不想撑大主窗口的人。
2. 工程骨架:SideWidget 里到底分了几个角色
拿到压缩包解压,第一眼看到的是一个标准的 Qt Widgets 小工程,没有 CMake 和 qmake 混用,也没有把业务代码塞进 main.cpp 的坏习惯。它把「窗口壳」「内容面板」「动画驱动」三件事拆开了,虽然量不大,但边界划得清楚,后面要往里加东西的时候不会互相扯。
2.1 三个类各管一摊
SideWidget是外壳,负责窗口标志、屏幕边缘定位、渐入渐出的动画调度,以及把「藏起来」和「展开」两种几何状态算出来。它不知道面板里放的是列表还是按钮。
SidePanel继承 QWidget,是内容容器,负责排版和样式。它对外只暴露一个信号,比如某个条目被点了,往外抛一个itemActivated(QString)之类的东西,主窗口接住就行。这一层单独拆出来的好处是,你换个业务场景,直接换掉 SidePanel 的实现,外壳一行不用动。
SideAnimator严格说是可有可无的,SideWidget 里直接持有动画对象也完全能跑。但把动画抽成一个小类之后,好处是动画的时长、缓动曲线、方向可以统一配置,而且「打断正在播放的动画并反向播放」这种逻辑集中在一处,不然在 enterEvent 和 leaveEvent 里各写一遍,早晚要写出两套不一致的行为。
三个角色的调用关系很直白:主窗口或者托盘图标触发 → SideWidget::showPanel() → 内部让 SideAnimator 从 hiddenGeometry 到 shownGeometry 播一遍位移动画,同时把 opacity 从 0 拉到 1;反向就是 hidePanel()。所有跨类通信走信号槽,没有全局单例,也没有静态变量。
2.2 构建配置:qmake 和 CMake 都留了一份
工程里给了两份构建脚本,.pro和CMakeLists.txt,内容等价,选一个用就行。我一般建议新工程直接上 CMake,Qt 6 之后官方也是这个倾向;但如果你的宿主项目是老 qmake 工程,把 .pro 抄进去更省事。
# SideWidget.pro 关键部分 QT += core gui widgets CONFIG += c++17 TARGET = SideWidget TEMPLATE = app SOURCES += \ main.cpp \ sidewidget.cpp \ sidepanel.cpp \ sideanimator.cpp HEADERS += \ sidewidget.h \ sidepanel.h \ sideanimator.h # Windows 下想去掉控制台窗口就加这一行 # CONFIG -= consoleQT += widgets是必须的,虽然 Qt 6 里 gui 已经被 widgets 隐式带上,但显式写出来不吃亏。CONFIG += c++17是因为后面用到了enum class和 lambda 捕获,C++11 也能编,但 17 更省心。
CMake 那份长这样:
cmake_minimum_required(VERSION 3.16) project(SideWidget LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 就必须开 find_package(Qt6 COMPONENTS Widgets REQUIRED) # 用 Qt5 的话换成 find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(SideWidget main.cpp sidewidget.cpp sidepanel.cpp sideanimator.cpp ) target_link_libraries(SideWidget PRIVATE Qt6::Widgets)CMAKE_AUTOMOC ON是新手最容易漏的一行,漏了的表现是编译报「undefined reference to vtable for SideWidget」,看着像链接库没配好,其实是 moc 没跑,这个坑后面第 5 章还会提。
提示:Qt 5.15 和 Qt 6.x 我都编过这个工程,唯一的源码差异在
enterEvent的签名——Qt 6 是void enterEvent(QEnterEvent *event),Qt 5 是void enterEvent(QEvent *event)。用宏#if QT_VERSION >= QT_VERSION_CHECK(6,0,0)包一下就能两边通吃。
2.3 目录结构和每个文件的职责
SideWidget/ ├── SideWidget.pro # qmake 工程文件 ├── CMakeLists.txt # CMake 工程文件(二选一) ├── main.cpp # 入口,造主窗口 + 造 SideWidget ├── sidewidget.h/.cpp # 外壳:窗口标志、边缘定位、动画调度 ├── sidepanel.h/.cpp # 内容面板:布局、样式、对外信号 ├── sideanimator.h/.cpp # 动画封装:时长、曲线、方向、打断 └── resources/ └── style.qss # 面板样式表,圆角、背景、滚动条main.cpp 里做的事情很少:创建主窗口、创建 SideWidget 并 setEdge(Edge::Right)、把主窗口的一个按钮连到 showPanel。真正有信息量的是 sidewidget.cpp,第 3 章会逐段拆。
resources/style.qss单独抽出来而不是写死在代码里,是因为侧边栏这种东西样式改得比逻辑勤。用qrc打包进去,加载方式就一行:
QFile f(":/style.qss"); if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { qApp->setStyleSheet(QString::fromUtf8(f.readAll())); }读不到文件就静默跳过,不要弹框,样式丢了程序还能跑,弹框反而打断用户。
3. 渐入渐出的核心:位移和透明度怎么同步
侧边栏的动画拆开看就两件事:位置从屏幕外移动到贴边位置,透明度从 0 变到 1。听起来简单,但这两个动画如果分开播,或者用 QTimer 手动插值,就会出现「先变透明再滑进来」或者「滑到一半突然出现」的割裂感。正确做法是用 QParallelAnimationGroup 把它们绑在一起,同一个时钟驱动,同一个缓动曲线。
3.1 窗口标志和透明背景,动画的前提
在写动画之前,得先把窗口本身配置对。侧边栏要无边框、不抢焦点、背景可透明,这三件事任何一个没做,后面的动画都是白搭。
SideWidget::SideWidget(QWidget *parent) : QWidget(parent, Qt::Tool // 不在任务栏占位,跟随父窗口 | Qt::FramelessWindowHint // 去掉标题栏和边框 | Qt::NoDropShadowWindowHint) // 关掉系统投影,阴影自己画 { // 允许背景透明,圆角才不会露出方角黑边 setAttribute(Qt::WA_TranslucentBackground); // 弹出时不激活窗口,主窗口的焦点不丢 setAttribute(Qt::WA_ShowWithoutActivating); // 面板里的按钮需要能点,所以不能整体设成 Qt::NoFocus setFocusPolicy(Qt::NoFocus); }Qt::Tool的效果是窗口不出现在任务栏,并且父窗口最小化时它跟着隐藏,这点比Qt::Window更符合「附属面板」的语义。WA_TranslucentBackground必须配合FramelessWindowHint用,只设前者不设后者,某些平台上会出现黑底。
WA_ShowWithoutActivating是最容易被忽略的一个。不设它,侧边栏一弹出来,主窗口正在输入的 QLineEdit 立刻失焦,用户打个字打到一半光标没了,这个体验是致命的。
3.2 两种几何状态:藏起来和展开
动画的本质是在两个 QRect 之间插值。展开态好算,贴边就行;隐藏态要往外多推一点,推到完全看不见为止,注意不是推到x = 屏幕右边界,而是要推到x = 屏幕右边界 + 宽度,否则边缘会留一条缝。
// 展开时的几何:贴住屏幕右边,垂直居中 QRect SideWidget::shownGeometry() const { // 用当前屏幕的可用区域,避开任务栏 const QRect avail = currentScreen()->availableGeometry(); switch (m_edge) { case Edge::Right: return QRect(avail.right() - m_panelSize.width() + 1, avail.top() + (avail.height() - m_panelSize.height()) / 2, m_panelSize.width(), m_panelSize.height()); case Edge::Left: return QRect(avail.left(), avail.top() + (avail.height() - m_panelSize.height()) / 2, m_panelSize.width(), m_panelSize.height()); case Edge::Top: return QRect(avail.left() + (avail.width() - m_panelSize.width()) / 2, avail.top(), m_panelSize.width(), m_panelSize.height()); case Edge::Bottom: return QRect(avail.left() + (avail.width() - m_panelSize.width()) / 2, avail.bottom() - m_panelSize.height() + 1, m_panelSize.width(), m_panelSize.height()); } return QRect(); } // 隐藏时的几何:整体平移出屏幕 QRect SideWidget::hiddenGeometry() const { QRect r = shownGeometry(); switch (m_edge) { case Edge::Right: r.moveLeft(r.left() + r.width()); break; case Edge::Left: r.moveLeft(r.left() - r.width()); break; case Edge::Top: r.moveTop(r.top() - r.height()); break; case Edge::Bottom: r.moveTop(r.top() + r.height()); break; } return r; }avail.right() - width + 1这个+1是血泪经验。QRect 的right()返回的是left + width - 1,直接相减会差一个像素,表现是面板右边永远露出 1px 的透明边,在深色背景上非常明显。
currentScreen()不要写成QApplication::primaryScreen(),双屏用户把主窗口拖到副屏上,侧边栏还从主屏弹出来,这个 bug 报上来你都不好复现。实现上优先用父窗口所在的屏,父窗口为空再退到光标所在的屏:
QScreen *SideWidget::currentScreen() const { if (parentWidget() && parentWidget()->windowHandle()) return parentWidget()->windowHandle()->screen(); if (QScreen *s = QGuiApplication::screenAt(QCursor::pos())) return s; return QGuiApplication::primaryScreen(); }3.3 用 QParallelAnimationGroup 绑定两条属性动画
关键的实现就在构造函数里把动画对象建好,之后每次播都是复用,不要每次 show 都 new 一个 QPropertyAnimation,那是内存泄漏的经典写法。
void SideWidget::initAnimations() { // 透明度:QGraphicsOpacityEffect 的 opacity 属性是 qreal,0.0 ~ 1.0 m_opacity = new QGraphicsOpacityEffect(this); m_opacity->setOpacity(0.0); setGraphicsEffect(m_opacity); // 位移:直接动 QWidget 自带的 pos 属性 m_posAnim = new QPropertyAnimation(this, "pos", this); m_posAnim->setEasingCurve(QEasingCurve::OutCubic); m_opacityAnim = new QPropertyAnimation(m_opacity, "opacity", this); m_opacityAnim->setEasingCurve(QEasingCurve::OutCubic); // 并行动画组:两条动画共享一个时钟,不会出现先透明后位移 m_group = new QParallelAnimationGroup(this); m_group->addAnimation(m_posAnim); m_group->addAnimation(m_opacityAnim); }QPropertyAnimation(this, "pos", this)的第二个参数是属性名,必须是 Q_PROPERTY 声明过的,pos和geometry是 QWidget 自带的,直接能用。第三个参数是 parent,传 this 之后跟着 SideWidget 一起析构,省心。
QGraphicsOpacityEffect有个限制:一个 QWidget 只能有一个 graphicsEffect,设第二次会把第一个顶掉。所以如果你还想在面板上加模糊或者阴影效果,得用嵌套 QWidget 的方式,外层挂 opacity,内层挂别的,不能都挂在同一个对象上。
3.4 showPanel / hidePanel 的完整实现
动画对象建好之后,播的时候要做三件事:停掉正在跑的、重置起点和时长、启动。少了任何一步都会出问题。
void SideWidget::showPanel() { m_hideTimer->stop(); // 用户又回来了,取消待执行的收起 if (m_shown && isVisible()) return; m_group->stop(); // 关键:打断正在播放的渐出 m_posAnim->setDuration(m_duration); m_posAnim->setStartValue(pos()); m_posAnim->setEndValue(shownGeometry().topLeft()); m_opacityAnim->setDuration(m_duration); m_opacityAnim->setStartValue(m_opacity->opacity()); m_opacityAnim->setEndValue(1.0); // 第一次显示时窗口还没 map,必须先把几何摆到隐藏位置再 show if (!isVisible()) { setGeometry(hiddenGeometry()); show(); // show 之后 pos 才生效,此时再启动动画 m_posAnim->setStartValue(hiddenGeometry().topLeft()); } m_group->start(); m_shown = true; } void SideWidget::hidePanel() { if (!m_shown || !isVisible()) return; m_group->stop(); m_posAnim->setDuration(m_duration); m_posAnim->setStartValue(pos()); m_posAnim->setEndValue(hiddenGeometry().topLeft()); m_opacityAnim->setDuration(m_duration); m_opacityAnim->setStartValue(m_opacity->opacity()); m_opacityAnim->setEndValue(0.0); // 动画播完才真正 hide,中途打断就不藏 if (m_hideConn) disconnect(m_hideConn); m_hideConn = connect(m_group, &QParallelAnimationGroup::finished, this, [this] { if (!m_shown) hide(); }); m_group->start(); m_shown = false; }m_shown这个标志位不是冗余的。用户在动画播到一半时又把鼠标移回来,showPanel()会被调用,此时m_shown已经是 false,但isVisible()还是 true,靠这两个条件才能正确判断「需要反向播放」而不是「重新从屏幕外滑进来」。finished信号的回调里再查一次m_shown,是为了防止「动画播完的瞬间用户又进来了」这种竞态——Qt 的事件循环里这种事真的会发生。
m_duration我默认给 220ms。试过 150ms,快是快,但位移距离有三百多像素,太快会有拖影感;试过 400ms,慢悠悠的像卡了。220 到 260 之间是比较舒服的区间,具体还得看你面板多宽。
缓动曲线用OutCubic,起步快、收尾慢,符合「滑出来停住」的物理直觉。想更弹一点可以用OutBack,但要注意OutBack会在终点附近过冲,如果面板贴着屏幕边缘,过冲那一下会被屏幕裁掉,看起来像抖了一下。
4. 交互细节:鼠标、焦点、多屏,一个都不能少
动画写完之后能跑,但跑起来像玩具。真正的侧边栏要处理鼠标快速划过、子控件之间移动、快捷键不被打断、屏幕缩放切换,这些才是花时间的地方。
4.1 enterEvent / leaveEvent 加防抖
最朴素的写法是进就展开、出就收起,代码五行搞定。但实际操作中,用户鼠标从主窗口挪到侧边栏,中间可能经过一两像素的空隙,leaveEvent立刻触发,面板刚开始滑就被收回去了,看起来就是闪一下。
解决办法是 leave 之后不立刻收,起一个单次定时器,延迟 300ms 左右再收。用户在这 300ms 内回来了,就把定时器停掉。
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void SideWidget::enterEvent(QEnterEvent *e) #else void SideWidget::enterEvent(QEvent *e) #endif { m_hideTimer->stop(); // 取消待收起 showPanel(); #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QWidget::enterEvent(e); #else QWidget::enterEvent(e); #endif } void SideWidget::leaveEvent(QEvent *e) { m_hideTimer->start(); // 350ms 后才真正收 QWidget::leaveEvent(e); }定时器初始化:
m_hideTimer = new QTimer(this); m_hideTimer->setSingleShot(true); // 只触发一次 m_hideTimer->setInterval(350); // 留出反悔的时间 connect(m_hideTimer, &QTimer::timeout, this, &SideWidget::hidePanel);只处理外壳的 enter/leave 还不够。面板里面有一堆子控件(按钮、列表项),鼠标从外壳空白处移到某个按钮上,QWidget 会先给外壳发 leaveEvent 再给按钮发 enterEvent,外壳就以为鼠标走了。这个 bug 的典型现象是:鼠标在面板里移动,面板在抽搐。
标准解法是给所有子控件装事件过滤器,凡是进入子控件就把定时器停掉:
bool SideWidget::eventFilter(QObject *obj, QEvent *e) { switch (e->type()) { case QEvent::Enter: case QEvent::HoverEnter: m_hideTimer->stop(); break; default: break; } return QWidget::eventFilter(obj, e); }然后在把子控件加进布局的时候统一装一遍,别一个个手写:
void SideWidget::installFilterRecursive(QWidget *w) { for (QObject *child : w->children()) { if (auto *cw = qobject_cast<QWidget *>(child)) { cw->installEventFilter(this); cw->setAttribute(Qt::WA_Hover, true); // 让 HoverEnter 能出来 installFilterRecursive(cw); } } }WA_Hover必须显式打开,默认是不发 HoverEnter 的,不设的话过滤器里只能收到 Enter,某些情况下收不到。
4.2 焦点策略:别把主窗口的输入抢走
侧边栏里如果有 QLineEdit,用户点进去就应该能输入,但侧边栏本身不应该主动抢焦点。这里的配置分三层:
外壳setFocusPolicy(Qt::NoFocus),面板setFocusPolicy(Qt::StrongFocus),可交互的输入控件保持默认。这样点面板空白处焦点不落在侧边栏上,点输入框才进入编辑状态。
如果侧边栏是快捷键唤出的(比如按下某个组合键),还得处理按下 Esc 收起的逻辑:
void SideWidget::keyPressEvent(QKeyEvent *e) { if (e->key() == Qt::Key_Escape) { hidePanel(); e->accept(); return; } QWidget::keyPressEvent(e); }注意hidePanel()之后不建议调setFocus()把焦点还给主窗口,Qt 在Qt::Tool窗口隐藏时会自动还回去。手动再设一次,反而可能设到错误的对象上。
4.3 多屏 / DPI 缩放切换时的坐标重算
用户把主窗口从 1080p 的屏拖到 4K 屏上,devicePixelRatio变了,availableGeometry也变了,侧边栏还在老位置上待着,看起来像「跑到另一个屏幕去了」。
需要监听屏幕变化信号:
// 在构造函数里接上 if (auto *wh = windowHandle()) { // 如果窗口还没创建 handle,就延后到 showEvent 里接 } // showEvent 里补一刀 void SideWidget::showEvent(QShowEvent *e) { static bool hooked = false; if (!hooked && windowHandle()) { // 窗口所在屏幕变了 connect(windowHandle(), &QWindow::screenChanged, this, [this](QScreen *) { repositionImmediately(); }); hooked = true; } QWidget::showEvent(e); } // 立即归位,不播动画 void SideWidget::repositionImmediately() { const QRect target = m_shown ? shownGeometry() : hiddenGeometry(); m_group->stop(); setGeometry(target); m_opacity->setOpacity(m_shown ? 1.0 : 0.0); }screenChanged的信号在 QWindow 上,QWidget 的windowHandle()只有在窗口真正创建之后才非空,所以这段逻辑不能放构造函数里,得放到showEvent或者winId()之后。
另外提一句QScreen::availableGeometry()和geometry()的区别:前者扣掉了任务栏和系统停靠区,后者是整块屏幕。侧边栏要贴「可用区域」的边,用前者;如果用户把任务栏放在右侧,用后者面板会被任务栏压住。
4.4 与主窗口的联动:联动开关和主题同步
侧边栏和主窗口之间总有些状态要同步。最常见的是主题切换——主窗口切到深色,侧边栏还白着,很出戏。
做法是主窗口抛信号,SideWidget 接:
// MainWindow 里 signals: void themeChanged(bool dark); // 构造 SideWidget 之后 connect(mainWindow, &MainWindow::themeChanged, sideWidget, &SideWidget::applyTheme); // SideWidget 里 void SideWidget::applyTheme(bool dark) { // 只改自己的样式表,不碰全局 qApp->setStyleSheet setStyleSheet(dark ? "SideWidget { background: rgba(28,30,36,235); }" : "SideWidget { background: rgba(250,250,252,240); }"); m_panel->applyTheme(dark); }这里刻意不调qApp->setStyleSheet(),全局样式表会触发整个应用的样式重算,主窗口复杂的时候能卡一下,而且容易把别处的样式覆盖掉。改局部对象的 setStyleSheet 影响面小得多。
如果侧边栏是常驻显示的,还需要跟主窗口的「显示/隐藏侧边栏」菜单项联动,用QAction::toggled直接连到showPanel/hidePanel就行。注意菜单勾选状态和实际可见状态要保持一致,用户从菜单关掉、又用鼠标悬停打开,菜单里的勾还在,这种不一致积累多了就很烦。
5. 避坑与排查:侧边栏动画最容易翻车的五个地方
这一章都是实际调试时记下来的。侧边栏这种「小窗口 + 动画 + 透明」的组合,出问题的现象往往很诡异,但只要知道去哪看,定位都不难。
5.1 第一次弹出没有动画,直接闪出来
现象:程序启动后第一次触发 showPanel,面板是「啪」一下出现的,第二次之后才有渐入效果。
原因:窗口第一次show()的时候,底层窗口还没创建完成,pos属性的设置不会立刻生效,动画的起始位置被系统当成默认位置,跟终点重合,插值就等于没动。
解决:show()之前先setGeometry(hiddenGeometry()),show()之后再把动画的 startValue 重置一次。第 3.4 节里if (!isVisible())那个分支就是干这个的。另外可以提前在构造函数末尾调一次winId(),强制创建原生窗口,代价是会稍微拖慢启动。
5.2 动画播完窗口还在,鼠标能点到底下的东西
现象:面板已经「看不见」了,但鼠标移到那个区域,点击事件被吃掉,主窗口点不动。
原因:只把 opacity 设成 0 或者把窗口移出屏幕,窗口本身还是 visible 状态,仍然接收鼠标事件。移到屏幕外的窗口在某些平台上仍会吃掉边缘的点击。
解决:动画的finished信号里调hide(),别只靠透明度。同时注意hide()之后要把几何重置回 hiddenGeometry,不然下次 show 的时候窗口从上次的位置冒出来。
5.3 QGraphicsOpacityEffect 和 QSS 打架,圆角没了
现象:给 SideWidget 设了border-radius,展开状态下圆角正常,动画过程中圆角变成直角,动画结束又变回圆角。
原因:QGraphicsOpacityEffect是把整个窗口先渲染到一张离屏 pixmap 上再做 alpha 混合,离屏渲染时如果背景没开透明,圆角外的区域会被填成不透明。
解决:确认WA_TranslucentBackground已开,FramelessWindowHint已开,然后在paintEvent里自己画圆角矩形,别依赖 QSS 的 border-radius:
void SideWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); QPainterPath path; // 只对靠近屏幕内侧的两个角做圆角,贴边的那侧保持直角 path.addRoundedRect(rect().adjusted(0, 0, -1, -1), 12, 12); p.fillPath(path, m_dark ? QColor(28, 30, 36, 235) : QColor(250, 250, 252, 240)); }rect().adjusted(0, 0, -1, -1)又是那个一像素问题,不减的话抗锯齿的边缘会被裁掉一半,看起来毛毛的。
5.4 面板里的 QOpenGLWidget 不跟着透明
现象:面板里放了一个用 OpenGL 画的图表,其他部分透明度动画正常,就这个控件一直是全不透明的。
原因:QGraphicsOpacityEffect走的是 QPainter 的合成路径,而 QOpenGLWidget 是独立的原生子窗口,不参与父窗口的离屏合成。
解决:这种控件不要试图用 effect 做透明度。要么用setWindowOpacity()走整个顶层窗口的合成,要么在 OpenGL 内部自己做 alpha。我一般的做法是避免在需要透明度动画的窗口里放 QOpenGLWidget / QQuickWidget,实在要放,就把动画改成「位移 + 缩放」不带透明度,视觉上也能接受。
5.5 多屏 + 125% 缩放后位置偏了半个面板
现象:笔记本外接显示器,两块屏缩放比例不同,侧边栏在主屏正常,拖到副屏上位置就偏,或者宽度看着不对。
原因:availableGeometry()返回的是逻辑像素,而窗口的实际物理尺寸受devicePixelRatio影响。如果代码里手动算过width * ratio之类的,两边就打架了。
解决:全流程只用逻辑像素,不要手动乘devicePixelRatio。Qt 在高 DPI 下的默认行为就是把所有坐标统一到逻辑像素,你手动乘一遍等于乘了两次。如果确实拿不到正确尺寸,检查有没有设Qt::AA_EnableHighDpiScaling(Qt 5 需要显式设,Qt 6 默认开启)。
注意:调试多屏问题时,别只在开发机上试。接个投屏、改一次缩放比例、把任务栏换到左边,各试一遍,能提前发现八成坐标问题。
6. 二次开发:把 SideWidget 改成你自己的工具面板
这套东西拿过去,基本不用改动画,改的是内容。下面这张表是我改过几次之后总结的对应关系,想动哪里直接按表找。
| 想改的东西 | 改哪个文件 | 关键点 |
|---|---|---|
| 面板里放什么 | sidepanel.cpp | 换布局和控件,对外信号保持itemActivated(QString)不变 |
| 从哪边滑出来 | sidewidget.cppsetEdge() | Left/Right/Top/Bottom 四种,几何计算已封装 |
| 动画快慢 | sideanimator 的 duration | 200~260ms 比较稳,超过 400ms 显拖沓 |
| 圆角大小和配色 | paintEvent + style.qss | 圆角在 paintEvent 里改,颜色两处都要改 |
| 触发方式 | main.cpp | 鼠标悬停、托盘图标、全局快捷键都可以接 showPanel |
触发方式换成全局快捷键的话,用 QShortcut 挂在主窗口上就够了,别去碰系统级热键,那玩意跨平台差异太大:
// 主窗口里:Alt+S 显隐侧边栏 auto *sc = new QShortcut(QKeySequence("Alt+S"), this); connect(sc, &QShortcut::activated, this, [this] { if (m_sideWidget->isShown()) m_sideWidget->hidePanel(); else m_sideWidget->showPanel(); });验证改动有没有搞坏动画,我有个笨办法但很有效:在showPanel()和hidePanel()里各打一行qDebug(),输出当前的pos()和m_opacity->opacity(),然后快速连续触发五六次,看日志里的起点值是不是每次都接着上一次的当前值。如果日志里出现「起点直接等于终点」,说明打断逻辑坏了,动画会跳。这招比肉眼看动画准得多,尤其是 220ms 这种一闪而过的时长。
还有一个改造方向值得试:把单块面板改成「主面板 + 二级抽屉」,点主面板里的某一项,二级面板从主面板内侧再滑出来一层。实现上就是再套一个 SideWidget,edge 设成同样的方向,只是宽度更小、动画延迟 60ms 启动。用 QSequentialAnimationGroup 或者 QTimer::singleShot 都能做,但要注意两个面板的 leave 定时器要互相 cancel,不然鼠标从主面板滑到二级面板的瞬间,主面板会判定「鼠标走了」然后开始收,二级面板就悬空了。
那次改双层抽屉,我在 leaveEvent 里漏了m_hideTimer->stop()的跨对象调用,主面板收起来的时候二级面板还在屏幕上飘着,而且因为父窗口隐藏了,二级面板的坐标参照系没了,直接飞到屏幕左上角。找了两个小时才发现是定时器没共享。从那以后我每次做这类联动面板,都强制走一遍「快速划进划出十次 + 从主面板划到二级面板再划回来」的验收,宁可多花十分钟,也不想再看到窗口飞走。希望这些能帮你少走点弯路。
工程解压后直接用 Qt Creator 打开SideWidget.pro就能编,Qt 5.15 和 Qt 6.x 都验证过;用 CMake 的话把CMakeLists.txt拖进去,记得编译器选带 Qt 的那套 Kit。
本文还有配套的精品资源,点击获取