尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Qt自定义ToolBox组件:从QToolBox到高性能可扩展实现

Qt自定义ToolBox组件:从QToolBox到高性能可扩展实现 简介这是一份面向Qt开发者的自定义工具箱控件教程包基于VS2015Qt5.9环境解决标准QToolBox样式固定、多节点展开交互单一的问题。压缩包内共62个文件核心为12个C源文件、7个头文件以及Visual Studio工程文件sln/vcxproj/ui另有编译生成的obj、pdb和exe及界面预览图包体仅7.73MB可打开工程直接对照运行。目前已有3207人学习。资源围绕继承QToolBox创建MyToolBox展开详细演示了重写paintEvent完成背景、边框与折叠图标的自绘以及showEvent/hideEvent的状态更新并利用QVector等结构管理节点、通过点击事件切换父节点展开状态、结合布局动态添加或移除子控件。借助这份源码示例可快速掌握自定义控件的事件处理与自绘思路并在此基础上扩展更多交互样式。 做桌面客户端特别是组态软件、上位机、编辑器这类项目时侧边往往需要一个“工具箱”里面放着一排可折叠的分组点开一组能看到对应的控件、图元或者操作项。QT 生态里自带 QToolBox很多人第一反应是“直接用”但真把它放进产品时默认的外观和交互往往撑不起一个像样的界面样式是上个世纪的按钮布局只能改个颜色没有任何动画和反馈想给某个分组加个角标、加个开关都要绕到内部结构里去 hack。所以我在项目里没有直接用 QToolBox而是自己实现了一套自定义 toolbox 组件。这篇文章把核心设计和踩过的坑都记下来给同样被这个控件困扰的人一个可以照抄的参考。1. 需求定位为什么现成的QToolBox不够用1.1 默认QToolBox的三个痛点先说我实际用 QToolBox 遇到的三个主要问题这也是下决心自己写的直接原因。第一个是外观。QToolBox 默认绘制是老的 Windows 风格按钮区是灰底黑字分隔线明显没有圆角、没有悬停高亮、没有选中态。虽然可以用 QSS 覆盖一部分样式但 QToolBox 内部用的是 QToolBoxButton这类内部控件在样式表里的可定制粒度有限想要“头部左侧图标、中间标题、右侧一个自定义角标再加上箭头”这种常见布局纯靠样式表基本做不出来。第二个是交互。展开/收起切换时没有过渡动画点击瞬间内容区直接跳变体验很僵硬。而且展开状态的行为是“同时只能展开一个分组”这在很多场景下并不合理。比如我在做图元面板时希望“基础图元”和“常用组合”两组能同时展开方便互相拖拽但 QToolBox 的默认逻辑不允许还得绕道去改。第三个是数据绑定。实际项目里每个分组往往挂着一批业务对象比如图元列表、配置项、宏命令。直接用 QToolBox 的话每个按钮和内容页都是控件内部维护的外部要拿到“这个分组绑定了哪种业务”只能靠 index 做映射加一个删一个都很容易错位。提示如果你的项目只是临时工具对界面不敏感QToolBox 完全可用。但只要是面向正式产品的界面这三个问题大概率会成为你改造它的理由。1.2 两条路魔改QToolBox还是重新造轮子我当时评估过两条路。一条是继承 QToolBox重写它的 paintEvent 和一些内部逻辑。好处是保留了现成的分页管理、currentChanged 等信号工作量相对小坏处是它内部的布局结构是固定写死的很多交互细节还是控制不了。最后得到的还是一套“很 QToolBox”的组件只是外观变好看了你要的头部长按、右键菜单、内容动画还是很难加。另一条是自己搭骨架用一个 QScrollArea 做外层滚动容器每个分组由一个“头部按钮内容区”组成的自定义控件整体用 QVBoxLayout 纵向排列。这样所有逻辑都在自己手里想加什么交互都行。代价是要自己处理折叠状态管理、高度变化、QSS 自定义属性这些细节。最终我选了第二条。原因很简单与其在 QToolBox 的内部结构上做“逆向工程”不如用一个清晰的模型自己实现。整体搭建时间并不比魔改多多少但后期扩展空间完全不一样。提示选型时先想清楚你要的交互边界。如果只是换个皮肤魔改够用如果你要的是“独立控件、业务驱动、动画反馈”这些能力自建骨架是更稳的选择。2. 核心架构把自定义toolbox拆成三层2.1 数据层先定义分组模型在写界面之前我会先把数据模型定下来避免界面改到一半业务数据不知道怎么映射进去。我定义了一个很简单的结构体struct ToolBoxItem { QString id; // 组的唯一标识业务层用它关联数据 QString title; // 分组标题 QIcon icon; // 头部图标 QWidget* widget; // 分组内容区由业务层传入 bool expanded false; // 初始是否展开 bool enabled true; // 是否允许交互 };int index 适合在循环里临时用不适合作为跨模块的唯一标识。因为一个分组被删除后后面所有分组的 index 都会变化之前保存的状态、信号里的 index 全部错位。而 id 是稳定的删除、插入、排序都不影响它的指向所以数据层里优先用 id 做锚点。对应地我在组件里维护一个 QList 再配一个 QHashQString, ToolBoxItem* 做 id 到分组的快速索引。这样增删改查的时间复杂度基本是常数级业务层反馈也及时。2.2 视图层每个分组怎么搭每个分组是一个 ToolBoxGroup 控件继承 QWidget。它的结构很直白头部是一个 QPushButton或者自定义的 QAbstractButton放 id、icon、title、角标内容区是一个 QWidget内部用 QVBoxLayout 装业务页面通过改变内容区的 maximumHeight 来控制展开/收起。外层用一个 QScrollArea 包住所有 ToolBoxGroup因为分组多的时候整个工具箱本身会超过可见区域需要滚动。为什么内容区不直接用 QStackedWidget因为 QStackedWidget 同一时间只显示一页而且它的高度是固定的没法让多个分组“同时展开”并且内容区高度随内容变化。我要的是每个分组在垂直布局里占据自己的真实高度该展开就展开该收起就收起到只剩头部。所以这里用普通 QWidget 布局更合适。每个 ToolBoxGroup 的固定结构大概是void ToolBoxGroup::initUi() { m_header new QPushButton(this); m_header-setObjectName(toggleButton); m_header-setCheckable(true); m_content new QWidget(this); m_contentLayout new QVBoxLayout(m_content); m_contentLayout-setContentsMargins(0, 0, 0, 0); auto *outer new QVBoxLayout(this); outer-setContentsMargins(0, 0, 0, 0); outer-setSpacing(0); outer-addWidget(m_header); outer-addWidget(m_content); }2.3 控制器层折叠状态怎么统一管理我把折叠状态收敛到一个 ToolBoxManager 类里而不是让每个 ToolBoxGroup 自己随便开关。原因很简单产品往往有“一次只展开一组”或“允许多组同时展开”两种模式如果状态分散在各分组里切换模式时逻辑会乱。ToolBoxManager 对外提供两个主要接口void setExpandMode(ExpandMode mode); // Single / Multi void setExpanded(const QString id, bool expanded);Single 模式下展开一组时先收起其他组Multi 模式下各自独立开关。展开状态变化时Manager 会发出 groupToggled(id, expanded)业务层拿这个信号做联动。这里有一个细节Single 模式先收起其他组再展开目标组如果连续触发两次要避免状态回跳。所以我在 Manager 里记录了当前展开的 id 列表每次先清理本组状态再更新防止事件顺序问题。3. 交互细节动画、事件与视觉定制3.1 折叠动画别只想到height动画折叠动画最简单的做法是给内容区的 maximumHeight 做插值void ToolBoxGroup::setExpanded(bool expanded) { if (m_expanded expanded) return; m_expanded expanded; auto *anim new QPropertyAnimation(m_content, maximumHeight); anim-setDuration(180); anim-setStartValue(m_content-height()); anim-setEndValue(expanded ? m_content-sizeHint().height() : 0); anim-setEasingCurve(QEasingCurve::InOutCubic); connect(anim, QPropertyAnimation::finished, anim, QObject::deleteLater); anim-start(QAbstractAnimation::DeleteWhenStopped); }实际用的时候有几个坑。第一直接设 startValue 为 m_content-height() 在连续点击时会有问题如果上一次动画还没结束height() 是中间值新动画从这里再开始会造成“回弹”。我的做法是每次开新动画前先 stop 掉旧动画并把 currentValue 取出来作为新起点。第二只改 maximumHeight 会有抖动。因为内容区内部布局在高度变化时会重新排布尤其是里面有复杂控件时重绘开销比较大。我建议内容区用“先固定宽度再自动高度”的思路必要时配合 setUpdatesEnabled(false) 包住动画过程动画结束后再统一更新。第三动画时长不宜过长。我实际测试下来 150~200ms 是最舒服的区间超过 250ms 用户会明显感觉“拖沓”。这个值不要做成固定常量建议在管理器里提供一个 setAnimationDuration(int ms) 让调用方按场景调整。3.2 用QSS把工具箱做得不像系统控件QSS 是 QT 界面定制最舒服的入口。但 QSS 的生效依赖控件的 objectName 和自定义属性。我的 QSS 大致长这样ToolBoxGroup QPushButton#toggleButton { background: transparent; border: none; border-bottom: 1px solid #e5e5e5; padding: 10px 14px; text-align: left; } ToolBoxGroup QPushButton#toggleButton:hover { background: #f5f7fa; } ToolBoxGroup QPushButton#toggleButton:checked { background: #f0f4ff; border-left: 3px solid #1976d2; }这里我用了 QPushButton 的 checked 属性表示展开态配合 :checked 伪状态来高亮当前组。这样点击头部切换时视觉反馈就是天然的不用额外写代码。如果你需要“选中组”和“展开组”是两个概念那就不能用 checked 了。这时可以用一个自定义属性比如 setProperty(active, true)然后在 QSS 里写 ToolBoxGroup[activetrue] QPushButton#toggleButton { ... }。但是要注意动态修改属性后需要手动刷新样式否则不生效style()-unpolish(button); style()-polish(button); button-update();这个细节经常被漏掉结果就是 QSS 怎么改都没反应。3.3 事件过滤让整块头部都可以点击默认的按钮点击区域只覆盖按钮自身。如果我要做一个“整块头部都可点击”的分组最简单的办法是把头部做成尽量撑满宽度并拦截鼠标事件发送到按钮。我的做法是给头部所在的 ToolBoxGroup 装事件过滤器bool ToolBoxGroup::eventFilter(QObject *obj, QEvent *ev) { if (obj m_header) { if (ev-type() QEvent::MouseButtonRelease) { toggleExpanded(); return true; } } return QWidget::eventFilter(obj, ev); }这里有个容易踩的坑如果头部还包含子控件比如单独放了一个箭头图标、角标 Label子控件收到点击时事件未必传递到 m_header。最简单的规避方法是不要让头部包含“需要处理点击”的子控件所有元素都用 QSS 背景或自绘来表现实在要放多个控件就把事件过滤器挂到头部所在容器上再用 childAt(event-pos()) 判断点击位置是否在可点击区域内。事件处理还有个键盘可达性的问题。产品经理可能会要求“Tab 切到分组头按空格展开”。这时 m_header 本身是 QPushButton天然支持键盘聚焦只要把 toggleExpanded 绑定到 clicked 信号并用 setFocusPolicy(Qt::StrongFocus) 让头部可聚焦即可不需要额外处理。4. 业务接入把工具箱变成可复用组件4.1 信号设计别把控件内部细节暴露出去组件对外信号的设计直接决定了业务层好不好用。我定义了三类信号基本覆盖常规需求signals: void groupToggled(const QString groupId, bool expanded); void itemClicked(const QString groupId, const QString itemId); void themeChanged(const QString themeName);groupToggled 用于折叠状态变化itemClicked 用于分组内容里某个业务项被点击themeChanged 是主题切换。设计原则是业务层不依赖控件内部的 ToolBoxGroup 指针只依赖 id 字符串。这样即使控件内部结构重写业务层的槽函数也不用动。在实际项目里很多同学喜欢把 QToolBox 的 currentChanged(int index) 直接连到业务逻辑。这个做法的隐患是一旦在中间删掉一个分组所有 index 都变了业务层存的那些“当前页序号”就全错了。所以我强烈建议在自定义组件初期就把 id 这套映射建好哪怕多写几行代码后面省的心远大于这几十行。4.2 动态增删与状态持久化提供以下接口给业务层使用void addGroup(const ToolBoxItem item); void removeGroup(const QString id); ToolBoxItem* group(const QString id); QListQString groupIds() const;业务层拿到 id 后可以动态修改标题、图标、内容区甚至可以临时禁用一个分组。禁用时我建议直接把头部按钮 setEnabled(false)QSS 里再写 :disabled 样式灰色化效果立刻出来。状态持久化这块我用 QSettings 存三样东西当前展开的分组 id、滚动条位置、用户是否自定义过排序。展开状态和滚动位置每次界面关闭时保存启动时恢复。排序信息用 QStringList 保存排序变更时整体重写一遍即可。代码大约是这样void saveState() { QSettings s(MyCompany, MyTool); s.setValue(toolbox/expandedIds, m_manager-expandedIds()); s.setValue(toolbox/order, m_manager-groupIds()); s.setValue(toolbox/scroll, m_scrollArea-verticalScrollBar()-value()); }恢复时注意顺序先 addGroup 把分组都建好再恢复排序和展开状态最后再恢复滚动位置。如果先恢复滚动位置再建分组滚动条还没出现设置值会丢。提示如果项目用不到 QSettings也可以用一个 JSON 配置文件。不管用哪种关键是“分组顺序、展开状态、滚动位置”这三个状态要分开存取不要混在一个 int 里。5. 常见问题与排查技巧实录5.1 滚动时动画卡顿现象内容区比较大时展开动画过程中出现明显的卡顿特别是高度超过 500px 时。原因动画期间 maximumHeight 每个帧都在变内容区内部控件会跟着重新布局、重绘开销成倍增加。解决办法动画时长控制在 180ms 以内动画期间给内容区 setUpdatesEnabled(false)动画结束后再恢复 true如果内容区特别复杂考虑放弃逐帧动画改成“立即展开但加一个淡入效果”体验也不差。5.2 背景透明失效与圆角被裁剪现象ToolBoxGroup 设置了 border-radius但圆角外还是能看到方形的底色或者整个分组放在 QScrollArea 里背景变成了白色块。原因QScrollArea 的 viewport 背景默认是窗口背景色QSS 只改了 ToolBoxGroup 自身的背景没改外层。圆角被裁剪则是因为内容区的 QWidget 没有同时设置透明背景。解决办法统一把外层容器、内容区、viewport 都设置成目标背景色或透明在 QSS 里写QScrollArea, QScrollAreaQWidgetQWidget, QScrollAreaQWidgetQWidgetToolBoxGroup { background: transparent; }5.3 快速点击导致多个动画叠加现象用户连续快速点击多个分组头页面出现“抽搐”或者某些组收起了一半又展开。原因每次点击都会 new 一个 QPropertyAnimation旧动画没有 stop两个动画同时操作同一个控件的 maximumHeight互相覆盖。解决办法在 ToolBoxGroup 里保存当前的动画指针开始新动画前先 stop 旧的if (m_anim) { m_anim-stop(); m_anim-deleteLater(); } m_anim new QPropertyAnimation(m_content, maximumHeight, this); // ...另外Single 模式下 Manager 还要负责在展开一组前先收起其他组这个动作也应复用同一套 stop 逻辑避免动画互相打断。5.4 QSS 样式不生效的排查QSS 不生效时按顺序排查这几种情况现象常见原因解决办法QSS 里写了属性但无效果控件没有对应的 objectName检查 setObjectName动态 setProperty 后无效果没刷新样式unpolish polish update背景透明无效外层 viewport 没设置透明QSS 中统一设置透明:checked 不生效按钮没有 setCheckable(true)setCheckable 后才有 checked 状态QSS 只在部分平台正常样式表对子控件的支持有差异用自定义属性替代子控件选择器这张排查表基本是照着踩坑记录整理的项目里看到哪条用哪条效率会高很多。最后再分享一个我自己的体会。自定义 toolbox 这个事技术难度不高但很容易在“外观调优”上陷进去一调一下午。我前两个版本就是先把 QSS 做得花里胡哨结果鼠标事件、折叠状态、动态增删这些基础能力还一堆病。后来把顺序反过来先把数据模型和折叠管理写完界面先保持最简单的白底黑字跑通增删、切换、保存恢复之后再动手调样式和动画。这样每改一项视觉都能立刻在真实业务里验证不会越调越乱。如果你也正在做类似的自定义组件我建议你按这个顺序来。本文还有配套的精品资源点击获取
返回列表