HarmonyOS | ArkUI 动画
这是一个 HarmonyOS 中心弹出菜单示例。


页面上只有一颗中心按钮、一圈会飞出去的图标、底部一条数量滑条。 ArkUI 做动效时反复出现的三件事:
- 动画跟的是状态,不是时间轴。 你改一个布尔值或一个坐标,框架把「改之前」和「改之后」补成过渡。
- 显式动画和属性动画是两套入口。 闭包里改状态用
animateTo;组件上绑着的属性自己变,用.animation()。 - 叠在一起时,谁驱动哪几个属性、时长是否一致,决定观感是一套动作还是两套各走各的。
1. 先把成品拆成三种动作
运行后你会看到三种交互,正好对应三条动画链路:
| 你做的事 | 屏幕上发生什么 | 走哪条链路 |
|---|---|---|
| 点中心按钮 | 图标从圆心飞向圆周,整圈同时绕中心转一圈;中心图切换并略微缩小 | 显式动画 animateTo 翻转 mainFlag,带动父容器旋转、中心图缩放;子图标的位移由属性动画跟着 mainFlag 走 |
| 再点一次中心按钮 | 图标飞回圆心,旋转收回 | 同一套,只是 mainFlag 翻回去 |
| 点某个已飞出的图标 | 这个图标绕 Y 轴翻转、放大、变半透明 | 只改这一个 item.clicked,走该图标内层的属性动画 |
| 拖底部滑条 | 数字跟着变;松手后图标个数变成 3~6 | 数量变化重算圆周坐标;位移属性动画会把图标送到新位置 |
2. 代码放在哪

