简介:面向Qt开发者的自定义工具箱控件实现资源,适合需要在VS2015+Qt5.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 QList<ToolItem> &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/tool@2x.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类型了。省下一个坑算一个。
本文还有配套的精品资源,点击获取