有些进度场景不适合横条:比如加载时想要个转圈动效,或者几个指标要放在一起对比。这篇用 Canvas 画环形进度:一个单环形组件(支持正常百分比和不确定模式两种状态),再加一个多环组件。
- 环形进度 --- 封装一个
CircleProgress组件,用Canvas画轨道和进度弧 - 多环进度 --- 同心三环,配合滑块联动,适合多指标对比
Demo 1:环形进度
一个封装好的 CircleProgress 组件:默认显示百分比,隔一会儿自动切到"加载中"的旋转模式,转几秒再切回来。

演示代码
先看组件本身,环形是怎么画出来的都在这:
qml
import QtQuick
Item {
id: root
// --- 公共属性 ---
property real value: 0.5
property bool indeterminate: false
property int lineWidth: 10
property color progressColor: "#3498db"
property color trackColor: "#e6e6e6"
property int animationDuration: 1000
implicitWidth: 160
implicitHeight: 160
readonly property real radius: Math.min(width, height) / 2 - lineWidth / 2
// 正常进度动画
// 注意:不加下划线前缀,否则信号名 _animatedValueChanged 会与 qmlcachegen
// 的 signal handler 命名校验冲突(下划线属性信号的大小写规则不统一)
property real animatedValue: value
Behavior on animatedValue {
enabled: !root.indeterminate // 不确定模式下禁用此动画
NumberAnimation { duration: root.animationDuration; easing.type: Easing.OutCubic }
}
// Canvas 一次性绘制轨道和进度弧,重绘触发见下方 Connections
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
var cx = width / 2
var cy = height / 2
var r = root.radius
// 第一层:背景轨道
ctx.beginPath()
ctx.arc(cx, cy, r, 0, Math.PI * 2)
ctx.strokeStyle = root.trackColor
ctx.lineWidth = root.lineWidth
ctx.stroke()
// 第二层:进度圆弧
// 不确定模式固定画 60 度的弧,靠整体旋转动起来;正常模式按进度画
var sweep = root.indeterminate ? (Math.PI / 3) : (root.animatedValue * Math.PI * 2)
ctx.beginPath()
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + sweep)
ctx.strokeStyle = root.progressColor
ctx.lineWidth = root.lineWidth
ctx.lineCap = "round"
ctx.stroke()
}
// 属性变化时重绘
Connections {
target: root
function onAnimatedValueChanged() { canvas.requestPaint() }
function onIndeterminateChanged() { canvas.requestPaint() }
function onProgressColorChanged() { canvas.requestPaint() }
function onTrackColorChanged() { canvas.requestPaint() }
function onLineWidthChanged() { canvas.requestPaint() }
}
// 不确定模式:旋转整个 Canvas,进度弧转起来(轨道是整圆,转了也看不出来)
RotationAnimation on rotation {
from: 0
to: 360
duration: 1500
running: root.indeterminate
loops: Animation.Infinite
}
}
// 文本显示
Text {
anchors.centerIn: parent
// 不确定模式下隐藏百分比
text: root.indeterminate ? "加载中" : Math.round(root.animatedValue * 100) + "%"
font.pixelSize: root.radius * 0.4
font.bold: true
color: root.progressColor
visible: true
}
}
然后在 demo 里用起来:
qml
import QtQuick
import QtQuick.Layouts
FadeInAnimation {
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 15
// ... 省略标题组件 TitleSeparator ...
CircleProgress {
id: control
Layout.preferredWidth: 220
Layout.preferredHeight: 220
Layout.alignment: Qt.AlignHCenter
}
Timer {
id: timer
interval: 1500
running: true
repeat: true
onTriggered: {
control.indeterminate = !control.indeterminate
}
}
Item { Layout.fillHeight: true }
}
}
关键逻辑解析
为什么用 Canvas 而不是 Shape?环形进度这种"一条弧 + 一条整圆"的图形,Shape 也能画,但它在动画(旋转)时要反复重新栅格化,容易卡、边缘容易出锯齿。Canvas 画一次就缓存成纹理,之后的旋转是场景图层面的变换,GPU 处理,既流畅又平滑。这也是项目里多环 demo 一直用的路子。
绘制逻辑全在 onPaint 里,只有两条弧:
- 轨道:整圆 360 度,用浅色轨道色;
- 进度弧 :从顶部(
-90°)起,扫过的角度按进度算------sweep = value * 2π。lineCap = "round"让弧的两端是圆头,更精致。
弧的半径不是"组件宽度的一半",还要减去线宽的一半:
qml
readonly property real radius: Math.min(width, height) / 2 - lineWidth / 2
不然粗线会画出组件的边界。这个减半的细节,画圆环时最容易漏。
正常模式下,进度变化走 animatedValue 的 Behavior 动画,Connections 监听它每帧的变化调用 canvas.requestPaint() 重绘,所以进度是平滑涨上去的,百分比文字跟着变。
不确定模式是另一个玩法:indeterminate 开启时,进度弧固定画 60 度,不再重绘,而是给整个 Canvas 加一个旋转动画------弧绕着圆心转,就像加载转圈。轨道是整圆,转了也看不出来,正好省事。
组件对外只暴露了 value、indeterminate、lineWidth、两个颜色等几个属性,demo 里只需一个 Timer 来回切换 indeterminate,就能看到两种状态的切换。
Demo 2:多环进度
三个同心圆环,各自代表一个指标(CPU、内存、磁盘),下面三个滑块分别控制,圆心显示综合值。

