简介:面向使用QML的开发者,这份资源围绕仪表盘控件Dial与样式类DialStyle的配合展开,演示如何自定义指针、表盘、刻度线和文字标签,完成一个可复用的仪表盘组件。压缩包共7个文件,大小仅7KB,包含3个QML界面文件、1个C++源文件以及.pro/.qrc/.user等工程配置文件,QML部分覆盖主窗口与两个Dial变体实现,cpp文件负责程序入口,工程文件则管理编译与资源加载,可直接在Qt环境中打开运行。这套示例已有3125人学习浏览,适合作QML控件学习的入门参考。资源内针对Dial的常用属性(value、from/to、stepSize、snapToSteps、readOnly、onValueChanged)提供了代码示例,并详细展示了自定义DialStyle时Pointer、Face、TickMark、Label四大视觉元素的改造方式,包括定义指针形状、设置背景、创建刻度线与添加数值标签的具体做法。通过研究这份实现,可以快速掌握从0到1构建自定义仪表盘的完整流程,并将类似旋钮、温度表、音量调节器等交互形态灵活迁移到自身项目中。
1. 从旋钮到仪表盘:Dial 控件到底能做什么
做车载中控、工业 HMI 或者设备状态面板时,几乎都会遇到同一个需求:在有限的屏幕里,把温度、转速、电量这类数值直观地显示出来,并且最好能让人一眼看出“当前处于什么区间”。QML 里的仪表盘控件 Dial 本质上是一个圆形旋钮,但它天生自带圆弧轨迹、数值映射和拖拽交互,配合 DialStyle 做自定义仪表盘,就能从默认的朴素小圆钮改造成真正带刻度、带指针、带数值文本的仪表盘。这篇文章不会只讲属性表,而是从最小可运行示例开始,一路把角度换算、Canvas 绘制、样式替换和版本坑讲透,适合想快速上手 QML 控件、又不想在样式细节上翻车的开发者。
2. 先把 Dial 跑起来:从属性取值到按键输入
2.1 几个必调的基础属性:from、to、value、stepSize
Dial 的核心模型就是一个数值区间:from是最小值,to是最大值,value是当前值,stepSize是每次调整的步长。这三个属性决定了仪表盘的量程。比如做一个温度表,量程是 -20 到 50 摄氏度,那就写成from: -20、to: 50;做一个转速表,量程是 0 到 8000,就写成from: 0、to: 8000。value可以直接赋值,也可以在运行时动态绑定到业务数据源上。
stepSize的选择取决于你的使用场景。如果是档位切换,比如空调风速的低中高,stepSize: 1就够了;如果是连续调节,比如音量、亮度,stepSize: 0.1会更顺滑。这个参数不光影响鼠标拖动时的增量,也影响键盘按键和滚轮的行为,后面会提到。
还有两个容易被忽略的属性:wrap和live。wrap为true时,数值到头后会循环绕回另一端,默认是false,仪表盘场景建议保持false,因为真实的表盘不应该从最大值直接跳回最小值。live控制拖动过程中是否实时更新value,默认true,这符合仪表盘指针跟随鼠标移动的直觉;如果某些场景你希望松开鼠标才提交最终值,就把live设为false。
2.2 snapMode、滚轮与键盘:输入交互的三个细节
sapMode是 Dial 特有的一个枚举属性,控制数值是否“吸附”到步长的整数倍上。它有三个值:SnapNotNeeded、SnapAlways和SnapOnRelease。默认是SnapNotNeeded,也就是不吸附,随便拖到哪就是哪。SnapAlways表示拖动过程中随时吸附到最近的有效档位;SnapOnRelease则是拖动过程中不吸附,松手时才跳到最近的档位。
要注意,只要把snapMode设成SnapAlways或SnapOnRelease,就必须给stepSize赋一个非零值,否则 Qt 会打印一条警告,然后吸附功能不生效。实际开发中这条警告经常出现在控制台里,很多人以为是控件坏了,其实就是漏了stepSize。
键盘操作是 Dial 容易忽略的交互通道。Dial 拿到焦点后,按左键或上键减小数值,按右键或下键增大数值,PageUp 和 PageDown 可以大幅度跳变,Home 键跳到最小值,End 键跳到最大值。如果你在页面里放了一个 Dial 但没设focus: true,你会发现键盘完全没反应,因为焦点根本不在它身上。同时也要留意,Dial 默认不响应鼠标滚轮,需要显式设置wheelEnabled: true才能用滚轮调整数值,这个属性在嵌入式的触屏场景下通常无所谓,但在 PC 调试时非常有用。
2.3 最小可运行示例:跑通一个 Dial
下面这段代码是可以直接复制到一个空 QML 项目里跑的,包含了上面说到的关键属性:
import QtQuick 2.15 import QtQuick.Controls 2.15 ApplicationWindow { visible: true width: 320 height: 320 Dial { id: dial anchors.centerIn: parent width: 220 height: 220 from: -90 to: 90 value: 0 stepSize: 1 snapMode: Dial.SnapAlways live: true wrap: false wheelEnabled: true focus: true onValueChanged: console.log("value =", value) } Text { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: 20 text: dial.value.toFixed(1) font.pixelSize: 26 color: "#333" } }为什么我在这里把from和to设成 -90 到 90?因为后面做仪表盘时,这个区间对应表盘上从左边到右边的对称扫描。focus: true是为了让键盘操作立即生效,snapMode配stepSize: 1让数值按整数档走,方便观察变化。onValueChanged里打日志,是为了验证live属性对更新时机的影响——你把它改成false再拖一下,就会发现拖动过程中日志不打印,松手才打印一次。这个特性在调试数据绑定时经常被误以为是 bug,提前知道能省不少时间。
3. 从 Dial 到仪表盘:角度公式、刻度线与 Canvas 画法
3.1 表盘角度映射:为什么不能用 0 到 360 直接映射
Dial 本身的默认视觉效果是一个圆形旋钮,它内部会把value按比例换算成一个角度来摆放指示标记。但我们要做的是仪表盘,仪表盘的表针扫描范围通常不是完整的一圈,而是从左下角经过 12 点钟方向扫到右下角,一共扫过 270 度。这里最关键的坑是:角度映射必须避开 0 度到 360 度的断点。
如果直接写angle = (value - from) / (to - from) * 360,指针在数值经过中位时会从某个角度瞬间跳回 0 度,看起来像表针在 12 点钟方向疯狂抖动。正确做法是把起始角度定在 225 度,然后顺时针扫 270 度,这样 225 度对应表盘左下角,450 度换算成 90 度对应右下角,中间 360 度正好是 12 点钟方向。注意 450 度在屏幕上会归一化成 90 度,但你在计算弧度时不要提前归一化,要让它连续跨过 360 度。
用数学公式表达就是:progress = (value - from) / (to - from),pointerAngle = 225 + progress * 270。当value等于from时指针在左下角,等于to时指针在右下角,等于中值时指针指向正上方。这个映射是整个自定义仪表盘的基础,后面画刻度和画指针都要复用它。
3.2 用 Canvas 画刻度线和指针:一个可复制的绘制方案
Canvas 在 QML 里是绘制仪表盘最直接的工具,它在 Qt 5 和 Qt 6 里都可用。下面的代码我按常见的转速表风格来写,量程沿用 -90 到 90,刻度间隔 30:
Canvas { id: dialCanvas width: 240 height: 240 property real from: -90 property real to: 90 onPaint: { var ctx = getContext("2d") ctx.reset() ctx.clearRect(0, 0, width, height) ctx.translate(width / 2, height / 2) // 画表盘底 ctx.beginPath() ctx.arc(0, 0, width / 2 - 2, 0, Math.PI * 2) ctx.fillStyle = "#f5f5f5" ctx.fill() // 画刻度线:每隔 30 画一条,长刻度为主刻度 for (var v = from; v <= to; v += 30) { var progress = (v - from) / (to - from) var angleRad = (225 + progress * 270) * Math.PI / 180 ctx.save() ctx.rotate(angleRad) ctx.beginPath() ctx.moveTo(0, -width / 2 + 8) ctx.lineTo(0, -width / 2 + 28) ctx.lineWidth = 2 ctx.strokeStyle = "#333" ctx.stroke() ctx.restore() } // 画指针:先平移旋转,再画一个三角形 ctx.save() ctx.rotate((225 + 0.5 * 270) * Math.PI / 180) // 示例:中值位置 ctx.beginPath() ctx.moveTo(0, 12) ctx.lineTo(-6, 12) ctx.lineTo(0, -width / 2 + 40) ctx.lineTo(6, 12) ctx.closePath() ctx.fillStyle = "#c0392b" ctx.fill() ctx.restore() } }这里有一个特别容易搞错的坐标系问题:Canvas 的rotate是顺时针为正,而且旋转中心是坐标原点。我在translate(width / 2, height / 2)之后,原点就移到了画布中心,此时moveTo(0, -半径)是 12 点钟方向,rotate(225度)会让这条线转到左下角。刻度线用save和restore包起来,是为了每次旋转互不影响。
指针的绘制也用了同样的旋转技巧:先把坐标系旋转到目标角度,然后画一个以中心为底、指向外侧的三角形。示例代码里我用的是中值 0 对应的角度,实际项目中你应该把0.5 * 270替换成根据value动态计算的表达式。指针颜色用深红色,是为了在浅色表盘上足够醒目。
3.3 Repeater 做刻度标签:文字永远不旋转的替代方案
如果你把刻度数字也放进 Canvas 里绘制,会遇到一个麻烦:在旋转后的坐标系里调用fillText,文字会跟着旋转,数字全都歪着脑袋。解决这个问题有一个更简单的方案——用 Repeater 生成刻度文本,文本控件放在固定层上,不参与旋转。
Repeater { model: [-90, -60, -30, 0, 30, 60, 90] Text { required property int modelData property real progress: (modelData + 90) / 180 property real angleRad: (225 + progress * 270) * Math.PI / 180 x: dialCanvas.width / 2 + Math.sin(angleRad) * (dialCanvas.width / 2 - 45) - width / 2 y: dialCanvas.height / 2 - Math.cos(angleRad) * (dialCanvas.height / 2 - 45) - height / 2 text: modelData.toString() font.pixelSize: 12 color: "#333" } }计算位置时用了一点小技巧:在屏幕坐标系里,12 点钟方向是正上方,对应坐标(0, -r),角度顺时针增加,所以x = 中心X + sin(角度) * 半径,y = 中心Y - cos(角度) * 半径。这样文字永远保持水平,不会旋转。至于为什么减 45 而不是别的数,那是半径的缩放系数,你根据表盘大小自己调即可。
Canvas 画刻度线、Repeater 放文字,这是 QML 仪表盘最经典的组合,代码直观,调试也方便。如果你用 Qt 6 的 Shape 组件来画刻度线,思路完全一样,只是把 Canvas 换成 ShapePath,角度公式不需要改。
4. DialStyle 自定义样式:把旋钮改成真正的仪表盘
4.1 DialStyle 的结构:mainItem、background、handle 各管什么
要把默认的 Dial 改造成仪表盘,核心就是替换它的样式。DialStyle 是 Qt Quick Controls 2 在 Qt 5.x 时代提供的样式定制入口,它和普通控件的属性绑定不一样,采用的是“提供可视组件”的套路。一个 DialStyle 里最重要的三个部分是mainItem、background和handle。
mainItem是必选项,样式里所有主要视觉内容都应该放进它里面;background是可选的,负责画表盘底和边框;handle负责画那个可拖拽的旋钮指示,Dial 会自动把 handle 放到与当前值对应的角度位置上。如果你只改background不改mainItem,表盘底色会变,但控件内部没有任何内容,看起来就是一块空圆盘,所以实际开发中mainItem里至少要放表盘面和指针两层内容。
一个常见的误解是“给 DialStyle 设置 width 和 height”,这其实没必要。DialStyle 内部的组件尺寸应当绑定control.width和control.height,因为 style 是跟着 Dial 本体走的,用户调整的是 Dial 的尺寸,不是 DialStyle 的尺寸。所有可视元素的定位都以control为参照物,而不是用绝对的像素值。
4.2 在样式里定义角度属性:表盘扫描的 270 度怎么喂进去
自定义仪表盘时,指针必须跟着value转,但 Dial 内部自己维护的角度映射是 360 度的,直接拿来用会导致指针和表盘刻度对不上。我的做法是在 DialStyle 里自定义一个属性gaugeAngle,专门存放我们需要的 270 度扫描角度,然后让指针层绑定这个属性去旋转。
这里要注意命名:不要把它命名为angle。Dial 对名为angle的样式属性有特殊处理,命名成别的可以避免和内部机制互相干扰。
DialStyle { id: dstyle property real gaugeAngle: 225 + (control.value - control.from) / (control.to - control.from) * 270 mainItem: Item { // 这里放表盘和指针 } }control是 DialStyle 内部提供的引用,指向被美化控件的实例。在 DialStyle 里访问control.value、control.from、control.to,写起来非常直观。这个gaugeAngle的公式和前面第 3 章完全一致,等于把 Canvas 绘制章节里的角度计算搬到了样式层。
另外,Dial 的键盘操作、滚轮操作、enable/disable 状态,都会作用到整个控件上,样式层不需要重复处理这些交互逻辑。这也是用 DialStyle 而不是直接自己写一个 Item 的好处:交互能力白嫖,只管画。
4.3 一个完整的 DialStyle 仪表盘示例
下面这段代码是一个可以直接跑的自定义仪表盘,包含深色表盘、刻度线、中位指针和实时数值文本。我把它压缩在一个 Dial 里,方便你复制后逐步拆解:
import QtQuick 2.15 import QtQuick.Controls 2.15 Dial { id: dial width: 260 height: 260 from: -90 to: 90 value: 30 stepSize: 1 snapMode: Dial.SnapAlways wheelEnabled: true focus: true style: DialStyle { id: dstyle property real gaugeAngle: 225 + (control.value - control.from) / (control.to - control.from) * 270 background: Rectangle { radius: width / 2 color: "#1e1e1e" border.color: "#3a3a3a" border.width: 2 } mainItem: Item { width: control.width height: control.height // 表盘面:画刻度线,不旋转 Canvas { id: faceCanvas anchors.fill: parent onPaint: { var ctx = getContext("2d") ctx.reset() ctx.clearRect(0, 0, width, height) ctx.translate(width / 2, height / 2) // 画刻度线 for (var v = control.from; v <= control.to; v += 30) { var progress = (v - control.from) / (control.to - control.from) var angleRad = (225 + progress * 270) * Math.PI / 180 ctx.save() ctx.rotate(angleRad) ctx.beginPath() ctx.moveTo(0, -width / 2 + 12) ctx.lineTo(0, -width / 2 + 32) ctx.lineWidth = 2 ctx.strokeStyle = "#dddddd" ctx.stroke() ctx.restore() } } } // 指针层:只旋转指针,不旋转刻度 Canvas { id: handCanvas anchors.fill: parent rotation: dstyle.gaugeAngle onPaint: { var ctx = getContext("2d") ctx.reset() ctx.clearRect(0, 0, width, height) ctx.translate(width / 2, height / 2) ctx.beginPath() ctx.moveTo(0, 16) ctx.lineTo(-7, 16) ctx.lineTo(0, -width / 2 + 46) ctx.lineTo(7, 16) ctx.closePath() ctx.fillStyle = "#e74c3c" ctx.fill() } } // 数值文本:固定显示,不跟着指针旋转 Text { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: parent.height * 0.12 text: control.value.toFixed(0) color: "#eeeeee" font.pixelSize: parent.height * 0.15 font.bold: true } } } }这段代码里,表盘面和指针层被我分成了两个 Canvas:表盘 Canvas 永不旋转,指针 Canvas 的rotation绑定dstyle.gaugeAngle。这是做自定义仪表盘最容易理解的模式,也是避免“整个表盘跟着指针转”这类翻车问题的最佳实践。数值文本放在mainItem的最上层,不参与任何旋转,始终水平显示。
刻度线的绘制逻辑在第 3 章已经讲过,这里只是把量程来源改成了control.from和control.to,这样你修改 Dial 的量程时,刻度线会自动跟着分布。handle我没有重写,保留默认样式,实际项目中如果不想看到中心旋钮,可以像下面这样给它一个透明的小矩形,既不影响视觉,也保留了热区:
handle: Rectangle { width: 24 height: 24 radius: 12 color: "transparent" }注意handle即使透明,也会被 Dial 自动放置在随数值变化的位置上,所以不要把它作为指针使用,指针显示交给handCanvas就好。
5. Dial 与 DialStyle 的五个典型坑:现象、原因、解决
5.1 把教程代码抄到 Qt 6 里直接报错:样式体系已经换了
现象:按照网上教程写style: DialStyle { ... },在 Qt 6 项目里一编译就提示找不到DialStyle,或者报“Dial has no property named style”。
原因:Qt Quick Controls 2 在 Qt 5 时代用 Style 组件定制外观,但 Qt 6 彻底移除了这套 Style 体系,改成了直接自定义background、contentItem、handle等属性。所以DialStyle是 Qt 5 专属的写法,不是语法变了,而是整个机制被替代了。
解决:在 Qt 6 里不要用DialStyle,直接给 Dial 提供可视组件。下面是一个等价的 Qt 6 写法:
Dial { id: dial from: -90 to: 90 value: 30 stepSize: 1 background: Rectangle { radius: width / 2 color: "#1e1e1e" border.color: "#3a3a3a" } contentItem: Canvas { anchors.fill: parent rotation: 225 + (dial.value - dial.from) / (dial.to - dial.from) * 270 onPaint: { var ctx = getContext("2d") ctx.reset() ctx.clearRect(0, 0, width, height) ctx.translate(width / 2, height / 2) ctx.beginPath() ctx.moveTo(0, 16) ctx.lineTo(-7, 16) ctx.lineTo(0, -width / 2 + 46) ctx.lineTo(7, 16) ctx.closePath() ctx.fillStyle = "#e74c3c" ctx.fill() } } }这里的逻辑和第 4 章的完整示例一样,区别只是把原来放在 DialStyle 里的内容分别放到了background和contentItem里。contentItem会跟随 Dial 自动布局,不用担心尺寸问题。
5.2 指针在 12 点钟方向来回跳:角度映射没跨过断点
现象:左右拖动 Dial,其他角度都正常,唯独数值经过中位附近时,指针先跳到对侧,再慢慢转回来,看起来像失灵了一样。
原因:这是角度断点问题。如果映射公式用了-180 到 180或者0 到 360这类会回绕的范围,当角度从 359 度过渡到 0 度时,视觉上会瞬间从 12 点左侧跳到右侧,产生反向旋转的错觉。仪表盘需要的是 225 度到 495 度这种连续递增的角度范围,让指针始终沿着同一方向转。
解决:统一使用225 + progress * 270的角度公式,不要加分段判断,更不要用取模运算把角度拉回 0 到 360。数学上允许角度超过 360,交给 QML 的旋转处理即可。
5.3 snapMode 设了但吸附不生效,控制台还有警告
现象:把snapMode设成Dial.SnapAlways后,拖动 Dial 依然可以停在任意位置,控制台打出一条关于 stepSize 的警告。
原因:吸附功能依赖stepSize来确定有效档位,当stepSize为 0 时控件无法判断该吸附到哪个值,于是直接放弃吸附并打出警告。
解决:只要snapMode不是SnapNotNeeded,就必须设置一个大于 0 的stepSize。如果你希望“看起来连续但实际有档位”,可以把stepSize设成0.5或0.1,档位密度够高,肉眼几乎分辨不出来。
5.4 刻度盘跟着指针一起转:旋转加错了对象
现象:自定义仪表盘跑起来后,表盘上的刻度线和数字全都跟着指针旋转,画面像个旋转的圆盘,而不是一个固定表盘上转动的指针。
原因:把rotation加到了整个mainItem或者包含刻度线的 Canvas 上。刻度线是以control.value为基准画的固定元素,它的角度位置只取决于刻度值本身,不应该随当前值变化。一旦旋转了整个容器,所有元素都被拖动了。
解决:严格分层。刻度线画在永不旋转的 Canvas 里,指针画在另一个 Canvas 或者普通 Item 里,只对指针层设置rotation。数值文本这类需要水平显示的内容,也不要放进旋转层。这个分层原则在多指针仪表盘里尤其重要,比如同时显示转速和油量的场景。
5.5 搜到的 DialStyle 属性用不了:认错控件了
现象:从某篇文章里复制了一段 DialStyle 代码,里面写了tickmarkStepSize、labelStepSize这类属性,放进 Qt Quick Controls 2 的 Dial 里直接报属性不存在。
原因:网上流传的 DialStyle 教程有一部分来自 Qt Quick Extras 组件库,那个 DialStyle 自带刻度盘和标签体系,属性名和 Qt Quick Controls 2 的 DialStyle 完全不同。两个控件都叫 Dial,但一个是QtQuick.Controls 2家族的,一个是QtQuick.Extras家族的,而且 Extras 组件在 Qt 6 里已经不再维护了。
解决:先确认 import 的是哪个模块。如果是import QtQuick.Controls 2.x,就按本文第 4 章的写法来;如果看到代码里 import 了QtQuick.Extras,那套写法在现在的 Qt 版本里不建议继续使用。做仪表盘的自定义样式,老老实实用 Canvas 画刻度,不依赖任何教程预设的样式属性,反而最稳。
6. 进阶:让仪表盘“活”起来——缓动、高亮环与动态数据
静态仪表盘只是第一步,实际项目里往往还要让指针平滑移动、显示区间高亮,并接收实时数据。这里分享三个我常用的做法。
第一是给指针加缓动。在指针 Canvas 上绑定Behavior on rotation,Qt 会自动在角度变化时补间动画,效果类似真实机械仪表盘的阻尼摆动。注意Behavior要写在rotation属性上,而不是写在外层控件上:
Canvas { id: handCanvas anchors.fill: parent rotation: dstyle.gaugeAngle Behavior on rotation { NumberAnimation { duration: 400 easing.type: Easing.OutCubic } } }duration建议 300 到 500 毫秒,太短显得生硬,太长会让人觉得响应迟钝。使用OutCubic缓动函数,指针起步快、到位慢,很接近真实表针的物理感。
第二是画高亮环。如果你要在表盘外圈画一个表示区间状态的弧形,比如电量低于 20% 时外圈变红,可以在表盘 Canvas 里用ctx.arc画弧线。弧线起止角度同样用 225 度和 495 度,但入库前先转成弧度,并且要保证角度跨过 360 度时arc的anticlockwise参数为false,这样弧线会沿正确方向扫过 12 点方向。
第三是接入动态数据。用一个Timer每 200 毫秒改一次value,就能模拟转速表升降。真实项目里,把value绑定到后端信号即可,比如:value: motorRpm。要注意的是,如果数据更新频率远高于动画时长,动画会跟不上,看起来指针在抖动,这时可以适当调大duration或者降低数据刷新频率。
做这类控件多了以后,我养成了一个习惯:任何仪表盘项目,先把角度映射公式写成一个独立函数,然后在所有绘制逻辑里复用,绝不复制粘贴。角度公式是仪表盘的根,根对了,后面刻度、指针、高亮环怎么改都不会出大问题。希望这篇笔记能帮你在做 QML 仪表盘时少踩几个坑。
本文还有配套的精品资源,点击获取