
简介面向Qt开发者的自定义工具箱控件实现资源适合需要在VS2015Qt5.9环境中构建可多节点展开、支持自定义绘制和动态布局的toolbox面板的读者。压缩包共62个文件以cpp/h源码、ui界面文件、qrc资源、png图标以及Visual Studio工程文件sln/vcxproj为主整体约7.73MB目录结构清晰便于直接打开工程查看效果。资源提供完整可运行的示例工程覆盖QToolBox继承重写、paintEvent自绘、节点数据结构管理、点击事件切换展开折叠、QVBoxLayout动态布局以及信号槽状态通知等关键实现同时也包含编译生成的exe和调试文件方便对照验证。已有3207人学习下载适合希望掌握Qt自定义控件原理并快速在其项目中复用或扩展的开发者。 写这篇东西之前先说个背景。去年我在做一个设备调试工具界面左侧需要放一个工具箱面板里面按“传感器、执行器、通信诊断”分组放工具按钮按钮还要支持拖到右侧画布生成节点。我第一反应是用QToolBox半小时后我把它删了开始自己写。这篇文章记录的就是这次自定义toolbox的全过程包括为什么标准控件不够用、核心结构怎么搭、拖拽和样式怎么处理以及几个我真实踩过、调了半天的坑。给正在做类似需求的Qt开发者参考。1. 从标准控件到自定义工具箱我为什么没直接用QToolBox先说结论QToolBox不是不能用但它默认行为偏向设置项分组而不是工具面板。两者差异在需求一具体化就会暴露。QToolBox的问题主要有三处。第一子页面区域固定。QToolBox中每个page放了什么就是什么如果页面高度超出容器它不会自己出现滚动条也没有按内容高度自动收缩的机制。工具面板通常希望折叠时高度收紧、展开时内容完整可见QToolBox对内容偏多的页面处理得很粗暴。第二按钮形态难改。默认的QToolBox使用tab按钮切换页面左侧有可展开指示箭头样式能做到的程度有限。我想让分组头变成图标标题右侧自定义状态角标比如显示该组有几个可用工具、是否在线用QToolBox要么重写样式表要么偷懒塞个QWidget到tabBar里面两种方式都很别扭。第三拖拽数据源不好接。工具面板天然要支持把工具拖到工作区这类交互。QToolBox本身不负责产生拖拽事件你需要在每个page中给子控件逐个装事件过滤器处理起来绕一圈。所以那次我定了个原则与其在QToolBox的默认行为上修修补补不如用QScrollArea QVBoxLayout 自定义可折叠分组头从交互到观感全部自己控制。这个方案灵活度高也不复杂核心代码量完全可以接受。下面把整体结构说一下方便你先有个画面。整体分三层最外层QScrollArea负责整块面板滚动设置widgetResizable(true)。中层一个透明的QWidget作为滚动内容容器内部是QVBoxLayout。内层每个分组是一个GroupWidget由分组头和工具按钮网格组成分组头点击时展开/收起工具区。这样每一层职责清楚滚动、分组、按钮各管各的后续加功能也不用推翻重来。2. 骨架设计一个可折叠分组容器的核心实现工具面板的基础单元是分组。我先定义一个数据结构描述一个工具项struct ToolItem { QString id; // 唯一标识拖拽时传给外部 QString name; // 显示名称 QString iconPath; // 图标路径 QString tooltip; // 悬停提示 QString groupId; // 所属分组 QVariant extraData; // 扩展数据比如生成节点的默认参数 };把工具项和分组分开管理新增或删除工具时只动数据源界面统一刷新。分组头的实现上我直接用QPushButton做基础然后setCheckable(true)再用样式表来控制选中和未选中状态的外观。展开和收起状态用isChecked()来表达实现起来非常直接class GroupHeader : public QPushButton { Q_OBJECT public: explicit GroupHeader(const QString title, QWidget *parent nullptr) : QPushButton(title, parent) { setCheckable(true); setChecked(true); setCursor(Qt::PointingHandCursor); connect(this, QPushButton::toggled, this, [this](bool expanded) { // 用箭头变化反馈状态样式表里配向上/向下箭头 setArrowType(expanded ? Qt::UpArrow : Qt::DownArrow); }); } };这里有个细节箭头图标不要用rotate去转直接准备两个图标展开用向上箭头收起用向下箭头。原因是不同平台对样式表的变换支持不一致在有些环境下旋转会失效到时你排查半天发现是渲染问题浪费精力。分组内容区我用QWidget作为容器内部用一个无间距布局放工具按钮。为了布局美观我固定按每行两列排列按钮用QToolButton设置setToolButtonStyle(Qt::ToolButtonTextUnderIcon)setSizePolicy让两个按钮平分宽度。QWidget *createToolGrid(const QListToolItem items) { auto *page new QWidget; auto *grid new QGridLayout(page); grid-setContentsMargins(8, 8, 8, 8); grid-setHorizontalSpacing(8); grid-setVerticalSpacing(8); for (int i 0; i items.size(); i) { QToolButton *btn createToolButton(items.at(i)); grid-addWidget(btn, i / 2, i % 2); } return page; }这样即使工具数量变化只要调用刷新函数重建这个grid即可。然后是最关键的折叠逻辑。我不用QPropertyAnimation去高度展开收起的动画而是做一个简单的页面切换内容区直接setVisible(groupHeader-isChecked())。没有动画就没有时序竞态代码简单很多对工具面板这种偏功能性组件动画反而是次要的。想要效果的话之后再考虑高度动画。折叠容器组装时需要注意一点如果分组是从0初始化的且Qt 5.15之后QScrollArea的内容变化不会自动重新计算最小尺寸所以内容区可见状态改变后必须手动触发outerWidget的updateGeometry否则滚动区域底部会空出一大块或者滚动条范围不对。我在切换时调用connect(groupHeader, QPushButton::toggled, this, [this]() { updateGeometry(); scrollAreaWidgetContents()-adjustSize(); });实测下来adjustSize()是解决滚动区域范围更新最直接有效的手段。3. 拖拽交互与数据传递把工具从面板拖到工作区工具面板没有拖拽价值就要打对折。我这次做的是从工具箱拖到右侧QGraphicsScene生成节点交互链路是按下工具按钮 - 记录拖拽来源 - 移动一定距离后启动Qt拖拽 - 外部放下时读取MIME数据。QToolButton要作为拖拽源需要重写mousePressEvent和mouseMoveEvent。按下和移动的判断我只记录全局坐标移动距离超过QApplication::startDragDistance()时再启动QDrag避免误触。void ToolButton::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { m_dragStartPos event-globalPosition().toPoint(); } QToolButton::mousePressEvent(event); } void ToolButton::mouseMoveEvent(QMouseEvent *event) { if (!(event-buttons() Qt::LeftButton)) { QToolButton::mouseMoveEvent(event); return; } if ((event-globalPosition().toPoint() - m_dragStartPos).manhattanLength() QApplication::startDragDistance()) { QToolButton::mouseMoveEvent(event); return; } QMimeData *mimeData new QMimeData; mimeData-setData(application/x-toolid, m_item.id.toUtf8()); QDrag *drag new QDrag(this); drag-setMimeData(mimeData); drag-setPixmap(m_dragPixmap); drag-setHotSpot(m_dragPixmap.rect().center()); drag-exec(Qt::CopyAction); }这里有个非常实用的经验m_dragPixmap不要直接用按钮的grab()得到的大图算上边框和文字会拖着一个很大的矩形跑视觉体验很差。我准备的是64x64的图标pixmap拖拽时就是一个图标跟着鼠标干净利落。MIME类型我自定义为application/x-toolid里面直接放工具id。目标侧接收时通过id查表拿到完整ToolItem信息不要把所有信息都塞进MIME。原因是MIME数据跨进程时最好保持精简自定义字段越多越容易出乱码问题而且拖动时拖的是引用落下时才去解析具体数据职责分离。目标侧接收时用事件重写就够了。以QGraphicsScene为例void CustomScene::dragEnterEvent(QGraphicsSceneDragDropEvent *event) { if (event-mimeData()-hasFormat(application/x-toolid)) { event-acceptProposedAction(); } else { event-ignore(); } } void CustomScene::dropEvent(QGraphicsSceneDragDropEvent *event) { const QString toolId QString::fromUtf8( event-mimeData()-data(application/x-toolid)); ToolItem item toolboxModel-findItemById(toolId); // 根据extraData创建对应节点放到event-scenePos()位置 }放下位置记得用event-scenePos()不是event-pos()。前者是场景坐标后者是视口坐标不转换的话节点位置会整体偏移在场景滚动后尤为明显。4. 让外观配得上功能QSS定制与高清屏适配工具面板这类界面外观直接影响使用意愿。默认的QToolButton长得很原生放到现代应用里很突兀所以我用一套QSS把整体观感统一起来。我这次用的样式表关键部分如下重点在分组头和工具按钮QScrollArea { border: none; background: transparent; } GroupHeader { background: #2b2f36; color: #e6e6e6; border: none; border-radius: 6px; padding: 8px 12px; text-align: left; font-weight: 600; min-height: 28px; } GroupHeader:hover { background: #353a42; } GroupHeader:checked { background: #262a30; } QToolButton { background: #3a3f47; color: #cccccc; border: 1px solid #4a4f57; border-radius: 6px; padding: 8px; min-width: 70px; } QToolButton:hover { background: #454b54; border-color: #5a6069; } QToolButton:pressed { background: #353a42; }使用QSS时有几个容易忽略的问题。第一个是子控件继承问题。如果你给分组头单独设了字体会导致包含中文标签的文字比预期大或小这通常不是字体本身问题而是样式表没有统一指定font-size某些子控件继承了默认字体。最省事的方法是在样式表开头设置QWidget { font-size: 13px; }给全局定一个基准。第二个是高清屏。如果你的应用要支持高分屏创建QApplication后设置QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);在Qt 6中这块默认开启但在Qt 5.x下如果漏了这行图标和文字会变糊。图标资源建议提供多个尺寸版本或者在加载时对高分屏使用更大尺寸的图标int dpr devicePixelRatioF(); QIcon icon; if (dpr 1.5) { icon QIcon(:/icons/tool2x.png); } else { icon QIcon(:/icons/tool.png); }第三个问题是拖拽pixmap的dpr。如果你在高分屏拖拽拖拽图片看起来发虚是因为QPixmap的devicePixelRatio没设置。创建拖拽图后显式设置m_dragPixmap.setDevicePixelRatio(devicePixelRatioF());这个细节我找了好一会儿效果差别很明显。还要说一个布局对齐的问题。默认QToolButton的文字和图标在按钮里居中但图标过大时文字会被盖住我通常在主窗口设置全局size约束。QToolButton的图标大小建议在代码里设不在QSS里设btn-setIconSize(QSize(32, 32));QSS的icon-size属性在不同平台解析不一致代码里设置更可控。5. 动态增删和状态记忆工具箱不能是个死面板工具面板在真实应用中基本都要支持动态变化。比如调试工具运行时某个传感器分组下要根据设备在线情况动态增加或移除诊断项这时候如果每次增删都重建整个toolbox会有闪烁和焦点丢失问题。我的做法是做一个增量刷新分组级刷新只有某个组的内容变化了就只重建对应的grid widget替换掉旧的。工具按钮状态级刷新如果只是启用禁用、在线离线这类状态变化不重建按钮只更新属性并触发样式的重新加载。动态添加分组的方法本质是在主布局中insertWidget但要注意插入位置。我用一个保存有序分组ID的QStringList插入新分组时按这个列表中的顺序找到位置void ToolboxPanel::insertGroup(const QString groupId, const GroupInfo info) { int insertIndex 0; int pos m_groupOrder.indexOf(groupId); if (pos 0) { // 新分组追加到末尾 insertIndex m_layout-count(); } else { // 找到该分组在布局中的索引插到它前面 insertIndex m_groupWidgetMap[groupId]-layoutIndex(); } // 创建GroupWidget并insertWidget }状态记忆这块我用了QSettings保存展开状态。原因是折叠面板的状态如果每次启动都重置用户会很烦躁尤其是工具多的时候。保存逻辑很简单void ToolboxPanel::saveState() { QSettings settings(MyCompany, MyTool); settings.beginGroup(toolbox); settings.setValue(groupState, m_groupExpandState); // QVariantMap settings.endGroup(); } void ToolboxPanel::restoreState() { QSettings settings(MyCompany, MyTool); settings.beginGroup(toolbox); QVariantMap saved settings.value(groupState).toMap(); for (auto it saved.begin(); it ! saved.end(); it) { if (m_groupWidgetMap.contains(it.key())) { m_groupWidgetMap[it.key()]-setExpand(it.value().toBool()); } } settings.endGroup(); }这个环节我建议把数据序列化成QByteArray统一保存而不是逐字段存QVariantMap。原因是在后续升级时如果字段结构变化QByteArray版本可以做整体迁移QVariantMap字段改起来容易遗漏。6. 真实项目里踩过的三个坑最后一个章节直接分享排查经历这几点都是我实际调过的网上描述得支离破碎我整理一下完整链路。第一个坑QScrollArea里内容不更新、滚动区域留白。现象是折叠一个分组后滚动条范围依然很大底部留出大片背景色。排查思路是这样先确认是布局刷新问题还是尺寸计算问题。我在折叠切换后手动调用outerWidget-adjustSize()问题解决。根因是QScrollArea的内容widget不会因为某个子widget显隐改变而自动更新sizeHint而滚动区域依据sizeHint决定滚动范围。如果不做这一步内容少了但滚动条范围还停留在之前的尺寸上。第二个坑删除工具按钮时崩溃。现象是某个分组内容变化旧的grid widget直接delete程序在下一帧崩溃。根源在于QVBoxLayout中虽然removeWidget了但删除的widget可能还被事件循环中的某个定时器或hover样式引用着。Qt中删除一个正在显示且有热点事件的widget可能触发悬停事件的延迟清理造成野指针。稳妥做法是QWidget *oldGrid m_groupWidgetMap[groupId]-takeContent(); oldGrid-deleteLater();deleteLater()会把删除压到事件循环末尾当前悬停处理完再删基本不会崩溃。这个坑在热词里也对应qt崩溃这个高频问题遇到类似情况先检查是不是在删除时有未处理的鼠标事件引用。第三个坑拖拽时目标场景的坐标偏了。现象是拖进QGraphicsScene后生成的节点没有出现在鼠标位置而是偏移了一段距离。排查链路是先在dropEvent里打印event-scenePos()和event-pos()对比后发现我错误地用了event-pos()它返回的是视口坐标。具体到代码里QGraphicsSceneDragDropEvent提供了scenePos直接用这个才是场景坐标系。如果是嵌套别的QGraphicsView还要考虑视图变换matrix需要做mapToScene转换这个情况我这次没遇到但如果你把工具箱放到多个视图中组合使用注意检查。另外补一个不算坑但容易忽略的点ToolButton按下拖拽后如果拖拽被取消按钮会残留一个pressed状态样式下次看到按钮像被按住一样。处理方式是在拖拽结束后强制清除状态connect(drag, QDrag::destroyed, this, [this]() { setDown(false); update(); });这个细节不做的话每次取消拖拽后按钮外观都会有一个假按下残留用户会以为按钮坏了。还有一件事拖拽到外部应用时MIME的data在Windows和Linux下表现略有不同Windows下setData的数据默认会带\0结尾或存在编码差异建议统一用setText传一个JSON字符串接收端从text()读取后QJsonDocument解析比自定义二进制数据跨平台安全得多。我在项目里就是把ToolItem序列化为JSON放在text里QJsonObject obj; obj[id] m_item.id; obj[name] m_item.name; mimeData-setText(QString::fromUtf8(QJsonDocument(obj).toJson()));接收端就简单了一个QJsonDocument::fromJson字段随意扩展后续加参数不用改MIME类型定义。我个人后来一直保留这个方案哪怕只是本应用内拖拽也不用自定义MIME类型了。省下一个坑算一个。本文还有配套的精品资源点击获取