1. 引言
掌握 QML 的基础语法之后,很多开发者会卡在同一个瓶颈:界面能画出来,但交互生硬、列表卡顿、状态切换靠一堆 if-else 硬编码。要跨过这道坎,核心是两块能力:
- 状态与动画体系 :用
State描述"界面有哪几种形态",用Transition描述"形态之间怎么过渡",用Behavior描述"某个属性变化时自动怎么动",用NumberAnimation等动画元素描述"具体怎么动"。 - 模型视图体系 :用
ListModel管理数据,用DelegateModel做分组、过滤、排序,用TableView展示二维表格数据。
本文按"概念 → 原理 → 可运行示例 → 常见坑"的顺序展开,每个知识点都配完整代码,建议边读边在 Qt Creator 里跑一遍。
2. 状态与动画体系
QML 的动画体系可以分成四层,理解这个分层,后面就不会混淆各个元素的职责:
一句话总结:State 管"变成什么样",Transition 管"状态切换时怎么变",Behavior 管"任意属性变化时怎么变",Animation 管"变化的曲线和时长"。
3. State
3.1 基本用法
State 的本质是一组"属性覆盖"。当 state 属性等于某个 State 的 name 时,该 State 中 PropertyChanges 定义的属性值就会生效;离开该状态时,属性自动恢复到默认值。
qml
import QtQuick 2.15
Rectangle {
id: root
width: 400; height: 300
color: "#f5f5f5"
Rectangle {
id: box
width: 100; height: 100
x: 20; y: 20
color: "#4a90d9"
radius: 8
}
states: [
State {
name: "moved"
PropertyChanges { target: box; x: 260; y: 160; color: "#e94b3c" }
}
]
MouseArea {
anchors.fill: parent
onClicked: root.state = (root.state === "moved" ? "" : "moved")
}
}
点击后方块跳到右下角并变红,再点一次回到原位。注意这里没有任何动画,是瞬间切换------这正是下一节 Transition 要解决的问题。
3.2 关键细节
- 默认状态名为空字符串 :
state: ""表示回到默认状态,不要写成"default"。 - PropertyChanges 只覆盖属性,不删除属性:离开状态后属性回到 State 之外声明的值。
- 可以修改任意对象的属性 :通过
target指定,不限于当前组件。 - StateChangeScript :需要在进入状态时执行逻辑,可以嵌入脚本,但建议优先用
onStateChanged信号,可读性更好。 - when 条件 :
State { name: "expanded"; when: root.expanded },用布尔表达式自动激活状态,比手动赋值更声明式。
3.3 用when替代手动赋值
qml
Rectangle {
id: root
property bool expanded: false
width: expanded ? 400 : 200
height: 200
states: State {
name: "expanded"
when: root.expanded
PropertyChanges { target: root; width: 400 }
}
MouseArea {
anchors.fill: parent
onClicked: root.expanded = !root.expanded
}
}
使用 when 后,状态与业务布尔量绑定,避免了"状态名拼错"这类低级错误。
4. Transition
4.1 基本用法
Transition 定义在 transitions 列表中,通过 from 和 to 指定适用的状态切换方向,"*" 表示任意状态。
qml
Rectangle {
id: root
width: 400; height: 300
color: "#f5f5f5"
Rectangle {
id: box
width: 100; height: 100
x: 20; y: 20
color: "#4a90d9"
radius: 8
}
states: State {
name: "moved"
PropertyChanges { target: box; x: 260; y: 160; color: "#e94b3c" }
}
transitions: Transition {
from: "*"; to: "*"
NumberAnimation {
properties: "x,y"
duration: 400
easing.type: Easing.InOutQuad
}
ColorAnimation { properties: "color"; duration: 400 }
}
MouseArea {
anchors.fill: parent
onClicked: root.state = (root.state === "moved" ? "" : "moved")
}
}
现在方块会平滑移动并渐变颜色。Transition 只负责状态切换时的动画,普通属性赋值不会触发它,这是与 Behavior 最本质的区别。
4.2 精确控制方向
qml
transitions: [
Transition {
from: ""; to: "moved"
NumberAnimation { properties: "x,y"; duration: 300; easing.type: Easing.OutCubic }
},
Transition {
from: "moved"; to: ""
NumberAnimation { properties: "x,y"; duration: 600; easing.type: Easing.OutBounce }
}
]
去程快、回程带回弹,这种"非对称动画"在真实产品中非常常见,用两条 Transition 就能表达。
4.3 常见坑
- Transition 不生效 :最常见原因是
from/to写错,或者状态名拼写不一致。调试时先用from: "*"; to: "*"验证。 - 动画属性名写错 :
properties: "x,y"中的属性名必须与 PropertyChanges 中修改的属性一致,否则该属性会瞬间跳变。 - Transition 与 Behavior 冲突:同一属性同时被两者控制时,行为不确定,应只保留一种。
5. Behavior
5.1 基本用法
Behavior 挂在某个属性上,只要该属性值发生变化(无论来自状态切换、绑定更新还是代码赋值),就自动播放指定动画。
qml
Rectangle {
id: root
width: 400; height: 300
color: "#f5f5f5"
Rectangle {
id: box
width: 100; height: 100
y: 100
x: 20
color: "#4a90d9"
radius: 8
Behavior on x {
NumberAnimation { duration: 500; easing.type: Easing.OutBack }
}
}
MouseArea {
anchors.fill: parent
onClicked: box.x = (box.x < 200 ? 260 : 20)
}
}
这里没有 State,也没有 Transition,但点击后 x 依然平滑移动------因为 Behavior 拦截了属性变化。
5.2 临时禁用Behavior
有时需要"瞬间归位",可以临时关闭:
qml
Behavior on x { enabled: false; NumberAnimation { duration: 500 } }
或者在代码中动态设置 box.Behavior.enabled = false(Qt 5.15+ 支持通过 Behavior 附加属性访问)。更通用的做法是给 Behavior 加一个 id,通过 id.enabled 控制。
5.3 Behavior与Transition
| 对比项 | Behavior | Transition |
|---|---|---|
| 触发条件 | 任意属性变化 | 仅状态切换 |
| 作用范围 | 单个属性 | 一组属性 |
| 适用场景 | 简单、独立的属性动画 | 多属性协同的复杂过渡 |
| 可维护性 | 分散在属性旁,直观 | 集中管理,便于统一调整 |
经验法则:单属性、随时可能变化 → Behavior;多属性、与状态强相关 → Transition。
6. NumberAnimation
6.1 NumberAnimation详解
NumberAnimation 是最常用的动画类型,用于数值属性(x、y、width、opacity、rotation 等)。
qml
Rectangle {
id: root
width: 400; height: 300
color: "#f5f5f5"
Rectangle {
id: ball
width: 60; height: 60
radius: 30
color: "#4a90d9"
x: 20; y: 20
}
NumberAnimation {
id: moveAnim
target: ball
property: "x"
from: 20
to: 300
duration: 800
easing.type: Easing.InOutQuad
loops: 1
}
MouseArea {
anchors.fill: parent
onClicked: moveAnim.start()
}
}
关键属性说明:
target/property:动画作用对象与属性;也可以用properties: "x,y"同时驱动多个属性。from/to:起止值,省略from时从当前值开始。duration:毫秒。easing.type:缓动曲线,常用Linear、InQuad、OutCubic、OutBack、OutBounce。loops:循环次数,Animation.Infinite表示无限循环。
6.2 动画控制方法
qml
moveAnim.start()
moveAnim.stop()
moveAnim.pause()
moveAnim.resume()
moveAnim.complete() // 直接跳到终点
配合 onFinished 信号可以做链式动画:
qml
NumberAnimation {
id: anim1
target: ball; property: "x"; to: 300; duration: 500
onFinished: anim2.start()
}
NumberAnimation {
id: anim2
target: ball; property: "y"; to: 200; duration: 500
}
6.3 其他常用动画
| 类型 | 作用 | 典型属性 |
|---|---|---|
| NumberAnimation | 数值属性 | x、y、width、opacity |
| ColorAnimation | 颜色属性 | color、borderColor |
| RotationAnimation | 旋转 | rotation、angle |
| PropertyAnimation | 通用属性(含非数值) | 任意可动画属性 |
| SequentialAnimation | 串行组合 | 内部嵌套多个动画 |
| ParallelAnimation | 并行组合 | 内部嵌套多个动画 |
6.3.1 动画元素选型
| 动画类型 | 触发场景 | 能否嵌套 | 是否支持 loops | 典型误用 |
|---|---|---|---|---|
| NumberAnimation | 驱动 x、y、width、height、opacity、rotation 等数值属性变化 | 可以,作为 SequentialAnimation / ParallelAnimation 的子元素 | 支持,可设 loops: Animation.Infinite |
用 property 写多个属性名(应改用 properties: "x,y");对非数值属性使用导致无效 |
| ColorAnimation | color、borderColor、backgroundColor 等颜色属性渐变 | 可以,可嵌套在组合动画中 | 支持 | 对非颜色属性使用;忘记颜色值需为可插值的 "#RRGGBB" 格式 |
| RotationAnimation | rotation、angle 等旋转属性,需要指定旋转方向时 | 可以,可嵌套 | 支持 | 用 NumberAnimation 代替导致旋转方向不可控;未设置 direction 时跨 0/360 度出现反向旋转 |
| PropertyAnimation | 通用属性动画,包括非数值类型(如 point、size、rect) | 可以,可嵌套 | 支持 | 本可用 NumberAnimation 却一律用 PropertyAnimation,牺牲了类型检查与可读性 |
| SequentialAnimation | 多个动画按顺序依次播放,如先移动再淡出 | 可以,内部可嵌套任意动画,也可再嵌套组合动画 | 支持,作用于整个序列 | 把本应并行的动画塞进序列,导致总时长翻倍;子动画各自设置 loops 造成逻辑混乱 |
| ParallelAnimation | 多个动画同时播放,如边移动边旋转边变色 | 可以,内部可嵌套任意动画 | 支持,作用于整个并行组 | 子动画 duration 不一致时误以为整体同步结束;与 Behavior 叠加导致同一属性被重复驱动 |
选型口诀可以记成四句话:数值属性找 Number,颜色渐变找 Color,旋转方向找 Rotation,拿不准类型就用 Property。 需要"先做什么再做什么"时用 SequentialAnimation 串起来,需要"同时做什么"时用 ParallelAnimation 并起来,两者还可以互相嵌套表达复杂节奏。loops 只写在真正需要循环的那一层 ,写在组合动画上比写在子动画上更可控。最后别忘了 Behavior 与 Transition 是"触发器",动画元素是"执行者",先确定由谁触发,再决定用哪种动画元素,选型就不会乱。
6.4 组合动画示例
qml
ParallelAnimation {
id: combo
NumberAnimation { target: ball; property: "x"; to: 300; duration: 600 }
NumberAnimation { target: ball; property: "opacity"; to: 0.3; duration: 600 }
RotationAnimation { target: ball; property: "rotation"; to: 360; duration: 600 }
}
三个动画同时进行,形成"边移动边淡出边旋转"的效果。
7. 模型视图总览
QML 的模型视图体系遵循 MVC 思想,但更强调"数据驱动 UI":
- ListModel :最常用的内存模型,用
ListElement声明数据。 - DelegateModel:在 ListModel 之上做分组(groups)、过滤(filterOnGroup)、排序(itemsIndex),适合复杂列表。
- TableView :二维表格视图,配合
TableModel或自定义模型使用。
8. ListModel
8.1 声明式定义
qml
ListModel {
id: fruitModel
ListElement { name: "苹果"; price: 5.5; stock: 100 }
ListElement { name: "香蕉"; price: 3.2; stock: 200 }
ListElement { name: "橙子"; price: 4.8; stock: 150 }
}
8.2 动态增删改查
qml
// 追加
fruitModel.append({ "name": "葡萄", "price": 8.0, "stock": 80 })
// 插入
fruitModel.insert(0, { "name": "西瓜", "price": 2.5, "stock": 50 })
// 修改
fruitModel.setProperty(1, "price", 3.5)
// 读取
console.log(fruitModel.get(0).name)
// 删除
fruitModel.remove(2)
// 清空
fruitModel.clear()
注意:append 传入的是 JS 对象,字段名必须与已有 ListElement 的 role 一致,否则新字段不会出现在 delegate 中。
8.3 与ListView配合
qml
ListView {
width: 300; height: 400
model: fruitModel
spacing: 4
delegate: Rectangle {
width: ListView.view.width
height: 60
color: index % 2 === 0 ? "#ffffff" : "#f0f0f0"
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
spacing: 16
Text { text: name; font.pixelSize: 16 }
Text { text: "¥" + price.toFixed(1); color: "#e94b3c" }
Text { text: "库存 " + stock; color: "#888" }
}
MouseArea {
anchors.fill: parent
onClicked: fruitModel.setProperty(index, "stock", stock - 1)
}
}
}
delegate 中可以直接访问 role 名(name、price、stock)以及 index。修改模型后视图会自动刷新,无需手动调用更新。
8.4 性能提示
- 大量数据时避免在 delegate 中使用复杂绑定和
console.log。 - 固定高度的 delegate 显式设置
height,避免布局计算开销。 - 超过几千条数据时考虑
ObjectModel或 C++ 模型(QAbstractListModel)。
9. DelegateModel
9.1 作用
ListModel 只能"原样展示"。当需要"按分类分组显示""只显示未完成的待办""按价格排序"时,DelegateModel 提供了 groups、filterOnGroup、itemsIndex 等能力,且不修改原始数据。
9.2 分组示例
qml
ListModel {
id: taskModel
ListElement { title: "写周报"; category: "work"; done: false }
ListElement { title: "买牛奶"; category: "life"; done: false }
ListElement { title: "修复 Bug"; category: "work"; done: true }
ListElement { title: "健身"; category: "life"; done: false }
}
DelegateModel {
id: taskDelegateModel
model: taskModel
groups: [
DelegateModelGroup { name: "work"; includeByDefault: false },
DelegateModelGroup { name: "life"; includeByDefault: false }
]
filterOnGroup: "work"
delegate: Rectangle {
width: 300; height: 50
color: done ? "#e0e0e0" : "#ffffff"
Text {
anchors.centerIn: parent
text: title
font.strikeout: done
}
}
Component.onCompleted: {
for (var i = 0; i < taskModel.count; i++) {
var item = taskModel.get(i)
taskDelegateModel.items.get(i).inGroups = item.category
}
}
}
通过 filterOnGroup 切换 "work" / "life",即可实现分类筛选,原始 ListModel 不受影响。
9.3 排序
qml
DelegateModel {
id: sortedModel
model: fruitModel
delegate: Text { text: name + " ¥" + price }
function sortByPrice() {
items.setGroups(0, items.count, "items")
// 简化示例:借助 JS 排序后重建索引
var arr = []
for (var i = 0; i < items.count; i++) arr.push(items.get(i))
arr.sort(function(a, b) { return a.model.price - b.model.price })
for (var j = 0; j < arr.length; j++) items.move(j, arr[j].itemsIndex)
}
}
实际项目中排序逻辑较复杂时,更推荐在 C++ 侧用 QSortFilterProxyModel 处理,QML 只负责展示。
9.4 使用建议
- 分组数量少、逻辑简单 → DelegateModel 足够。
- 需要多级分组、动态增删组 → 考虑 C++ 代理模型。
- DelegateModel 的
items是只读视图,修改数据仍要回到原始 model。
10. TableView
10.1 基本结构
TableView 位于 QtQuick.Controls 中(Qt 5.12+ 提供 TableView,Qt 6 中为 TableView + TableModel)。它需要列定义和行数据。
qml
import QtQuick 2.15
import QtQuick.Controls 2.15
import Qt.labs.qmlmodels 1.0
ApplicationWindow {
visible: true
width: 600; height: 400
TableView {
anchors.fill: parent
columnSpacing: 1
rowSpacing: 1
clip: true
model: TableModel {
TableModelColumn { display: "name" }
TableModelColumn { display: "price" }
TableModelColumn { display: "stock" }
rows: [
{ "name": "苹果", "price": 5.5, "stock": 100 },
{ "name": "香蕉", "price": 3.2, "stock": 200 },
{ "name": "橙子", "price": 4.8, "stock": 150 }
]
}
delegate: Rectangle {
implicitWidth: 180
implicitHeight: 40
color: row % 2 === 0 ? "#ffffff" : "#f5f5f5"
border.color: "#e0e0e0"
Text {
anchors.centerIn: parent
text: display
}
}
}
}
10.2 关键点解析
- TableModelColumn :声明每一列对应的字段,
display是列名,delegate 中通过display取值。 - delegate 中的 row / column:可直接访问当前行列索引,用于斑马纹、条件样式。
- implicitWidth / implicitHeight:TableView 依赖 delegate 的隐式尺寸计算列宽行高,必须设置。
- clip: true:内容超出时裁剪,避免溢出。
10.3 自定义列宽与表头
qml
TableView {
id: table
anchors.fill: parent
columnWidthProvider: function (column) {
return column === 0 ? 200 : 120
}
rowHeightProvider: function (row) { return 40 }
// ...
}
Row {
anchors.top: parent.top
Repeater {
model: ["名称", "价格", "库存"]
Rectangle {
width: index === 0 ? 200 : 120
height: 36
color: "#4a90d9"
Text {
anchors.centerIn: parent
text: modelData
color: "white"
}
}
}
}
10.4 与ListModel结合
TableView 也可以直接使用 ListModel,此时 delegate 中通过 role 名取值:
qml
TableView {
anchors.fill: parent
model: fruitModel
delegate: Rectangle {
implicitWidth: 150
implicitHeight: 40
Text { anchors.centerIn: parent; text: name + " / " + price }
}
}
但这种方式无法区分列,通常只适合单列展示。多列场景建议使用 TableModel 或 C++ 的 QAbstractTableModel。
10.5 常见坑
- 不显示内容 :忘记设置 delegate 的
implicitWidth/implicitHeight,导致尺寸为 0。 - 滚动卡顿 :delegate 过于复杂,或未开启
reuseItems(Qt 6 默认开启)。 - 列宽不生效 :同时设置了
columnWidthProvider和 delegate 的width,后者会覆盖前者。 - Qt 5 与 Qt 6 差异 :Qt 6 中
TableView的 API 有调整,迁移时注意Qt.labs.qmlmodels的导入。
11. 综合实战
把前面知识串起来:用 ListModel 存任务,用 State + Transition 实现侧边栏展开收起,用 Behavior 让卡片高度平滑变化实现带动画任务看板
qml
import QtQuick 2.15
import QtQuick.Controls 2.15
ApplicationWindow {
visible: true
width: 800; height: 500
title: "任务看板"
ListModel {
id: tasks
ListElement { title: "需求评审"; done: false }
ListElement { title: "接口联调"; done: true }
ListElement { title: "编写文档"; done: false }
}
Rectangle {
id: sidebar
width: 200; height: parent.height
color: "#2c3e50"
states: State {
name: "collapsed"
PropertyChanges { target: sidebar; width: 60 }
}
transitions: Transition {
NumberAnimation { properties: "width"; duration: 300; easing.type: Easing.InOutQuad }
}
Text {
anchors.centerIn: parent
text: sidebar.state === "collapsed" ? ">>" : "任务列表"
color: "white"
}
MouseArea {
anchors.fill: parent
onClicked: sidebar.state = (sidebar.state === "collapsed" ? "" : "collapsed")
}
}
ListView {
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 16
model: tasks
spacing: 8
delegate: Rectangle {
width: ListView.view.width
height: done ? 40 : 70
color: done ? "#ecf0f1" : "#ffffff"
border.color: "#dcdcdc"
radius: 6
Behavior on height {
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
text: title
font.strikeout: done
font.pixelSize: 16
}
MouseArea {
anchors.fill: parent
onClicked: tasks.setProperty(index, "done", !done)
}
}
}
}
这个例子覆盖了:State 描述侧边栏两种宽度、Transition 平滑切换、Behavior 让卡片高度随完成状态动画变化、ListModel 驱动列表、delegate 中访问 role 与 index。
12. 总结与学习建议
- 先分清职责:State 描述形态,Transition 描述状态间过渡,Behavior 描述单属性自动动画,Animation 描述具体曲线。混淆它们是新手最常见的错误。
- 模型视图先跑通再优化:ListModel + ListView 能覆盖 80% 场景;需要分组过滤再上 DelegateModel;二维数据用 TableModel + TableView。
- 性能意识要提前:delegate 保持轻量,显式设置尺寸,大数据量交给 C++ 模型。
- 多读官方示例 :Qt 安装目录下的
examples/quick中有大量可运行 demo,比看文档更直观。
建议按本文顺序动手实现每个示例,再尝试把"任务看板"扩展成支持分组筛选(DelegateModel)和表格统计(TableView)的完整应用,这样两块进阶知识就能真正串起来。