QML组合动画实战:SequentialAnimation与ParallelAnimation详解
动画这一块我在前几篇笔记里把基础类型逐个过了一遍从 PropertyAnimation 到 NumberAnimation、ColorAnimation、RotationAnimation 都单独整理过。单个属性动起来确实不难但真正让我觉得 QML 动画系统“好用”的节点是在我彻底搞明白组合动画之后。这篇笔记就专门记 SequentialAnimation 和 ParallelAnimation解决的是“好几个属性按顺序动”和“好几个属性同时动”这两类高频需求。如果你已经会用 NumberAnimation 改一个位置、用 ColorAnimation 改一个颜色但想让界面的动效真正有节奏、有层次那这篇内容应该正好是你需要的。1. 单条动画的边界为什么需要组合动画1.1 一条动画只能干一件事先看 QML 里最简单的动画写法NumberAnimation { target: box property: x to: 200 duration: 600 }这种代码我写过很多次作用很直接让 box 的 x 属性在 600 毫秒内变化到 200。可问题也在这里——一条 NumberAnimation 一次只能操作一个对象的一个属性。你当然可以在一个 Item 上同时挂好几个NumberAnimation on x、NumberAnimation on y让它们各自跑起来。但一旦动画数量变多它们之间的启动时机、结束顺序、相互覆盖关系就全靠手动协调很容易出现“一个还没跑完另一个已经结束”的时序混乱。举例来说我想做一个“先向右移动 200 像素再向下移动 100 像素同时颜色从蓝色变成橙色”的动效。用单条动画怎么实现没法实现。因为“先……再……”代表顺序执行“同时”代表并行执行这两种时序关系单条属性动画都表达不了。这时候就需要组合动画登场。1.2 组合动画的本质动画容器组合动画在 QML 里不是一种全新的动画算法而是一个“动画容器”。它本身不直接改变任何属性它的工作是管理一组子动画的播放节奏。容器内部可以放任意 Animation 类型的子项包括NumberAnimation数值变化最常用ColorAnimation颜色过渡RotationAnimation旋转动画PauseAnimation暂停占位ScriptAction在指定时刻执行一段脚本甚至可以再嵌套一个 SequentialAnimation 或 ParallelAnimation组合动画分两种SequentialAnimation串行子动画一个接一个执行前一个 duration 走完后一个才开始ParallelAnimation并行所有子动画在同一时刻一起开始各自按自己的 duration 走理解了这两个容器你会发现 QML 动画系统的表达能力一下子上了一个台阶。以前只能做“单属性过渡”现在能做“多阶段、多属性的完整动效编排”。2. SequentialAnimation串行动画的正确打开方式2.1 顺序执行的基础写法SequentialAnimation 的使用非常直观。把子动画按执行顺序排列框架会自动等前一个动画结束以后再启动下一个。下面这段代码是我测试时最常用的基础模板import QtQuick 2.15 Rectangle { width: 360 height: 240 color: #f5f5f5 Rectangle { id: box width: 80 height: 80 x: 20 y: 80 color: #42a5f5 radius: 8 SequentialAnimation { id: seqAnim running: true NumberAnimation { target: box property: x to: 240 duration: 600 } NumberAnimation { target: box property: y to: 120 duration: 600 } NumberAnimation { target: box property: rotation to: 180 duration: 500 } ColorAnimation { target: box property: color to: #ff7043 duration: 400 } } } }运行起来的效果是方块先横向移动再纵向移动接着旋转 180 度最后变色。整个过程一次播完顺序完全可控。这里有一个细节值得注意我没有在后面的动画里写from值。SequentialAnimation 在执行时前一个动画结束后的属性值会自动成为后一个动画的起始值。比如x从 20 被第一个动画带到 240后面不需要再管from系统知道当前 x 已经是 240。这是一个非常顺手的机制但也是后面很多坑的来源——如果你在某个子动画里手动写了from它会强制从这个值开始覆盖掉前一个动画的结果造成视觉跳变。2.2 用 PauseAnimation 和 ScriptAction 控制节奏光有顺序还不够有时候动画之间需要一个停顿或者需要在某个阶段切换状态。这两个需求分别由 PauseAnimation 和 ScriptAction 解决。PauseAnimation 本身不做任何属性变换它的作用就是“占时间”。比如做一个呼吸灯效果放大之后停一会儿再缩小就非常适合用它SequentialAnimation { running: true loops: Animation.Infinite NumberAnimation { target: box property: opacity to: 0.2 duration: 500 } PauseAnimation { duration: 300 } NumberAnimation { target: box property: opacity to: 1 duration: 500 } PauseAnimation { duration: 300 } }PauseAnimation 的 duration 单位也是毫秒它不会阻塞 UI只是告诉组合动画“这段时间什么都不做”。ScriptAction 则更有意思。它可以在动画序列的某个位置插入一段 JavaScript 逻辑。比如一个“先缩小再换颜色再恢复”的动效在缩小和恢复之间切换颜色用 ScriptAction 比再加一条 ColorAnimation 更灵活SequentialAnimation { NumberAnimation { target: box property: scale to: 0.6 duration: 200 } ScriptAction { script: { box.color #fff59d // 这里可以放任意逻辑比如切换状态、更新文案 } } NumberAnimation { target: box property: scale to: 1 duration: 200 } }建议ScriptAction 里不要写耗时操作它运行在 GUI 线程上一旦执行复杂计算动画会出现明显卡顿。2.3 loops、alwaysRunToEnd 和循环动画的三个坑组合动画作为 Animation 的子类天然支持loops属性。loops: Animation.Infinite是无限循环指定数字就是循环固定次数。这个很简单但实际使用中有三个坑我必须记录一下。第一个坑中断后属性残留。SequentialAnimation 一旦执行中被 stop后面还没执行的子动画会直接跳过属性会停在中断那一刻的值上。比如“先移动再变色”执行到移动过程中被 stop颜色永远不会变。解决办法是在需要停的地方手动把属性设置到最终值或者在 stop 后调用complete()。Animation 没有 complete 方法但可以自己写一个收尾函数把各属性归位。第二个坑alwaysRunToEnd 的延迟停止。给组合动画设置alwaysRunToEnd: true以后调用 stop 不会立即中断动画而是等当前整段序列跑完再停下。这在状态切换动效里很实用可以避免画面切到一半的尴尬但如果你在按钮的 onClicked 里写“点击停止”第一次点击可能没有“立即停止”的效果用户会以为按钮没响应。我建议在需要快速响应的交互场景里慎用或者提前计算好剩余时间给用户一个视觉反馈。第三个坑loops 和嵌套动画的叠加。如果外层 SequentialAnimation 设了loops: 3内部某个子动画自己也设了loops: 2执行顺序会变成“内部子动画循环 2 次然后外层推进一次整体循环 3 次”。这个逻辑容易把人绕晕所以我现在的习惯是只在最外层控制循环次数内层一律不设 loops。还有一个小技巧restart()等价于先 stop 再 start。在串行动画里restart 会让动画重新从第一个子动画开始这是很常见的重播方式。3. ParallelAnimation并行动画的协作方式3.1 让多个属性同时动起来ParallelAnimation 的语义就一句话所有子动画同时启动互不等待。ParallelAnimation { id: parAnim NumberAnimation { target: box property: x to: 240 duration: 800 } NumberAnimation { target: box property: y to: 120 duration: 800 } ColorAnimation { target: box property: color to: #7e57c2 duration: 800 } RotationAnimation { target: box property: rotation to: 360 duration: 800 } }这段代码会让方块从 (20, 80) 斜着移动到 (240, 120)同时颜色变成紫色旋转一整圈。所有变化都在 800 毫秒内完成。ParallelAnimation 的执行时长不是子动画 duration 之和而是所有子动画里的最大值。这一点在规划动效总耗时的时候非常有用。比如一个子动画 600 毫秒、另一个 800 毫秒整个并行动画就是 800 毫秒600 毫秒的那个跑完以后会“空转”等待剩余时间直到 800 毫秒结束。并行动画的每个子动画仍然可以单独设置 easing 曲线。比如 x 方向用Easing.OutQuady 方向用Easing.InOutBack出来的轨迹就是一个“先快后慢”加“过冲回弹”的合成效果。这种组合方式比统一缓动更有“手调感”我经常用它来做卡片弹入的动效。3.2 嵌套组合串行里套并行并行里套串行组合动画真正强大的地方不是单层使用而是嵌套。一个完整的动效通常可以拆成多个阶段每个阶段内部是并行的阶段与阶段之间是串行的。这种结构在 QML 里写出来非常自然SequentialAnimation { id: complexAnim // 阶段一同时缩小和旋转 ParallelAnimation { NumberAnimation { target: box property: scale to: 0.6 duration: 300 } RotationAnimation { target: box property: rotation to: 30 duration: 300 } } // 阶段二向下移动 NumberAnimation { target: box property: y to: 150 duration: 400 } // 阶段三同时变色和变透明 ParallelAnimation { ColorAnimation { target: box property: color to: #e57373 duration: 300 } NumberAnimation { target: box property: opacity to: 0.2 duration: 300 } } }执行顺序非常清楚先缩小并旋转然后向下移动最后变色并变透明。三层结构在当前这个例子里已经够用如果你需要更复杂的编排完全可以再往 ParallelAnimation 里塞 SequentialAnimation形成任意深度的组合树。嵌套时有一个规划建议先在纸上画出“阶段图”确定哪几个属性属于同一阶段、阶段之间是否需要停顿再写代码。我有好几次直接在 QML 里凭感觉嵌套结果跑起来节奏完全不对最后还是得回到“阶段拆分”这个思路来调整。3.3 在 Transition 里用组合动画驱动状态切换组合动画不只可以主动 start/stop还有一个非常重要的使用场景Transition。QML 的 State 配合 Transition可以在状态切换时自动播放过渡动画而 Transition 里可以直接放组合动画。import QtQuick 2.15 Rectangle { width: 360 height: 240 color: #f5f5f5 Rectangle { id: box width: 70 height: 70 x: 30 y: 30 color: #42a5f5 states: [ State { name: normal PropertyChanges { target: box x: 30 y: 30 color: #42a5f5 } }, State { name: expanded PropertyChanges { target: box x: 200 y: 120 color: #ff7043 width: 100 height: 100 } } ] transitions: [ Transition { from: normal to: expanded reversible: true SequentialAnimation { ParallelAnimation { NumberAnimation { property: x; duration: 400 } NumberAnimation { property: width; duration: 400 } } ParallelAnimation { NumberAnimation { property: y; duration: 400 } ColorAnimation { property: color; duration: 400 } } } } ] } MouseArea { anchors.fill: parent onClicked: box.state (box.state normal ? expanded : normal) } }注意到没有Transition 里的子动画不需要写 target框架会自动把动画作用到 State 变化涉及的对象上。这是组合动画在 Transition 里使用的一个便捷点。reversible: true这个属性也很关键。它让 Transition 在从 expanded 退回 normal 时自动反向执行动画序列。如果没有它状态从 expanded 回到 normal 时动画仍然按“先 x 后 y 再 color”的正向顺序播放视觉上会非常奇怪。串行组合在 Transition 里的可逆性是我踩过坑之后才注意到的细节。4. 实操案例把组合动画揉进一个真实小项目4.1 案例A卡片“弹入-移动-变色”分段动画写一个完整的小页面点击卡片依次执行缩小、旋转、移动、变色、放大整个过程串行。import QtQuick 2.15 import QtQuick.Controls 2.15 ApplicationWindow { visible: true width: 400 height: 300 title: SequentialAnimation Demo Rectangle { id: page anchors.fill: parent color: #f5f5f5 Rectangle { id: box width: 80 height: 80 x: 160 y: 110 color: #42a5f5 radius: 8 SequentialAnimation { id: seqAnim running: false NumberAnimation { target: box property: scale to: 0.4 duration: 250 } NumberAnimation { target: box property: rotation to: 45 duration: 250 } NumberAnimation { target: box property: y to: 180 duration: 400 } ColorAnimation { target: box property: color to: #ff7043 duration: 300 } NumberAnimation { target: box property: scale to: 1 duration: 250 } } MouseArea { anchors.fill: parent onClicked: seqAnim.restart() } } } }这里故意不用anchors.centerIn定位 box而是写死了 x/y。原因是 anchors 的优先级高于手动设置 x/y一旦你用动画改 x/yanchors 会在下一帧把位置拉回来动画就失效了。这是 QML 里一个非常隐蔽的坑。点击一次卡片会走完“缩小-旋转-下移-变色-放大”全流程。再点一次restart 会从头播放。如果你快速连续点击动画会在每次点击时重新开始画面看起来可能会有点“跳”这正好引出下面要讲的启停控制问题。4.2 案例B加载动效的无限循环组合组合动画很适合做加载指示器。我的目标是做一个“外圈连续旋转两周内圈节奏性缩放”的动效Rectangle { width: 100 height: 100 color: transparent Rectangle { id: outer width: 60 height: 60 anchors.centerIn: parent color: transparent border.color: #42a5f5 border.width: 4 radius: 30 } Rectangle { id: innerDot width: 12 height: 12 anchors.centerIn: parent color: #42a5f5 radius: 6 } SequentialAnimation { id: loadAnim running: true loops: Animation.Infinite ParallelAnimation { NumberAnimation { target: outer property: rotation to: 360 duration: 1200 } NumberAnimation { target: innerDot property: scale to: 0.2 duration: 600 } } PauseAnimation { duration: 200 } ParallelAnimation { NumberAnimation { target: outer property: rotation to: 720 duration: 1200 } NumberAnimation { target: innerDot property: scale to: 1 duration: 600 } } PauseAnimation { duration: 200 } } }整个动效的节奏可以预算出来第一段并行 1200 毫秒内圈在此时段内完成“缩小-再放大”其实用了两个并行段第二段 1200 毫秒内圈继续回弹加上两个 200 毫秒暂停一个循环总计 2800 毫秒。由于外圈 rotation 从 0 到 360 再到 720整体视觉上外圈是匀速连续转了两圈内圈则呈呼吸感缩放。这个例子充分展示了组合动画在“循环动效”上的优势无限循环 多属性并行 阶段错峰全部由一个顶层 SequentialAnimation 搞定代码结构非常清晰也不需要在外面写任何 JS 定时器。4.3 用点击事件动态启停组合动画组合动画的 running 属性是只读的不能直接绑一个 JS 变量来控制启停但可以通过 start、stop、restart 这三个方法来管理。下面这段代码就是点击页面任意位置切换动效启停Rectangle { width: 360 height: 240 color: #eeeeee Rectangle { id: box width: 80 height: 80 x: 140 y: 80 color: #66bb6a radius: 40 SequentialAnimation { id: pulseAnim loops: Animation.Infinite NumberAnimation { target: box property: scale to: 1.2 duration: 500 } NumberAnimation { target: box property: scale to: 1 duration: 500 } } } MouseArea { anchors.fill: parent onClicked: { if (pulseAnim.running) { pulseAnim.stop() box.scale 1 } else { pulseAnim.restart() } } } }点击页面开始呼吸缩放再点击停止。注意我在 stop 之后手动把box.scale 1归位了这一步非常关键——因为动画可能在缩放中途被 stop如果不手动归位圆形就会卡在 1.1 或者 0.9 这种中间值上下次 restart 又会从那个值开始整个动画的起点就被污染了。还有一种做法是不调用 stop而是设置running: false效果相同。但 running 是只在声明时有效还是可以在运行时动态赋值实际上 running 属性是可以在运行时赋值的不过我更习惯用 start/stop/restart因为语义更明确配合归位逻辑也更顺手。如果项目里需要精确控制动画播放进度可以考虑用 AnimationController 配合组合动画不过 AnimationController 更多是和单一动画配合组合动画的场景下用 start/stop 已经足够。这算是我自己测试下来的边界组合动画的优势在“完整编排”精确进度控制不是它的强项。5. 排错实战组合动画常见问题排查清单5.1 先看速查表我把这一路上踩过的问题整理成一个速查表遇到问题可以先对着查一遍。现象常见原因解决方案动画只跑了一部分就停中途有 stop或者 running 被外部条件改成了 false检查是否调用了 stop在需要完整播完时启用 alwaysRunToEnd属性没变化但动画看起来在跑target 写错或 property 名字拼错打印 target 的对应属性值确认属性名准确并行动画中某个值突然跳变并行容器里两个子动画操作了同一个属性后写入的覆盖了前面的结果拆成串行或合并成一个动画只改一个属性串行动画衔接处有跳变某个子动画显式写了 from覆盖了前一阶段结束值去掉 from或者把 from 改成前一阶段的目标值循环动画运行时 UI 卡顿ScriptAction 里执行了耗时逻辑或者动画对象过多把耗时逻辑移出动画线程适当合并子动画数量Transition 反向切换时动画顺序乱了没有设置 reversible: true在 Transition 上补上 reversible5.2 动画只执行一半running 与 alwaysRunToEnd遇到最多的问题就是“动画只跑一半”。一种典型场景是你在某个 Item 的onVisibleChanged里启动动画但 Item 在动画中途被隐藏运行中的动画会被强制停止。另一种场景是用running: mouseArea.pressed绑定了鼠标按下状态用户点一下快速松开动画只执行了那一下的几十毫秒就停了。这两种情况本质都是“动画被外部条件中断”。解决方案取决于你想达到的效果如果希望每次触发都完整播完可以在动画上设置alwaysRunToEnd: true如果你想随时可以用新触发重新开始就不要开 alwaysRunToEnd但要做好停止后的状态归位。我自己的经验是像按钮点击这类交互动效通常不需要 alwaysRunToEnd因为快速连点时用户更在意“新动画立刻跟上”而不是把旧动画播完。但如果是页面的入场动画、状态过渡动画alwaysRunToEnd 就很值得开避免画面卡在半路。5.3 串行动画衔接处出现跳变串行动画最理想的状态是每个属性值都“无缝衔接”但有时候你会发现前一个动画刚结束画面突然跳了一下。多数情况下是 from 值的问题。举个例子一个方块先从 x20 移动到 x240下一段动画本意是从 240 继续移到 300。如果你在第二段动画里惯性写了from: 20; to: 300动画会从 20 直接跳到 300和第一段结束时画面产生了明显跳变。排查方法很简单在串行动画里除了第一段后面每段都默认不写 from。让动画从前一段结束时的属性值自然开始。如果你确实需要指定起始值那就把它写成前一段的目标值保持逻辑一致。还有一个更隐蔽的问题ColorAnimation 的 from 如果不写同样会从当前颜色开始过渡但如果你前一段把它改成了某个颜色后一段的起始颜色也会跟着变。这个在设计渐变配色时尤其要注意。5.4 并行动画的属性冲突ParallelAnimation 看起来可以让多个动画同时操作同一个属性但实际上这是风险最大的写法。比如一个并行容器里既有一个 NumberAnimation 改 x又有一个 Behavior 或另一个并行动画也在改 x最终效果取决于“最后写入”的那个动画结果就是画面闪烁、值不确定。我的经验是并行动画只操作不同的属性或不同的对象。如果多个属性需要同步变化但它们之间的变化节奏相同可以直接用一条 NumberAnimation 的properties属性同时驱动多个属性比如properties: x,y这样既简洁又不会冲突。如果必须用不同 duration 或不同 easing那就拆到不同阶段里用串行容器串起来。一旦出现并行动画“属性打架”优先检查是不是内部有两条动画指向同一 target 的同一 property。这种问题在代码里很难一眼看出来我通常会在排查时把并行容器里的动画先注释到只剩一条确认单独能跑再一条一条加回来直到定位出冲突的组合。5.5 动画闪变、停顿失效duration 与 easing 的细节还有一个经常被忽略的点duration 为 0 的动画。如果你给某个子动画设了duration: 0它会立即跳到 to 值看起来就像“闪变”而不是平滑过渡。这在组合动画里会打破整个节奏特别是前后紧接的动画会失去衔接感。排查时如果发现某一段变化特别突兀先看看那段动画的 duration 是不是被写成了 0。停顿失效则和 PauseAnimation 有关。如果你的 PauseAnimation 被放在loops: Animation.Infinite的循环里停顿确实会生效但如果 PauseAnimation 被放在了并行容器里它并不会阻塞整个并行组——它只是“不同步地空转”其他子动画不会等它。PauseAnimation 更合理的用法是放在串行容器里和需要等待的动画处于同一个时间轴上。easing 也是一个容易影响观感的细节。组合动画里每个子动画默认都是线性缓动如果你想做出“先快后慢”的立体感要记得在每个子动画里单独设置easing.type。常见的选择我和你分享几个Easing.OutQuad先快后慢适合位移Easing.OutBack带一点回弹适合缩放Easing.InOutCubic起止都缓适合透明度变化这些缓动曲线可以混合使用。比如并行动画里x 用 OutQuad、scale 用 OutBack出来的效果比统一线性要生动得多。写到这里这篇关于组合动画的笔记基本把我在实际项目里常用的内容都覆盖了。个人体会是组合动画并不难难的是在复杂动效里保持每个属性的状态可预期。我现在但凡遇到超过两个阶段、或者涉及多个属性联动的动效都会一律用 SequentialAnimation 和 ParallelAnimation 去封装而不是同时开一堆散装动画。这样做代码可读性高排查问题也容易定位。后面如果继续深入 QML 动画我大概率会往粒子动画和状态机动画的方向再写几篇到时候再和大家分享实测经验。