entry/src/main/ets
├── pages/Index.ets 页面根,把数量和图标模型交给子组件
├── view/AnimationWidgets.ets 中心按钮 + 显式动画
├── view/IconAnimation.ets 单图标:外层位移、内层点击
├── view/CountController.ets 滑条改数量
├── viewmodel/IconsModel.ets 算圆周上每个图标该落在哪
├── viewmodel/IconItem.ets 一张图、是否点过、落点坐标
└── common/constants/Const.ets duration、半径、缩放比等参数
读动画时按这个顺序:Index 看状态从哪来 → AnimationWidgets 看点击中心按钮 → IconAnimation 看飞出和单击 → IconsModel 看飞向哪里。
3. 核心观念:先改状态,再让框架补帧
ArkUI 里常见的两种写法,本质都是「声明目标值」:
// 显式:把「会让界面变样」的赋值包进闭包
this.getUIContext().animateTo({ duration: 1000, curve: Curve.Smooth }, () => {
this.mainFlag = !this.mainFlag;
});
// 属性:组件上已经写好了目标属性,再挂一条 .animation()
.translate(this.mainFlag ? { x: point.x, y: point.y } : { x: 0, y: 0 })
.animation({ duration: 1000, curve: Curve.Smooth })
mainFlag 从 false 变成 true 的那一瞬间:
- 父
Stack的旋转角从0变成360 - 中心图缩放从
1变成0.75,资源从imgInit换成imgActive - 每个图标的位移从
(0, 0)变成圆周上的point
框架在 duration 时间内按曲线插值。你不会去写第 16 帧图标在哪,只负责「展开时应该在圆周上」。
记住一句就够:能动画的属性变了,并且这条变化落在某次 animateTo 闭包里,或落在某条 .animation() 覆盖的组件上,才会有过渡;否则就是瞬切。
4. 显式动画:一次闭包,带动一组属性
文件:view/AnimationWidgets.ets。
中心按钮的点击处理很短:
.onClick(() => {
this.iconModel.reset();
this.animate();
})
reset() 先把各图标的 clicked 清掉,避免收回菜单时还有图标停在「点过」的放大半透明态。真正开启动画的是 animate():
animate() {
this.getUIContext().animateTo(
{
delay: Common.DELAY_10,
tempo: Common.TEMPO, // 0.68,播放速度小于 1,视觉上更从容
iterations: 1,
duration: Common.DURATION_1000,
curve: Curve.Smooth,
playMode: PlayMode.Normal
},
() => {
this.mainFlag = !this.mainFlag;
}
);
}
闭包里只改了一个布尔值,但 build() 里有三处绑定了 mainFlag:
.rotate({
x: 0, y: 0, z: 1,
angle: this.mainFlag ? 360 : 0 // 整圈图标绕 Z 轴转一圈
})
Image(this.mainFlag ? $r('app.media.imgActive') : $r('app.media.imgInit'))
.scale({
x: this.mainFlag ? 0.75 : 1,
y: this.mainFlag ? 0.75 : 1
})
这就是显式动画的用法:把「这一拍里该一起变」的状态,写进同一次 animateTo。 父容器旋转和中心按钮缩放会按同一套 duration / curve / tempo 走。
实战里注意三点:
用 this.getUIContext().animateTo,不要用全局 animateTo。 全局接口已弃用,UI 上下文上的版本才跟当前窗口绑定。
rotate 的轴要写对。 { x:0, y:0, z:1, angle:360 } 是屏幕平面内转圈(像钟表指针),适合「菜单整体旋出」。单图标点击用的是 { x:0, y:1, z:0 },绕竖轴翻面,像卡片翻转。同一个 rotate API,轴不同,故事就不同。
tempo 是播放速率,不是第二套 duration。 tempo: 0.68 表示按 0.68 倍速播这段 1000ms 的动画,墙钟时间会变长。调手感时可以先动 duration,再微调 tempo。
5. 属性动画:属性自己变,组件自己过渡
文件:view/IconAnimation.ets。
每个小图标要做两件独立的事:
- 菜单展开/收回时,整颗图标搬家(
translate) - 用户点它时,这张图自己翻转、放大、变透明(
rotate/scale/opacity)
如果把这些属性写在同一个 Image 上,再挂一条 .animation(),那么无论是飞出还是点击,都会走同一套参数。展开只要位移,点击只要翻转,两者抢一条动画配置,后改的属性也会把先改的「带进去」。
这个项目的做法是拆成两层组件,每层只动画自己的属性:
外层 Stack(IconAnimation)
只负责 translate
.animation({ duration: 1000, curve: Smooth }) ← 飞出 / 收回
内层 Image(IconTapEffect)
只负责 rotate、scale、opacity
.animation({ duration: 1000, curve: Smooth }) ← 单击反馈
外层:
Stack() {
IconTapEffect({ item: this.item })
}
.width(58)
.height(58)
.translate(this.mainFlag ? { x: this.item.point.x, y: this.item.point.y } : { x: 0, y: 0 })
.animation({
delay: 10,
duration: 1000,
curve: Curve.Smooth,
playMode: PlayMode.Normal
})
内层:
Image(this.item.image)
.rotate({ x: 0, y: 1, z: 0, angle: this.item.clicked ? 360 : 0 })
.scale(this.item.clicked ? { x: 1.25, y: 1.25 } : { x: 1, y: 1 })
.opacity(this.item.clicked ? 0.6 : 1)
.onClick(() => { this.item.clicked = !this.item.clicked; })
.animation({ duration: 1000, curve: Curve.Smooth, ... })
外层必须是真正的容器(这里是 Stack),不能把 .translate() 打在「根节点就是那张 Image」的自定义组件上——那样位移和点击又回到同一个节点,拆层等于没拆。
属性动画的使用口诀:
.animation()写在谁后面,就过渡谁身上那些会变的通用属性(translate、rotate、scale、opacity、宽高等)。- 点击里直接改
this.item.clicked,不用再包animateTo。因为这些属性已经挂了.animation(),赋值本身就会播过渡。 - 一种意图一层节点。位移一层,点击反馈一层,以后改飞出时长不会把翻转也拖慢或拖快。
6. 两套动画怎么叠成「一圈旋出」
点中心按钮时,时间线上其实同时发生两件事:
t = 0
animateTo 闭包:mainFlag = true
│
├─ 显式动画:父 Stack 在 1000ms 内转到 360°
├─ 显式动画:中心图缩到 0.75 并换图
└─ 属性动画:每个图标的 translate 在 1000ms 内从 (0,0) 走到 point
mainFlag 是两套管线的接头:显式动画负责改它,属性动画负责读它。
要让「转圈」和「飞出」像同一个动作,两套的 duration、delay、curve 应对齐。 本项目里都是 delay: 10、duration: 1000、Curve.Smooth。如果显式动画 500ms 转完,属性动画还要再飞 500ms,眼睛会觉得图标转完了还在往外飘,像两段没接上的镜头。
动手时可以故意把一边改成 300、另一边保持 1000,跑一遍,观感差异会非常明显。这是叠动画时最常踩的坑,也是这个示例最值得记住的经验。
7. 飞向哪里:先算终点,再让 translate 去追
动画只负责「怎么走」,不负责「走到哪」。圆周坐标在 viewmodel/IconsModel.ets:
x = radius × cos(2π × index / n)
y = radius × sin(2π × index / n)
radius 是 145(vp),n 是当前图标个数(3~6)。三个点是正三角,六个点是正六边。index 从 0 开始沿圆周均分。
addImage 按目标个数增删数组,再 refreshPoint 给每一个 IconItem 换一个新的 Point。外层 .translate 绑的是 item.point,坐标一换,属性动画就会把图标滑到新位置。
Point 是普通 {x, y}。更新时是 换成新的 Point 对象,不是改旧对象的 x、y。属性动画看到的是「translate 的目标值变了」,于是补一段位移。
把几何和动画分开,是这个项目结构上最清晰的一点:ViewModel 只输出坐标,View 只声明「展开时 translate 到这个坐标」。换半径、换数量,都不必去改 .animation() 参数。
8. 状态怎么接到动画上
页面根 pages/Index.ets 只做两件事:记下数量,提供图标模型。
@State quantity: number = 3;
@Provide iconModel: IconsModel = new IconsModel(this.quantity, 145);
往下传的关系:
| 状态 | 谁持有 | 谁用来播动画 |
|---|---|---|
quantity |
Index @State,滑条 @Link |
个数变了 → addImage → point 变 → 位移属性动画 |
iconModel |
Index @Provide |
舞台 @Consume,中心按钮里 reset() |
mainFlag |
舞台 @State |
显式动画改它;每个图标 @Link 读它做 translate |
item |
@Observed 的 IconItem |
图标 @ObjectLink,点它只刷新自己 |
点某个图标时只翻 item.clicked,因为用了 @ObjectLink,其它图标不会重绘。这和「点击反馈是单图标属性动画」是配套的:状态粒度有多细,动画就能有多局部。
滑条把「展示」和「提交」拆开,也是为了保护动画:
.onChange((value: number, mode: SliderChangeMode) => {
this.displayQuantity = value; // 拖动中只改屏幕上的数字
if (mode === SliderChangeMode.End || mode === SliderChangeMode.Click) {
this.quantity = value; // 松手或点轨道才改真实个数
}
})
quantity 一变就会 refreshPoint,所有图标的 translate 目标一起变。如果拖过 3、4、5 每一步都提交,展开态下圆周会连续重排,图标会跟着跳。会触发位移动画的数据,放在手势结束时再提交。 点轨道走的是 Click 不是 End,所以两种都要写,点选才会生效。
9. 把一次「展开」从头走到尾
假设当前 4 个图标,菜单是收起的,mainFlag === false,所有 translate 都在 (0,0),叠在中心按钮底下。
- 手指点中心图。
reset():若上次有图标还处在 clicked,先把它们的 rotate/scale/opacity 用内层属性动画收回去。animateTo闭包执行,mainFlag变为true。- 同一时刻:
- 包着四个图标的父
Stack开始绕 Z 轴转到 360°(显式动画)。 - 中心图开始缩到 0.75,并换成激活态资源(显式动画)。
- 四个外层
Stack的translate分别奔向四个point(属性动画)。
- 包着四个图标的父
- 约 1 秒后(再乘上
tempo的观感),图标落在圆周上,中心按钮停在缩小态。 - 此时再点其中一颗:只有那颗内层 Image 的
clicked翻转,绕 Y 轴转一圈并放大变淡;其它图标和父容器都不动。 - 再点中心按钮:
reset()+mainFlag回到false,整套过程反向播一遍,图标飞回圆心。
读代码时可以在 animate() 和 IconTapEffect.onClick 各打一个日志,看两次点击各自改了哪个状态。能分清「改 mainFlag」和「改 clicked」,这个示例就算读懂了。
10. 动画参数怎么记、怎么改着玩
常量集中在 common/constants/Const.ets。和手感直接相关的是这些:
| 参数 | 本项目取值 | 改它时你会看到 |
|---|---|---|
duration |
1000 | 整段变快或变慢。显式和位移两边一起改,才像同一个动作 |
delay |
10 | 起势略顿一下,几乎无感;调大以后会明显「晚一拍再飞」 |
curve |
Curve.Smooth |
进出更圆滑。可换成 EaseIn、EaseOut、Friction 感受加速方式 |
tempo |
0.68(仅 animateTo) |
只放慢显式那条(旋转和中心缩放),位移仍按 1000ms。用来体会「两套管线不同步」 |
playMode |
Normal |
可试 Reverse、Alternate,看往返怎么播 |
OFFSET_RADIUS |
145 | 圆周更大或更小,飞出距离变了,动画时长不变,速度观感会变 |
INIT_SCALE |
0.75 | 中心按钮按下有多「瘪」 |
SCALE_RATIO |
1.25 | 单击图标放大多少 |
ROTATE_ANGLE_360 |
360 | 改成 180 立刻能看出绕的是哪根轴 |
建议的实验顺序:
- 只改
AnimationWidgets里animateTo的duration为300,飞出仍是 1000。看旋转和飞出脱节。 - 改回来,只把外层
.animation()的duration改成300。看图标「瞬移到位」而背景还在转。 - 两边都改回 1000,把内层点击的
duration改成300。展开照旧,单击反馈变脆——这就是拆层的好处。 - 把内层
rotate的轴改成{ x:0, y:0, z:1 },单击会变成平面内自转,和菜单整体的公转对比一下。 - 把
OFFSET_RADIUS改成80或200,体会「终点变了,同一段 duration 下快慢观感不同」。
做完这五步,对「状态、属性、时长、轴」四件事会比只看文档扎实得多。
11. 总结
先写状态机,再挂动画。 这个菜单的状态其实只有:开/关(mainFlag)、点过没点过(clicked)、落在哪(point)、有几个(quantity)。动画全部是这些值变化时的插值。
一次交互只选一种入口去「启动」。 中心按钮用 animateTo 改 mainFlag;单击图标直接改 clicked 靠 .animation()。不要两个入口抢同一批属性。
叠动画时对时长。 同一拍里要一起结束的,duration、delay、curve 写成一样的。需要对不齐时再故意拆开,并且你自己知道在错开什么。
不同意图拆到不同节点。 .animation() 的作用域是它所在的组件。位移和点击反馈不是同一种意图,就不要写在同一个 Image 上。
会改布局终点的数据,等手势结束再写。 Slider 拖动只更新数字,松手再改 quantity,位移动画才不会在拖的过程中被连续打断。
几何和动画解耦。 圆周公式在 Model 里算完,View 只 translate 到 point。换排布方式时,动画代码可以不动。
更多推荐


所有评论(0)