演示代码
qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
FadeInAnimation {
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 15
// ... 省略标题组件 TitleSeparator ...
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 20
Layout.rightMargin: 20
spacing: 30
Layout.alignment: Qt.AlignHCenter
// 三环进度
ColumnLayout {
spacing: 6
Layout.alignment: Qt.AlignHCenter
Text { text: "三环进度"; font.pointSize: 11; font.bold: true; color: "#555"; Layout.alignment: Qt.AlignHCenter }
Canvas {
id: multiCanvas1
Layout.preferredWidth: 180
Layout.preferredHeight: 180
property real p1: slider1.value
property real p2: slider2.value
property real p3: slider3.value
onP1Changed: requestPaint()
onP2Changed: requestPaint()
onP3Changed: requestPaint()
onPaint: {
var ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
var cx = width / 2, cy = height / 2
var rings = [
{ r: 75, progress: p3, color: "#6C5CE7", bg: "#E0E0E0" },
{ r: 58, progress: p2, color: "#00B894", bg: "#E0E0E0" },
{ r: 41, progress: p1, color: "#E17055", bg: "#E0E0E0" }
]
for (var i = 0; i < rings.length; i++) {
var ring = rings[i]
// 背景
ctx.beginPath()
ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
ctx.strokeStyle = ring.bg
ctx.lineWidth = 10
ctx.stroke()
// 进度
ctx.beginPath()
ctx.arc(cx, cy, ring.r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * ring.progress)
ctx.strokeStyle = ring.color
ctx.lineWidth = 10
ctx.lineCap = "round"
ctx.stroke()
}
// 中心数值
ctx.fillStyle = "#333"
ctx.font = "bold 20px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(Math.round(p2 * 100), cx, cy - 6)
ctx.fillStyle = "#999"
ctx.font = "10px sans-serif"
ctx.fillText("综合", cx, cy + 14)
}
}
}
// 图例 + 滑块
ColumnLayout {
Layout.alignment: Qt.AlignVCenter
spacing: 12
RowLayout {
spacing: 8
Rectangle { width: 12; height: 12; radius: 6; color: "#E17055" }
Text { text: "CPU"; font.pointSize: 10; color: "#555" }
Text { text: Math.round(slider1.value * 100) + "%"; font.pointSize: 10; font.bold: true; color: "#E17055" }
}
Slider { id: slider1; from: 0; to: 1; value: 0.65; Layout.preferredWidth: 150 }
RowLayout {
spacing: 8
Rectangle { width: 12; height: 12; radius: 6; color: "#00B894" }
Text { text: "内存"; font.pointSize: 10; color: "#555" }
Text { text: Math.round(slider2.value * 100) + "%"; font.pointSize: 10; font.bold: true; color: "#00B894" }
}
Slider { id: slider2; from: 0; to: 1; value: 0.78; Layout.preferredWidth: 150 }
RowLayout {
spacing: 8
Rectangle { width: 12; height: 12; radius: 6; color: "#6C5CE7" }
Text { text: "磁盘"; font.pointSize: 10; color: "#555" }
Text { text: Math.round(slider3.value * 100) + "%"; font.pointSize: 10; font.bold: true; color: "#6C5CE7" }
}
Slider { id: slider3; from: 0; to: 1; value: 0.45; Layout.preferredWidth: 150 }
}
}
Item { Layout.fillHeight: true }
}
}
关键逻辑解析
多环和单环是同一个绘制思路,区别在于"画多遍":用数组描述三圈环,每圈有自己的半径、进度和颜色,循环里逐个画背景、画进度。半径从外到内依次缩小,三圈互不遮挡。
数据从哪来?Canvas 上声明了三个属性 p1/p2/p3,分别绑到三个滑块的值,每个属性变化时 requestPaint() 重绘一次。滑块一拖,对应的环就跟着涨------这就是"控件属性驱动 Canvas 重绘"的标准姿势,比在 onPaint 里到处找数据源清晰。
中心的两行文字也是用 ctx 直接画的:fillText 在圆心偏上画综合值(取中间环的进度),偏下画"综合"两个字。数值是 Canvas 画上去的,不是 QML 的 Text 组件------混着用也行,但既然画布都开了,顺手一起画更省事。
对比表格
| 维度 | 环形进度 | 多环进度 |
|---|---|---|
| 形态 | 单环 + 中心百分比 | 三环同心 + 中心综合值 |
| 数据来源 | 外部 value |
三个滑块联动 |
| 不确定模式 | 旋转固定弧 | 无(有具体进度) |
| 适合场景 | 单指标加载/评分 | 多指标仪表盘对比 |
运行验证
- 用 Qt Creator 打开
qml_progressbar/CMakeLists.txt; - 按
Ctrl+R运行; - 先看环形进度自动在"百分比"和"加载中"之间切换,再到多环进度拖三个滑块,看三环联动变化。
扩展复用方向
CircleProgress组件的lineWidth、progressColor都开放了,换主题色、调粗细都是改属性的事。- 多环的"环数"写死在数组里,改成动态生成就能支持任意数量的指标。
- 圆环也可以加个圆点起点、渐变描边,做出更精致的仪表盘风格。
已验证环境:
- Qt 版本:Qt 6.8.2 / Qt 6.11.1
- 操作系统:Windows 11
- GitHub:QML-Minimal-Demos/qml_progressbar