Qt Model/View自定义代理:从paint到编辑器,手写进度条任务列表

发布时间:2026/10/21 17:28:47
Qt Model/View自定义代理:从paint到编辑器,手写进度条任务列表 1. 什么时候必须自己写代理默认代理的能力边界做 Model/View 开发时很多人以为掌握了 QAbstractItemModel 和 QTableView 就够用了直到产品经理丢来一句这一列要显示进度条双击可以改进度才发现**自定义代理Delegate**才是 Model/View 框架里真正决定用户体验的那块拼图。代理管的是单元格层级的绘制和编辑同一个模型数据交给默认代理只能显示成一行文本交给自定义代理却能画成进度条、按钮、星标、缩略图甚至一个迷你折线图。1.1 三层架构里最容易被忽略的第三方Qt 的 Model/View 框架把界面拆成三个角色Model 管数据、View 管布局、Delegate代理管单个条目的长相和编辑方式。很多人学这块时把注意力全放在 Model 和 View 上直到某天需要在一列表格里画一个进度条或者在单元格里塞个按钮才发现默认代理根本搞不定。那一刻才是真正需要自定义代理的时候。说个最直白的比喻Model 是仓库View 是货架代理是货架上一个商品的具体摆法——标签贴哪、歪着放还是正着放、顾客能不能直接拿下来改标签。仓库只管存东西货架只管把东西陈列出来而每一件商品具体长什么样由代理决定。在 Qt 里对应的就是 QAbstractItemDelegate。QTableView、QListView、QTreeView 都自带一个默认代理负责最基本的显示和编辑文字、图标、选中高亮、双击弹出编辑器。绝大多数简单列表场景默认代理够用了。但一旦你的数据需要换一种视觉语言展示比如进度条、评分星星、状态圆点、缩略图、按钮默认代理就无能为力了。1.2 默认代理能做的三件事与做不了的几件事默认代理准确说是 QStyledItemDelegate实际上做了三件事根据模型的 Qt::DisplayRole 数据调用当前 QStyle 绘制文本和图标根据模型的数据类型自动选择合适的编辑器字符串用 QLineEdit整数用 QSpinBox逻辑值用 QCheckBox在合适的位置和时机弹出编辑器、回写数据。它做不了的事就多了无法把一个 int 画成半圆形的进度环无法在一个单元格里画多个可点击的热区无法在编辑器与显示区域之间做复杂的视觉过渡。更常见的是你的数据是一个结构体或一个对象指针显示层需要从里面取不同字段渲染成不同组件默认代理这时候完全不知道该怎么表达。1.3 一个常见误判样式表能替代代理吗有些同学第一反应是我用 QSS 样式表搞定。样式表确实能改颜色、改边框、改间距但它只能作用于 Qt 已经画出来的元素。你没法用 QSS 把一个文本单元格变成进度条也没法让单元格的某一部分响应鼠标点击。样式表是化妆代理是整容两者解决的问题基本不重叠。我在某个项目里为了图省事试图用样式表模拟一个风险等级标签结果折腾了两天还是回到了自定义代理因为点击区域的命中测试根本无法用样式表表达。判断标准很简单如果这个单元格的展示方式不在 QStyle 的基础元素列表里文本、图标、复选框、进度条等或者需要自定义交互热区就得写自定义代理。2. 自定义代理的底层机制六个虚函数的触发顺序与分工自定义代理不是魔法你只需要重写 QStyledItemDelegate 的六个核心虚函数。只要搞清楚每一个函数何时被调用、负责什么、返回值给谁用代码就水到渠成了。这一节先把机制讲透后面的实战才能写得顺。2.1 显示链路paint 与 sizeHint 如何配合视口里每出现一个可见条目视图都会问代理两个问题这个条目该画成什么样和这个条目需要多大空间。前者对应 paint()后者对应 sizeHint()。paint() 接受三个关键参数QPainter、QStyleOptionViewItem、QModelIndex。QPainter 是画笔负责真正往屏幕上画QStyleOptionViewItem 包含了当前条目的矩形、选中状态、悬停状态、焦点状态、是否可用等信息QModelIndex 是你取数据的凭证通过它调 model()-data(index, role) 就能拿到要渲染的数据。sizeHint() 则是视图计算布局时被调用的返回值决定行高、列宽的初始大小。注意它不是只在初始化时调用一次数据变化、视图尺寸变化时都可能重新触发所以不要在 sizeHint 里做重活尤其不要在里面做大量字符串截断、图片缩放这类开销不小的操作。视图、代理、模型三者的调用链条可以归纳成一张通用表格函数触发时机核心职责paint条目进入可见区域绘制条目的视觉外观sizeHint布局、尺寸变化时返回条目的建议尺寸createEditor用户发起编辑时创建编辑器控件setEditorData编辑器创建完成后把模型数据填入编辑器setModelData用户提交编辑时把编辑器数据写回模型updateEditorGeometry编辑器展示前设置编辑器位置与尺寸2.2 编辑链路从双击到写回模型的完整生命周期一个完整的编辑流程是这样的用户双击或按 F2或视图设定的其他编辑触发方式视图调用代理的 createEditor()拿到一个编辑器控件视图随后调用 setEditorData()把模型里当前的值填进编辑器用户改完按回车或点击其他地方视图通过 commitData() 信号触发代理的 setModelData()把编辑器里的值写回模型视图再通过 closeEditor() 信号关闭编辑器编辑结束编辑器出现的位置和大小由 updateEditorGeometry() 决定默认取 option.rect。这五个环节里createEditor 和 setEditorData 负责从模型到编辑器setModelData 负责从编辑器到模型updateEditorGeometry 负责位置。写回模型后视图会收到 dataChanged 信号自动刷新paint 重新执行新数据立刻出现在界面上。2.3 为什么要继承 QStyledItemDelegate 而不是 QItemDelegate网上很多老教程写的是继承 QItemDelegate但新代码建议都继承 QStyledItemDelegate。区别在于QStyledItemDelegate 绘制时走 QStyle会自动跟随当前程序的主题、调色板、样式表QItemDelegate 则大量使用自己的硬编码绘制逻辑样式表对它的影响很有限。除非有非常特殊的兼容需求否则不要碰 QItemDelegate。记住这一点能省掉很多为什么我的样式换了它不变的排查时间。3. 实战手写一个带进度条、可原地改值的任务列表代理理论讲完了来做一个能直接抄作业的例子。场景是一个任务管理列表QTableView 展示多行任务第一列任务名第二列进度0 到 100第三列负责人第四列状态第五列操作按钮。目标效果进度列显示一条带百分比的进度条双击这一列时弹出 QSpinBox 直接改数值。3.1 需求定义与类骨架因为进度列和状态列的展示逻辑完全不同建议把展示和编辑能力放进同一个代理类里在 paint 和 createEditor 中用 index.column() 分派。这样只挂载一次代理代码集中好维护。类骨架如下// TaskListDelegate.h #pragma once #include QStyledItemDelegate enum TaskColumn { ColName 0, // 任务名 ColProgress 1, // 进度 ColOwner 2, // 负责人 ColStatus 3, // 状态 ColAction 4 // 操作按钮 }; class TaskListDelegate : public QStyledItemDelegate { Q_OBJECT public: explicit TaskListDelegate(QObject* parent nullptr); void paint(QPainter* painter, const QStyleOptionViewItem option, const QModelIndex index) const override; QSize sizeHint(const QStyleOptionViewItem option, const QModelIndex index) const override; QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem option, const QModelIndex index) const override; void setEditorData(QWidget* editor, const QModelIndex index) const override; void setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex index) const override; void updateEditorGeometry(QWidget* editor, const QStyleOptionViewItem option, const QModelIndex index) const override; };模型中进度值建议存在 Qt::UserRole 1 这个自定义角色里DisplayRole 存的是纯文本说明两边的数据用途不同避免角色混淆。3.2 paint 里的绘制细节从背景到进度条到文字paint 是这次自定义代理的重头戏。核心逻辑分四步先画单元格背景再画进度条槽然后画进度填充最后画百分比文字。void TaskListDelegate::paint(QPainter* painter, const QStyleOptionViewItem option, const QModelIndex index) const { QStyleOptionViewItem opt option; initStyleOption(opt, index); const QWidget* widget opt.widget; QStyle* style widget ? widget-style() : QApplication::style(); // 进度列走自定义绘制 if (index.column() ColProgress) { // 1. 画标准背景保证选中态、悬停态不丢 style-drawControl(QStyle::CE_ItemViewItem, opt, painter, widget); // 2. 从模型取进度值 bool ok false; int progress index.data(Qt::UserRole 1).toInt(ok); if (!ok) progress 0; // 3. 画进度条槽 const QRect barRect opt.rect.adjusted(8, 10, -8, -10); painter-save(); painter-setRenderHint(QPainter::Antialiasing, true); painter-setPen(Qt::NoPen); painter-setBrush(QColor(0xEC, 0xEC, 0xEC)); painter-drawRoundedRect(barRect, barRect.height() / 2.0, barRect.height() / 2.0); // 4. 画进度填充 if (progress 0) { QRect fillRect barRect; fillRect.setWidth(barRect.width() * qBound(0, progress, 100) / 100); // 满进度用绿色未满用主题蓝 const QColor fillColor (progress 100) ? QColor(0x52, 0xC4, 0x1A) : QColor(0x2D, 0x8C, 0xF0); painter-setBrush(fillColor); painter-drawRoundedRect(fillRect, fillRect.height() / 2.0, fillRect.height() / 2.0); } // 5. 画百分比文字 const QString text QString::number(progress) QStringLiteral(%); painter-setPen(Qt::white); painter-drawText(barRect, Qt::AlignCenter, text); painter-restore(); return; } // 其他列走默认绘制 QStyledItemDelegate::paint(painter, opt, index); }有几个细节值得单独强调。第一一定要先调用 initStyleOption(opt, index)它会把 View 传下来的 option 补全为标准条目包括选中状态、可用状态、文本、图标等。如果你跳过这步直接拿原始 option 画背景就会丢失选中高亮这也是新手最容易踩的坑。第二进度条矩形用 adjusted 留出边距行高和文字才不会挤在一起。第三绘制前 save、结束后 restore防止画笔属性污染后面的绘制。第四文字颜色这里统一用了白色但实际项目中如果进度值很小白字会被灰底吃掉更稳的做法是判断填充区域宽度是否足够不够时把文字画在进度条右侧并用深色这个属于细节优化。3.3 用 QSpinBox 作为单元格编辑器展示搞定后编辑逻辑就四个函数的事。先看 createEditor 和 setEditorDataQWidget* TaskListDelegate::createEditor(QWidget* parent, const QStyleOptionViewItem option, const QModelIndex index) const { if (index.column() ColProgress) { QSpinBox* editor new QSpinBox(parent); editor-setRange(0, 100); editor-setSuffix(QStringLiteral( %)); editor-setAlignment(Qt::AlignCenter); return editor; } return QStyledItemDelegate::createEditor(parent, option, index); } void TaskListDelegate::setEditorData(QWidget* editor, const QModelIndex index) const { if (index.column() ColProgress) { QSpinBox* spinBox qobject_castQSpinBox*(editor); if (spinBox) spinBox-setValue(index.data(Qt::UserRole 1).toInt()); return; } QStyledItemDelegate::setEditorData(editor, index); }setEditorData 的意图很明确——把模型里的数据搬到编辑器里。这一步不做的话弹出来的编辑器永远是默认值用户一改就把原有数据覆盖了。写回的部分才是真正和模型打交道的setModelData 负责把编辑器里的值通过 setData 写回模型。写回后视图会自动刷新paint 重新执行进度条就更新了。void TaskListDelegate::setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex index) const { if (index.column() ColProgress) { QSpinBox* spinBox qobject_castQSpinBox*(editor); if (spinBox) model-setData(index, spinBox-value(), Qt::UserRole 1); return; } QStyledItemDelegate::setModelData(editor, model, index); }3.4 在 QTableView 里挂载代理最后挂载视图。你有两种选择setItemDelegate 设置整表统一代理或者 setItemDelegateForColumn 单独给某一列挂代理。上面这个例子因为各列逻辑都分派在同一个 paint 里了所以直接整表挂载即可ui-tableView-setModel(taskModel); ui-tableView-setItemDelegate(new TaskListDelegate(ui-tableView)); ui-tableView-setSelectionBehavior(QAbstractItemView::SelectRows); ui-tableView-verticalHeader()-setVisible(false);注意代理的父对象。我一般直接传给 view让 Qt 负责生命周期不需要自己手动 delete。给某列单独挂代理时也一样父对象设为 view 即可。到这里一个带进度条、双击可改值的自定义代理已经能跑了。先别急着扩展别的列把上面这套跑通你已经掌握了 80% 的委托开发套路。4. 踩坑实录自定义代理最常见的四个翻车现场这些年见过太多人在自定义代理上翻车我自己也踩过。下面四个问题几乎每个做代理的人都会遇到至少一个提前看能省不少时间。4.1 翻车一选中态与悬停态莫名消失表现写完 paint 之后选中单元格时高亮背景不见了鼠标悬停也没有反馈。原因paint 里没有调用 initStyleOption直接把传入的 option 用来画背景。View 传进来的 option.state 里的 State_Selected、State_MouseOver 都没被解析成实际背景。修复在 paint 开头统一执行一句 initStyleOption(opt, index)再基于 opt 画。这样选中、悬停、可用状态全部恢复正常。还要注意如果你在自定义绘制里完全没有调用 style-drawControl(QStyle::CE_ItemViewItem...)那选中态的背景就要自己填充此时可以用 opt.palette 里的 highlight 色不要自己硬编码一个蓝色。一个经验法则自定义 paint 永远先调用 initStyleOption再判断要不要走默认绘制。这条能避免的问题数量超乎想象。4.2 翻车二编辑器位置对不齐、尺寸像假肢表现双击进度列弹出 QSpinBox但输入框比单元格小一圈位置还偏上看起来像是给单元格贴了块补丁。原因没有重写 updateEditorGeometry编辑器默认按 option.rect 放置。如果你的单元格内部有边距paint 里留了 8 像素编辑器就会显得不对劲。修复重写 updateEditorGeometry让编辑器的几何区域和画出来的可编辑视觉区域保持一致void TaskListDelegate::updateEditorGeometry(QWidget* editor, const QStyleOptionViewItem option, const QModelIndex index) const { if (index.column() ColProgress) { QRect rect option.rect; // 覆盖进度条视觉区域附近留出少量边距 rect.adjust(8, 4, -8, -4); editor-setGeometry(rect); return; } QStyledItemDelegate::updateEditorGeometry(editor, option, index); }核心思路是编辑器应该盖住用户预期可以点击的区域而不是机械地等于整个单元格。如果你在 paint 里画了一个边距很大的自定义控件编辑器却只盖住整个格子视觉上也会很怪。两边留白对齐体验才顺滑。4.3 翻车三回车后数据没写回或编辑器不关闭表现用户改完数值按回车编辑器留在原地数据没写回模型。原因标准编辑流程依赖视图的编辑请求信号和代理的 commitData/closeEditor 信号协同。如果你的代理里重写了 eventFilter并且在里面拦截了 KeyPress 却忘了调用基类逻辑Tab 和 Enter 键的默认提交行为就没了。另外如果你在自定义编辑时用了非标准编辑器并且自己接管焦点也可能导致写回链路断掉。修复大部分场景下不要自己拦截键盘事件把事件过滤交给 QStyledItemDelegate 的默认实现。如果确实需要自定义比如 Enter 表示确认、Esc 表示取消推荐在编辑器的 eventFilter 里只处理特殊情况然后手动调用 emit commitData() 和 emit closeEditor()。注意这两个信号是 QAbstractItemDelegate 提供的调用时机要放在数据真正准备好之后。附一个常见的回车过滤范本bool TaskListDelegate::eventFilter(QObject* object, QEvent* event) { if (event-type() QEvent::KeyPress) { QKeyEvent* keyEvent static_castQKeyEvent*(event); QWidget* editor qobject_castQWidget*(object); if (editor keyEvent-key() Qt::Key_Return) { emit commitData(editor); emit closeEditor(editor, QAbstractItemDelegate::SubmitModelCache); return true; } } return QStyledItemDelegate::eventFilter(object, event); }4.4 翻车四多列共用一个代理导致行为错乱表现代理只给第二列写了特殊逻辑但第一列和第三列也出现了异常绘制或者双击第一列时弹出了 QSpinBox。原因paint 和 createEditor 里没有按列分派或者列判断写错了。如果你用 setItemDelegate 挂整表那么代理会被每一列调用所有列都会走你的代码路径。调用的列不一样行为和画法当然不一样。修复在 paint、createEditor、setEditorData、setModelData、updateEditorGeometry 五个函数里都加上 column 判断或者干脆每个列单独写一个代理用 setItemDelegateForColumn 挂载。我个人推荐后者代理类职责单一后期扩展和排错都轻松。如果你只改一个列的展示而其余全部走默认那么一个代理加 column 判断也完全可以注意代码可读性即可。5. 进阶技巧按钮列代理、动态行高与高DPI适配基础功能跑通后再给真正项目里会遇到的三个进阶需求收个尾。5.1 给任务加一个开始/暂停按钮列绘制命中测试进度列之后很多人想加一个操作列放开始/暂停按钮。按钮列的难点不再是画而是让单元格内的按钮真正响应点击。思路分两步paint 里画一个 QStyleOptionButton 样式的按钮然后在 editorEvent 里做命中测试。画按钮直接用 QStyle 的画法保证按钮的按下、悬停、禁用等视觉状态和系统一致void TaskListDelegate::paintActionButton(QPainter* painter, const QStyleOptionViewItem option, const QRect buttonRect, const QString text, bool isRunning) const { QStyleOptionButton button; button.rect buttonRect; button.text text; button.state QStyle::State_Enabled; if (isRunning) button.state | QStyle::State_On; // 这里用单元格悬停近似按钮悬停 // 更精细的做法是在编辑器事件里记录鼠标位置 if (option.state QStyle::State_MouseOver) button.state | QStyle::State_MouseOver; QStyle* style option.widget ? option.widget-style() : QApplication::style(); style-drawControl(QStyle::CE_PushButton, button, painter, option.widget); }命中测试在 editorEvent 里做最干净。editorEvent 是很多新手忽略的虚函数单击、双击、鼠标移动事件都会先经过它返回 true 表示事件已被消费bool TaskListDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem option, const QModelIndex index) { if (index.column() ColAction) { if (event-type() QEvent::MouseButtonRelease) { QMouseEvent* mouseEvent static_castQMouseEvent*(event); QRect btnRect actionButtonRect(option.rect); if (btnRect.contains(mouseEvent-pos())) { // 切换任务运行状态写入模型 const bool running index.data(Qt::UserRole 2).toBool(); model-setData(index, !running, Qt::UserRole 2); return true; } } } return QStyledItemDelegate::editorEvent(event, model, option, index); }注意editorEvent 里的 option.rect 是整个单元格的矩形按钮矩形需要自己在内部计算并留出边距。每列逻辑不一样千万别把整个单元格都当成按钮。5.2 根据内容动态调整行高任务描述长短不一的时候固定行高会让长文本溢出。动态行高的做法是在 sizeHint 里根据文本实际宽度计算QSize TaskListDelegate::sizeHint(const QStyleOptionViewItem option, const QModelIndex index) const { QSize size QStyledItemDelegate::sizeHint(option, index); if (index.column() ColName) { const QString text index.data(Qt::DisplayRole).toString(); QFontMetrics fm(option.font); const int textWidth fm.horizontalAdvance(text); const int maxWidth 400; if (textWidth maxWidth) { // 预留 20 像素边距 一行的高度 int neededHeight fm.height() * (textWidth / maxWidth 1); size.setHeight(qMax(size.height(), neededHeight 12)); } } return size; }动态行高的性能和正确性都要兼顾。如果只是偶尔几个长文本可以只对超长的单元格做两行显示逻辑如果每行都要算建议把 textLayout 或换行结果缓存起来避免每次 sizeHint 都重复计算。视图在数据变化后会重新查询 sizeHint所以数据变了行高会自动更新不需要手动触发。5.3 高DPI下的绘制缩放与字体清晰度高DPI 已经不是小众需求了。如果你的代理里用了大量硬编码像素值在 2 倍缩放屏上就会显得又小又糊。我的建议有三条绘制矩形坐标里避免硬编码大边距改用 option.fontMetrics 的 height 比例计算比如 verticalMargin fm.height() / 4painter 开启抗锯齿圆角矩形在高 DPI 下更平滑字体大小不要自己 setPixelSize(12)直接用 option.font 派生的字体系统缩放会帮你处理好。图表、进度条这类纯绘制的组件Qt 的坐标系统会自动做缩放但如果你在代码里把字体和间距写死缩放后就只剩小和更小两个结局。另外低 DPI 和高 DPI 下 drawText 的字体渲染差异往往不是代理本身的问题而是没有正确设置设备像素比确认程序开启高 DPI 缩放后代理绘制质量基本就稳了。回过头看自定义代理的门槛其实不在 API 数量而在于你明确知道视图、模型、代理三者之间的数据流什么时候发生。把六个虚函数的调用时机想清楚剩下的就是绘制上的功夫。如果让我给初学者一个建议先别急着写炫酷的绘制先跑通一个最朴素的 paint createEditor再往上加效果比什么都管用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询