ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

QML Lottie动画:播放控制、逐帧拖拽、方向控制

QML Lottie动画:播放控制、逐帧拖拽、方向控制 这篇写三个控制类示例播放控制、拖拽控制帧控制、方向控制。三个示例把LottieAnimation的控制接口用得比较全代码里踩到的坑也都留在注释里了。本文是 QML Lottie 系列第 3 篇共 3 篇。播放控制进入示例即自动播放。播放与暂停合并成一个按钮另配「停止」和「从头播放」底下实时显示status。演示代码import QtQuick import QtQuick.Controls import QtQuick.Layouts import Qt.labs.lottieqt Rectangle { id: root color: #FAFBFC property int _controlHeight: 34 // LottieAnimation 没有 playing 属性读不回播放状态 // 所以按钮文案需要自己维护一个镜像状态。 // 本示例 autoPlay: true进入即为播放中。 property bool playing: true readonly property bool ready: anim.status LottieAnimation.Ready function statusText() { // LottieAnimation.Status: Null / Loading / Ready / Error switch (anim.status) { case LottieAnimation.Null: return Null未加载 case LottieAnimation.Loading: return Loading加载中… case LottieAnimation.Ready: return Ready已就绪 case LottieAnimation.Error: return Error加载失败 } return ? } ColumnLayout { anchors.fill: parent anchors.margins: 18 spacing: 14 // ... 省略页面标题与说明文案 ... // 动画展示区 Rectangle { Layout.fillWidth: true Layout.preferredHeight: 260 radius: 10 color: #FFFFFF border.color: #E3E6ED border.width: 1 Item { id: animBox width: 230 height: 230 anchors.centerIn: parent LottieAnimation { id: anim source: qrc:/lottie/success.json autoPlay: true loops: LottieAnimation.Infinite quality: LottieAnimation.HighQuality // 不要设置 width/height加载后会被覆盖成素材尺寸。 // transformOrigin 放左上角缩放后才正好对齐外层容器。 transformOrigin: Item.TopLeft scale: (width 0 height 0) ? Math.min(animBox.width / width, animBox.height / height) : 1 onStatusChanged: { if (status LottieAnimation.Ready) root.playing true } // loops 有限时播完会自动停镜像状态要跟上本示例是 Infinite走不到 onFinished: root.playing false } } Text { anchors.bottom: parent.bottom anchors.bottomMargin: 10 anchors.horizontalCenter: parent.horizontalCenter text: 状态 root.statusText() color: anim.status LottieAnimation.Error ? #E5484D : #4A5568 font.pixelSize: 12 } // ... 省略素材尺寸角标显示「素材 600×600 → 显示 230×230」... } // 控制条未加载完成前整排禁用避免在 Loading 阶段无效点击 RowLayout { Layout.fillWidth: true spacing: 10 enabled: root.ready Button { text: root.playing ? 暂停 : 播放 Layout.preferredHeight: root._controlHeight Layout.preferredWidth: 110 onClicked: { anim.togglePause() root.playing !root.playing } } Button { text: 停止 Layout.preferredHeight: root._controlHeight onClicked: { anim.stop() // 停止并回到 startFrame root.playing false } } Button { text: 从头播放 Layout.preferredHeight: root._controlHeight onClicked: { anim.gotoAndPlay(anim.startFrame) root.playing true } } Item { Layout.fillWidth: true } Label { text: root.playing ? 播放中 : 已暂停 color: root.playing ? #2E7D4F : #888 font.pixelSize: 12 } } } }关键逻辑讲解播放状态要自己镜像LottieAnimation没有playing属性currentFrame也只是 C 里的普通函数没做成可绑定的属性。简单说就是播放状态读不回来按钮文案没法直接绑到动画上。所以只能在 QML 侧自己维护一份状态点一下翻一次property bool playing: true onClicked: { anim.togglePause() // 播放中则暂停暂停中则播放 root.playing !root.playing }onFinished里也要把状态置为 false——当loops设成有限次数时播完动画会自己停下状态不跟着改的话按钮就会一直显示「暂停」。常用的播放控制接口有这些名字已经说明了用途就不展开了方法行为play()从当前位置继续播pause()停在当前位置togglePause()二选一适合单按钮stop()停止并回到startFramegotoAndPlay(frame)跳到指定帧并播gotoAndStop(frame)跳到指定帧并停注意「从头播放」要用gotoAndPlay(anim.startFrame)不要用play()—— 后者是从当前位置继续不是从零开始。等 Ready 再启用控制enabled: root.ready // ready anim.status LottieAnimation.Ready素材是异步加载的。加载完成前startFrame和endFrame都是 0这时候点「从头播放」会跳到 0 帧——一个不在合法区间里的位置动画直接卡住不播。所以最稳妥的做法就是素材没就绪之前把整排按钮都禁用掉。autoPlay 时别碰播放头这是我写示例时踩的一个坑。素材success.json的起始帧是 25 而不是 0播放头初值却在 0落在区间外面会导致首次播放不推进帧得先点停止再点播放才正常。按理说加载完成后应该把播放头拨回合法位置onStatusChanged: { if (status LottieAnimation.Ready) gotoAndStop(startFrame) }但这行只在autoPlay: false时该加。autoPlay: true时内部顺序是「先启动播放 → 再设 Ready 状态」等Ready信号到达 QML 这边动画已经在跑了再调gotoAndStop会把刚启动的播放硬生生停掉。所以自动播放的示例里Ready时只同步一下镜像状态就行别去碰播放头。拖拽控制滑杆拖到哪一帧动画就停在哪一帧。素材是 227 帧、60fps 的文档扫描动画帧数足够多逐帧拖动最能看出区别。演示代码import QtQuick import QtQuick.Controls import QtQuick.Layouts import Qt.labs.lottieqt Rectangle { id: root color: #FAFBFC property bool playing: false property int shownFrame: 0 readonly property bool ready: anim.status LottieAnimation.Ready readonly property int frames: Math.max(0, anim.endFrame - anim.startFrame) // getDuration() 是普通函数调用QML 绑定机制捕获不到依赖关系 // 所以在表达式里显式读一下 endFrame / startFrame 当作「依赖锚点」 // 素材加载完成后这一行才会自动刷新。 readonly property string durationText: { if (anim.endFrame anim.startFrame) return — return anim.getDuration(true) 帧 / anim.getDuration(false).toFixed(2) 秒 } function frameAt(v) { return Math.round(anim.startFrame v * (anim.endFrame - anim.startFrame)) } ColumnLayout { anchors.fill: parent anchors.margins: 18 spacing: 12 // ... 省略页面标题与说明文案 ... Rectangle { Layout.fillWidth: true Layout.preferredHeight: 250 radius: 10 color: #1E1F26 clip: true // 定尺容器动画只在这个框里缩放显示 Item { id: animBox width: 210 height: 210 anchors.centerIn: parent LottieAnimation { id: anim source: qrc:/lottie/document-ocr-scan.json autoPlay: true loops: LottieAnimation.Infinite quality: LottieAnimation.HighQuality transformOrigin: Item.TopLeft scale: (width 0 height 0) ? Math.min(animBox.width / width, animBox.height / height) : 1 // autoPlay: true 时 load 完已经在播了 // 不要再 gotoAndStop会把刚启动的播放停掉只同步状态。 onStatusChanged: { if (status LottieAnimation.Ready) { root.shownFrame anim.startFrame root.playing true } } onFinished: root.playing false } } // ... 省略左上角 startFrame / endFrame / 时长调试面板 ... // ... 省略右上角当前帧角标 ... } // ... 省略播放 / 暂停按钮与上一个示例同一套写法与「回到起点」按钮 ... // 帧拖拽条拖动时先暂停避免定时器与滑杆互相打架 RowLayout { Layout.fillWidth: true spacing: 12 enabled: root.ready Label { text: 帧 color: #888 font.pixelSize: 12 } Slider { id: frameSlider Layout.fillWidth: true from: 0 to: 1 value: 0 onMoved: { if (root.playing) { anim.pause() root.playing false } root.shownFrame root.frameAt(value) anim.gotoAndStop(root.shownFrame) } } Label { text: root.shownFrame color: #333 font.pixelSize: 12 font.bold: true Layout.preferredWidth: 44 horizontalAlignment: Text.AlignRight } } } }关键逻辑讲解滑杆用比例换算滑杆的范围固定成 0 到 1实际帧号靠frameAt()函数换算出来function frameAt(v) { return Math.round(anim.startFrame v * (anim.endFrame - anim.startFrame)) }这样写的好处是换素材不用改滑杆范围。不同素材的帧区间差异很大——这份扫描动画是 0~227对勾圆环是 25~69统一用比例换算就能自动适配。有一点要注意startFrame不一定是 0所以换算时要以它为基准。另外拖动时会先暂停再跳帧不然动画定时器和滑杆互相抢控制权手一松画面又跑掉了体验会很差。读不回当前帧currentFrame没有暴露给 QML所以动画播放时读不到「现在第几帧」。示例里的做法很直接帧号以拖拽后的值为准播放中就用「playing…」占位显示。如果确实需要实时帧号只能自己在外面按帧率估算或者干脆不播、只做逐帧浏览。时长显示的依赖锚点readonly property string durationText: { if (anim.endFrame anim.startFrame) return — return anim.getDuration(true) 帧 / anim.getDuration(false).toFixed(2) 秒 }getDuration()是个普通函数QML 的绑定机制看不到它的依赖关系素材加载完成后这行不会自动刷新。解决办法是在表达式里显式读一下endFrame和startFrame——这两个是带通知信号的属性素材就绪时会发信号表达式就跟着重算了。这两个属性就像「锚点」一样把绑定挂了上去。素材没加载时endFrame和startFrame都是 0getDuration()会返回 0所以要先判一下再显示。方向控制direction控制正向 / 反向播放loops控制循环次数。素材是「删除文件」正向是文件被丢进垃圾桶反向就是文件从桶里飞回来方向感比单纯的旋转动画清楚得多。演示代码import QtQuick import QtQuick.Controls import QtQuick.Layouts import Qt.labs.lottieqt Rectangle { id: root color: #FAFBFC property bool playing: false property int selectedLoops: -1 // -1 表示永远循环 // 有限循环跑完后播放头会停在区间外标记一下便于重新起步 property bool everFinished: false readonly property bool ready: anim.status LottieAnimation.Ready function loopLabel() { return selectedLoops -1 ? 无限 : (selectedLoops 次) } // 当前方向对应的起点正向从头反向从尾 function startFrameOf() { return anim.direction LottieAnimation.Forward ? anim.startFrame : anim.endFrame } // 从方向对应的一端重新起步。gotoAndPlay() 内部会把循环计数器清零。 function restart() { anim.gotoAndPlay(startFrameOf()) root.everFinished false root.playing true } // 播放 / 暂停合并成一个按钮 function togglePlay() { if (root.playing) { anim.pause() root.playing false return } // 跑完过之后不能再用 play()播放头在区间外play() 只会立刻再报一次 finished if (root.everFinished) { root.restart() } else { anim.play() root.playing true } } // 改 loops 必须让内部的循环计数器归零否则新的循环次数不生效。 // 计数器只在 reset() / gotoAndPlay() / setDirection() 里清零 // stop() 内部就是 reset()所以用它。 function setLoops(v) { root.selectedLoops v anim.loops v anim.stop() // 停表 计数器归零播放头回到该方向的起点 root.everFinished false root.playing false } // 切方向setDirection() 自己会清零计数器再用 gotoAndPlay 从该方向的起点起跑 function setDirection(d) { anim.direction d root.restart() } ColumnLayout { anchors.fill: parent anchors.margins: 18 spacing: 12 // ... 省略页面标题与说明文案 ... Rectangle { Layout.fillWidth: true Layout.preferredHeight: 240 radius: 10 color: #FFFFFF border.color: #E3E6ED border.width: 1 // 定尺容器素材 1200×1200控件尺寸会被强制写成素材尺寸只能靠 scale 缩进这个框 Item { id: animBox width: 200 height: 200 anchors.centerIn: parent LottieAnimation { id: anim source: qrc:/lottie/delete-bin.json autoPlay: false loops: root.selectedLoops direction: LottieAnimation.Forward quality: LottieAnimation.MediumQuality transformOrigin: Item.TopLeft scale: (width 0 height 0) ? Math.min(animBox.width / width, animBox.height / height) : 1 onStatusChanged: { if (status LottieAnimation.Ready) { gotoAndStop(startFrame) root.playing false root.everFinished false } } onFinished: { root.playing false // 有限循环播完会自己停 root.everFinished true } } } // ... 省略左上角「正向 / 反向 · 循环次数」状态角标 ... } // 播放控制 RowLayout { Layout.fillWidth: true spacing: 10 enabled: root.ready Button { text: root.playing ? 暂停 : 播放 implicitHeight: 32 implicitWidth: 88 onClicked: root.togglePlay() } Button { text: 重播 implicitHeight: 32 implicitWidth: 72 onClicked: root.restart() } Item { Layout.fillWidth: true } Label { text: 第 anim.startFrame ~ anim.endFrame 帧 color: #666 font.pixelSize: 11 } } // 方向控制正向 / 反向 互斥选中 RowLayout { Layout.fillWidth: true spacing: 12 enabled: root.ready Label { text: 方向; color: #555; font.bold: true } ButtonGroup { id: dirGroup exclusive: true } Button { text: 正向 implicitHeight: 32 implicitWidth: 76 checkable: true checked: anim.direction LottieAnimation.Forward ButtonGroup.group: dirGroup onClicked: root.setDirection(LottieAnimation.Forward) } Button { text: 反向 implicitHeight: 32 implicitWidth: 76 checkable: true checked: anim.direction LottieAnimation.Reverse ButtonGroup.group: dirGroup onClicked: root.setDirection(LottieAnimation.Reverse) } Item { Layout.fillWidth: true } } // 循环次数控制互斥选中 RowLayout { Layout.fillWidth: true spacing: 12 enabled: root.ready Label { text: 循环; color: #555; font.bold: true } ButtonGroup { id: loopGroup exclusive: true } Repeater { model: [ { v: 1, t: 1 次 }, { v: 3, t: 3 次 }, { v: -1, t: 无限 } ] delegate: Button { text: modelData.t implicitHeight: 32 implicitWidth: 76 checkable: true checked: root.selectedLoops modelData.v ButtonGroup.group: loopGroup onClicked: root.setLoops(modelData.v) } } Item { Layout.fillWidth: true } } } }关键逻辑讲解反向从尾帧起跑direction设成反向之后播放头是从当前位置往回走。如果不管它动画会从当前帧倒退到起点就直接结束——看起来就像「只播了一小段」完全不是想要的效果。所以切方向时要重新定位起点正向从第一帧开始反向从最后一帧开始。function startFrameOf() { return anim.direction LottieAnimation.Forward ? anim.startFrame : anim.endFrame } function restart() { anim.gotoAndPlay(startFrameOf()) root.everFinished false root.playing true }改 loops 要清计数器最开始我直接给loops赋值结果选了「1 次」动画还是一直循环下去finished再也不触发。原因是引擎内部有个循环计数器直接改loops不会把它清零。可以简单理解成这样动画每播完一圈计数器就加 1只要「设定次数 - 已播次数」不等于 0就继续循环。如果你已经播了 3 圈再改成「1 次」差值永远是负数动画就会一直循环下去。而这个计数器只在少数几个操作里会清零比如reset()、gotoAndPlay()、setDirection()。直接赋值、play()、pause()、gotoAndStop()都不会清。修法就是赋值之后走一个会清零计数器的入口stop()内部就是reset()所以用它最方便function setLoops(v) { root.selectedLoops v anim.loops v anim.stop() // 停表 计数器归零 root.everFinished false root.playing false }顺带还有第二个坑有限循环跑完之后再调play()不会播。因为播完时播放头停在了帧区间外面此时play()只会立刻再发一次finished一帧都不画。要重新起步必须走gotoAndPlay()。示例里用一个everFinished标记区分「暂停中」和「已经播完了」两种情况播完了就走重新起步的逻辑。三个示例的共同点示例素材固有尺寸 / 帧率 / 帧数主要接口特点播放控制success600² / 30fps / ip 25–op 69play()pause()togglePause()stop()gotoAndPlay()进入即自动播放播放暂停合并一个按钮拖拽控制document-ocr-scan600² / 60fps / 0–227gotoAndStop()getDuration()startFrameendFrame滑杆逐帧定位播放中读不回当前帧方向控制delete-bin1200² / 30fps / 0–60directionloopsonFinished正反向互斥选中循环次数三档三个示例有三个共同的写法约定放在一起记比较省事。第一是状态自己镜像playing不是LottieAnimation的属性按钮文案必须自己维护一份。第二是autoPlay: true时别在Ready里调gotoAndStop()那会把刚启动的播放停掉只有autoPlay: false才需要用它来拨正播放头的初始位置。第三是尺寸靠外层容器加scale来控制不给LottieAnimation直接写width/height写了也会被素材尺寸覆盖掉。系列文章QML 动画选择GIF与Lottie 的区别以及 Qt 6.8 与 6.11 的 Lottie 实现差异QML Lottie动画素材独立控制与尺寸适配QML Lottie动画播放控制、逐帧拖拽、方向控制 【本篇】已验证环境Qt 版本推荐 Qt 6.11操作系统Windows 11工程下载QML 示例合集V2.0 - qml_lottie
返回列表