做 QML 动画做了大半年,我一度觉得rotation和scale两个属性就是 Item 变换的全部。直到某次原型里需要一个卡片翻转菜单——点击一张卡,它要绕着纵轴转 180 度露出背面——用内置rotation硬做才发现压根转不出来:内置旋转只能绕 Z 轴,也就是屏幕垂直方向转,绕着 X 轴或者 Y 轴翻面这种事它完全帮不上忙。这次把 QML 动画里最容易被低估的一块——Transforms 变换——完整捋了一遍,从基础用法、动画配合到踩坑经历,都记录在这篇笔记里。如果你也卡在"旋转中心不对""多个变换互相串味""卡片翻转没有立体感"这些问题上,这篇应该能省你不少试错时间。
1. 先别急着用 rotation 属性:Transform 能做的三种"越级"变换
很多刚接触 QML 的人,包括我自己,最早都是用Rectangle的rotation属性来做动画。转一圈、晃一下、提示气泡弹出来,这些效果内置属性完全够用。可一旦需求从"简单动画"升级成"复合变换",内置属性和 Transform 体系的差距就藏不住了。这里的核心区别可以归纳成三点:变换原点的自由度、变换顺序的可控性、以及有没有三维旋转能力。
1.1 变换原点:枚举值永远比不过一个能算的坐标
内置rotation和scale调整中心点时,用的是transformOrigin枚举,也就那固定的九个档位:左上、中上、右上、中左、中央、中右、左下、中下、右下。选一个档位,旋转就围绕对应点转。做简单动效时这够用,但只要卡片大小是动态的,或者旋转中心需要根据内容位置计算,九宫格就明显不够看了。
可能有人会说:Item 不是也有originX/originY吗,能自定义中心点啊。确实,Qt 5.5 之后 Item 有这两个属性,设置之后transformOrigin会变成Custom。但这里有个很鸡肋的地方:这个自定义中心只服务于内置的 rotation / scale,只要你想在同一条变换链里头先旋转再缩放,或者旋转的同时把坐标平移一下,内置属性就抓瞎了。而Transform家族的每个成员(尤其是Rotation和Scale)都自带origin.x/origin.y,它们可以独立计算,也可以参与动画。这意味着你可以让旋转中心随着卡片尺寸动画实时变化,而不是只能选一个固定锚点。
1.2 变换顺序:Item 自带属性被固定死,Transform 由你说了算
内置rotation、scale、x/y这几套变换,它们在渲染时走的是固定的流水线,顺序基本是写死的:先平移、再旋转、最后缩放,你是没法中途插一脚的。大多数场景这不影响什么,但当你需要"先旋转 90 度再沿旋转后的方向平移"时,内置属性就做不出来了。
Transform体系把选择权还给了开发者:transforms: []数组里按声明顺序逐个执行变换,每个变换会作用在前一个变换产生的结果之上。顺序不同,最终图形完全不同。这就好比拍照时先摆姿势再换背景,和先换背景再摆姿势,得到的构图逻辑不是一回事。在动画场景里,顺序对了,圆弧轨迹、围绕自身旋转同时位移这些效果都能顺理成章地做出来;顺序不对,就会出现"旋转中心飘走了""缩放方向反了"这种看着特别诡异的画面。
1.3 绕 X 轴、Y 轴旋转:二维系统里的"伪三维"
这一条才是真正让大多数人转向 Transform 的硬需求。Item.rotation只能绕 Z 轴(也就是屏幕朝向你的那根轴)转,所以它本质上是个平面旋转。但Transform.Rotation提供了一个axis属性,可以指定任意的三维向量作为旋转轴。设成(0, 1, 0)就绕 Y 轴转,做出左右翻牌;设成(1, 0, 0)就绕 X 轴转,做出从上往下的翻页。虽然 Qt Quick 里的这些旋转在数学上仍然是 2D 平面上的仿射变换组合,但从视觉上已经足够模拟三维翻转的动态过程了。
需要补一句:这种"伪三维"没有透视投影,旋转过程中卡片不会有"远小近大"的收缩感,是一个正交投影效果。想要更真实的三维透视,得靠Matrix4x4手动加投影,或者借助 Qt Quick 3D 那套带透视相机的渲染体系。这个我后面实战部分会再展开。
2. Transform 家族逐个拆解:Translate、Rotation、Scale、Matrix4x4 和 GroupTransform
QML 的 Transform 体系一共有几个常用的子类,很多教程只讲了 Rotation,导致有人误以为 Transform 就是旋转。实际上它是一整个家族,每个成员解决一类特定问题,组合起来才是完整能力。
| Transform 类型 | 核心属性 | 典型用途 |
|---|---|---|
| Translate | offsetX / offsetY / offsetZ | 沿指定方向位移,可参与变换链 |
| Rotation | angle / axis / origin | 二维或三维旋转,动画重头戏 |
| Scale | xScale / yScale / zScale / origin | 缩放宽高,支持负值镜像 |
| Matrix4x4 | matrix | 完全自定义变换矩阵,投影也能做 |
| GroupTransform | 子 Transform 列表 | 把一组变换封装成一个整体 |
2.1 Translate:一个能参与变换链的位移
Translate看着和直接改x/y差不多,但它的关键差异在于:它活在transforms数组里,受其它变换的影响。比如你先 Scale 缩小一半,再 Translate 10 像素,那这 10 像素也是被缩小过的 10 像素;但如果先 Translate 再 Scale,位移量会跟着缩放倍数一起变化。这就是变换链里的"连带关系"。在动画里我经常拿Translate做相对偏移,比如一个组件在缩放动画的同时要往右上角挪一点,用Translate配合Scale的缩放中心,可以做到位移量跟着缩放一起"自适应",比手算像素坐标干净得多。
有一点要注意:OffsetZ在实际 2D 渲染里不像三维引擎那样产生前后遮挡,它更多是改变了变换的矩阵结构。我在实践中几乎只用offsetX/offsetY,offsetZ留给准备上Matrix4x4的复杂场景。
2.2 Rotation:axis 是它和内置 rotation 的本质区别
Rotation是最常用的一个。基本写法:
Rectangle { width: 200 height: 120 transform: Rotation { origin.x: 100 origin.y: 60 axis { x: 0; y: 1; z: 0 } angle: 45 } }origin控制旋转中心,默认是(0, 0),也就是 Item 自己的左上角,不是很多人以为的 Item 中心。所以写卡片翻转效果时,origin.x和origin.y必须指向卡片的几何中心,否则一转就变成绕角转。axis默认是(0, 0, 1),等价于内置 rotation 的平面旋转。真正让Rotation强大的地方在于你可以把它连续作用在多个轴上,也可以在动画里不断改变axis,做出"翻面 + 倒转"的组合效果。
另外一个容易忽略的点:Rotation.angle是真正的数值属性,可以放进NumberAnimation里补间,也可以绑定到一个表达式上。它没有内置 rotation 那个"最短路径"的自动处理——什么意思呢?就是你把角度从 350 动画到 10,它会直接往回退 340 度走一圈,而不是只转 20 度。如果你希望走短路径,得自己控制中间值,或者用两个独立的补间阶段去接。
2.3 Scale:负值镜像和"锚定"缩放
Scale的xScale/yScale控制 X / Y 方向的缩放倍数,zScale基本也是留给三维矩阵场景用。它同样有自己的origin,默认在左上角。想要围绕中心缩放,就得把 origin 设成width / 2、height / 2。
比内置scale多出来的能力首先是负值。把xScale设成-1,组件会沿 Y 轴镜像翻转,这在做"左右对称摆位""倒影效果"时特别顺手。其次是它可以和Rotation优雅地组合:绕 X 轴旋转配合负的yScale,在动画中途能把正反面重叠带来的视觉错乱处理掉一部分。
还要提醒一下:如果 Item 的width/height本身也在做动画,那么origin最好写成绑定表达式,例如origin.x: rect.width / 2,而不是写死一个数字。前面提到过,Transform 的原点是独立计算的,你不让它跟着尺寸走,缩放中心就会停在初始值上,表现出来就是"缩着缩着中心点移位了"。
2.4 Matrix4x4 与 GroupTransform:两个不常用但关键时刻救场的类型
Matrix4x4是最底层的方案,它直接让你指定一个完整的 4x4 变换矩阵,理论上一切变换都能用它表达。QML 里的写法是这样的:
transform: Matrix4x4 { matrix: Qt.matrix4x4( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 20, 0, 1 ) }这相当于把 X 方向平移 50、Y 方向平移 20。实际项目中我很少直接去写矩阵数值,因为可读性实在太差,但它有两个场景不可替代:一是你要做正交投影之外的透视效果,虽然 QML 默认没有透视,但你可以在矩阵里写入透视项来模拟;二是某些第三方渲染场景里需要替换 Item 空间到场景空间的整体映射,这时候矩阵化反而直观。
GroupTransform则是结构性的工具:它把多个 Transform 子元素包成一个组,作为transforms数组里的一个元素。如果你有一组变换经常整组复用,用GroupTransform一次性声明比在多个 Item 里重复粘贴数组要清爽得多。我通常拿它封装"偏移 + 旋转 + 缩放"的标准入场动效,在几个页面组件里反复调用同一套结构。
3. 动画与 Transform 的三种配合姿势,以及一个顺序陷阱
Transform 本身只是静态变换,真正的动态效果要靠动画驱动。QML 里让 Transform 动起来有三条路,各有适用场景,用错就会出现"动画不生效"或者"效果跟预期差一截"的问题。
3.1 写法一:把 Transform 实例当作动画 target
最直观的方式是给 Transform 起个 id,然后让NumberAnimation直接指向它的属性:
Rectangle { width: 160 height: 160 transform: Rotation { id: rotateTransform origin.x: 80 origin.y: 80 axis { x: 0; y: 1; z: 0 } } NumberAnimation { target: rotateTransform property: "angle" from: 0 to: 180 duration: 800 easing.type: Easing.InOutQuad } }这种写法适合"动画逻辑独立于变换声明"的场景,比如一个翻牌按钮,点击后在外部启动动画。好处是动画代码可以复用、可以暂停,还能用running/loops控制节奏。
3.2 写法二:Transform 内部内联动画或 Behavior
如果你希望变换属性一变化就有动画过渡,Behavior是最省事的:
transform: Rotation { id: flipRotation origin.x: rect.width / 2 origin.y: rect.height / 2 axis { x: 0; y: 1; z: 0 } Behavior on angle { NumberAnimation { duration: 400; easing.type: Easing.OutCubic } } }之后只要任何逻辑里修改flipRotation.angle,这个旋转就会自动以 400 毫秒的缓动动画过渡过去。注意Behavior只能对一个普通 QML 属性做拦截,像axis.x这种子属性它也答应,但你要是用Behavior on axis这种整体写法,通常会得不到预期效果,因为一个向量类型属性不足以平滑插值出每个轴的角度。常见做法是分别对angle做 Behavior,axis只做离散切换。
3.3 写法三:状态机 + Transition 驱动变换
这是最"QML 风格"、也是我强烈推荐的一种:用states定义变换终态,用Transition描述状态切换时哪些属性需要动画。这样所有逻辑都声明化,不需要手写动画启停。
states: State { name: "flipped" when: card.flipped PropertyChanges { target: flipRotation; angle: 180 } } transitions: Transition { NumberAnimation { target: flipRotation property: "angle" duration: 600 easing.type: Easing.InOutQuad } }点一下card.flipped = true,状态进入flipped,Transition 自动把角度从 0 补间到 180。这种写法最大的优势是在状态切换的时候,无论你从哪个中间阶段切走,QML 都会正确处理当前值到目标值的补间,不会出现动画互相打架。
3.4 transforms 数组的执行顺序:先写谁,谁先变
这一节单独拿出来讲,是因为顺序问题在多个 Transform 组合时最容易翻车。transforms数组按声明顺序应用,先声明的变换先生效,后声明的变换在它基础上继续生效。比如:
transform: [ Translate { offsetX: 50 }, Rotation { angle: 45; origin.x: 25; origin.y: 25 } ]结果是先平移 50 像素,再在已经被平移后的坐标系里做旋转。如果你把数组顺序反过来,视觉上就变成了"先旋转,再沿着旋转后的 X 方向平移",最终图形完全不同。
这个特性在做"围绕组件自身旋转、同时沿屏幕对角线移动"的动效时特别有用:你先旋转再平移,位移方向会跟着旋转一起偏;你先平移再旋转,旋转中心和位移方向就保持屏幕坐标系不变。写之前先想清楚哪一步该"先发生",否则效果经常会以"翻车"的形式提醒你顺序错了。
4. 实战:两分钟搭出一个 Y 轴卡片翻转效果
理论说了那么多,来一个能直接抄的实战。需求很简单:一张卡片,点击后绕 Y 轴转 180 度,正面显示"正面"内容,背面显示"背面"内容,来回切换。
4.1 正反面结构与旋转角度关系
核心思路是准备两个矩形叠放在同一个容器里。正面矩形的旋转角度从 0 变到 180,背面矩形的旋转角度从 -180 变到 0。为什么背面不是从 0 变到 180?因为背面一开始就应该"面朝里",也就是已经转了 -180 度,等它转到 0 度时,恰好正面对着用户。
判断哪一面可见,标准很简单:绕 Y 轴旋转时,角度在 0 到 90 之间,正面朝用户;角度超过 90 后,背面朝用户。所以两个面的visible直接绑定到角度上,90 度处做一次切换,正好无缝衔接。
4.2 可运行的完整 QML 示例
import QtQuick 2.15 import QtQuick.Controls 2.15 Item { id: card width: 320 height: 200 property bool flipped: false Rectangle { id: front anchors.fill: parent color: "#3b6ea5" radius: 10 Text { anchors.centerIn: parent text: "正面" color: "white" font.pixelSize: 20 } visible: frontRot.angle < 90 transform: Rotation { id: frontRot origin.x: card.width / 2 origin.y: card.height / 2 axis { x: 0; y: 1; z: 0 } angle: 0 } } Rectangle { id: back anchors.fill: parent color: "#d06b45" radius: 10 Text { anchors.centerIn: parent text: "背面" color: "white" font.pixelSize: 20 } visible: frontRot.angle >= 90 transform: Rotation { id: backRot origin.x: card.width / 2 origin.y: card.height / 2 axis { x: 0; y: 1; z: 0 } angle: -180 } } states: State { name: "flipped" when: card.flipped PropertyChanges { target: frontRot; angle: 180 } PropertyChanges { target: backRot; angle: 0 } } transitions: Transition { NumberAnimation { duration: 650 easing.type: Easing.InOutQuad } } MouseArea { anchors.fill: parent onClicked: card.flipped = !card.flipped } }把这段代码丢进任意 QML 运行环境,一个基础的卡片翻转效果就有了。关键点有三个:两个矩形的 transform 必须使用独立的Rotation;origin必须绑定到父容器宽度高度的一半,不能写死;visible的切换临界点放在 90 度。
我实测下来,Easing.InOutQuad在翻转效果里比线性动画舒服太多,因为 90 度切换的那一瞬正好是动画速度最快的中间阶段,视觉上几乎察觉不到"换面"的断点。如果用InBack这种带回弹的曲线,正面转过去的同时背面回弹,整体会有点像弹簧,某些 UI 风格下反而更活泼。
4.3 从单卡到列表:封装可复用的 FlipCard 组件思路
单张卡片跑通之后,复制成一堆卡片时千万别直接 Ctrl+C。把FlipCard.qml抽成一个带属性的组件,对外只暴露frontText、backText、flipped和flipDuration,内部的正反面内容和动画逻辑全部隐藏起来:
// FlipCard.qml Item { id: root width: 320 height: 200 property alias frontText: frontLabel.text property alias backText: backLabel.text property bool flipped: false property int flipDuration: 650 // ... 内部实现同上,只是把写死的文案换成 alias,duration 用属性值 }这样在列表里用时,每个 delegate 就是一行FlipCard { flipped: itemModel.checked }。需要说明的是,flipped最好不要直接做列表项的属性绑定,因为列表项回收(delegate 复用)时如果flipped状态没有缓存好,会出现卡片翻错面的奇怪表现。稳妥做法是把它存进模型数据里,每个 item 一个布尔值,翻牌后写回模型。
如果想要"风扇展开"的菜单效果,思路也是一样:每张卡片在原有翻转基础上叠加一个Rotation,角度按索引递增。由于多个 Rotation 会依次作用,外层的扇面旋转用GroupTransform包一层,再和卡片的翻面 Rotation 组合,效果就不乱了。
4.4 正交投影的遗憾:为什么没有透视效果
上面这个翻转效果,你会看到卡片在旋转到 45 度时宽度被"压扁"了,这是严格的等宽压缩,而不是近大远小的透视收缩。因为 Qt Quick 默认的正交投影没有深度缩放概念。想要更接近真实三维卡片翻转,有两个思路:
第一个思路是手动写一个带透视项的Matrix4x4,在旋转过程中把透视投影项代入矩阵,视觉上就会有"远端变窄"的效果。不过矩阵运算比较复杂,而且要和Rotation的插值协调,我之前尝试时发现调试成本不低,不推荐新手一上来就走这条路。
第二个思路是直接用 Qt Quick 3D 做卡片,里面自带透视相机和光照,翻转效果天然就有立体感。代价是引入的渲染概念变多,如果整个 UI 只有这一处三维需求,性价比并不高。
我自己的取舍是:卡片尺寸比较大、翻转时停留时间比较长时,正交投影的"压扁感"很明显,会想办法加透视;如果卡片小、翻转速度快,正交投影几乎看不出问题,直接用Rotation就行,不用过度设计。
5. 我踩过的 Transform 坑:origin 漂移、叠加旋转与渲染性能
章节写到这里,大部分是系统性梳理,这最后一节是我真正想保留的部分。有些问题不是看文档能发现的,它们往往藏在"看起来明明按文档写了,效果却不对劲"的日常里。
5.1 翻转中心漂移:把 origin 写死等于自废武功
第一次做翻牌效果时,我把origin.x写成了150,因为当时那张卡宽度固定是 300。后来需求改成宽度可变的卡片,我忘了去动这个数字,于是翻转中心一直在 x=150 处,而卡片宽度变成了 400。结果就是卡片绕着一个偏离几何中心的轴旋转,翻了一半时像被推了一把。
这件事的教训很朴素:Transform的 origin 不会跟随 Item 尺寸自动调整,必须在声明里写绑定表达式。上面示例里的origin.x: card.width / 2就是标准写法。如果你还要让卡片在翻转过程中同步改变尺寸,那就更得注意:绑定表达式会实时求值,但Behavior动画插值的其实是 angle 数值,width的变化是另一条动画链,两条链的节奏必须对齐,否则旋转到 90 度的瞬间,中心会因为宽度刚变完而出现一次小跳变。
5.2 内置属性与 Transform 同时使用:叠加的顺序和预期差很远
我一度在图省事的时候同时用了item.rotation = 45和Transform.Rotation { angle: 45 },心想反正都绕 Z 轴转,加一块不就是 90 度吗。结果画面比我预期奇怪得多,因为这两者不是简单相加,而是按固定先后顺序叠加,后者会在前者的基础上再做一次旋转,配合 origin 差异,最终角度看起来像"斜着又斜了一下"。
从此我给自己立了一条规矩:同一个 Item 上,简单平面旋转永远用内置rotation,三维旋转或者需要自定义中心才用Transform.Rotation,两者不要混用。如果真的要组合,必须清楚 Item 的自带属性变换发生在整个 Transform 链的固定位置,你只能在这个前提下判断最终视觉效果。
5.3 Behavior 的隐式动画:能用,但别让它替你决定一切
Behavior on angle很省心,但它有一个隐蔽的问题:Behavior会拦截所有针对该属性的赋值,包括你在状态切换、列表刷新、甚至临时复位逻辑里做的赋值。一旦某个操作只想要瞬间跳变,不想有过渡动画,Behavior 就会自作主张加一段补间,经常导致你以为"没生效",其实是被动画吞了。
这种情况下我一般用Behavior.enabled来控制。比如:
Behavior on angle { enabled: animEnabled NumberAnimation { duration: 300 } }在需要瞬间跳变的赋值前,先把animEnabled设成 false,再在下一次交互时恢复。这是很常见的一个坑,也是调试 QML 动画时最容易被忽略的开关。
5.4 性能与渲染:什么时候该退回"傻瓜模式"
最后一个坑是关于渲染性能的。Transform体系最终会反映到场景图的渲染节点上,多个 Transform 叠加、尤其是Matrix4x4,会迫使场景图走更复杂的变换路径。之前我在一个列表里给每行配了两个Rotation和一个Scale,滚动起来帧率明显下降,后来检查发现很多卡片的旋转角度其实一直为零。
优化思路很简单:动画结束后,如果某个 Transform 长期处于单位变换(angle 为 0、scale 为 1、offset 为 0),尽量从 transforms 数组里移除,或者干脆在静止状态退回内置属性。QML 的场景图裁剪对零角度变换有优化,但多个 Transform 节点的存在本身仍会增加开销。另外,大量卡片同时翻转时,可以做一个"一次只翻一张"的交互限制,既避免视觉上过于混乱,也照顾了渲染压力。
如果让我给刚接触 Recompose 模块的读者一个建议,那就是:先学会用内置属性把 80% 的普通动画跑通,再用 Transform 解决那 20% 真正需要"变换链"的场景。它不复杂,但每一步都讲逻辑,顺序、原点、轴这三个要素只要想清楚,配合动画几乎不会翻车。后面这个 FlipCard 组件我还会继续做扩展,比如加入翻牌时的阴影渐变、多卡片联动的层叠效果,等踩出新坑再来更新这一篇。