这是一个 HarmonyOS 中心弹出菜单示例。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

页面上只有一颗中心按钮、一圈会飞出去的图标、底部一条数量滑条。 ArkUI 做动效时反复出现的三件事:

  1. 动画跟的是状态,不是时间轴。 你改一个布尔值或一个坐标,框架把「改之前」和「改之后」补成过渡。
  2. 显式动画和属性动画是两套入口。 闭包里改状态用 animateTo;组件上绑着的属性自己变,用 .animation()
  3. 叠在一起时,谁驱动哪几个属性、时长是否一致,决定观感是一套动作还是两套各走各的。

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 })

mainFlagfalse 变成 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() 写在后面,就过渡身上那些会变的通用属性(translaterotatescaleopacity、宽高等)。
  • 点击里直接改 this.item.clicked,不用再包 animateTo。因为这些属性已经挂了 .animation(),赋值本身就会播过渡。
  • 一种意图一层节点。位移一层,点击反馈一层,以后改飞出时长不会把翻转也拖慢或拖快。

6. 两套动画怎么叠成「一圈旋出」

点中心按钮时,时间线上其实同时发生两件事:

t = 0
  animateTo 闭包:mainFlag = true
       │
       ├─ 显式动画:父 Stack 在 1000ms 内转到 360°
       ├─ 显式动画:中心图缩到 0.75 并换图
       └─ 属性动画:每个图标的 translate 在 1000ms 内从 (0,0) 走到 point

mainFlag 是两套管线的接头:显式动画负责改它,属性动画负责读它。

要让「转圈」和「飞出」像同一个动作,两套的 durationdelaycurve 应对齐。 本项目里都是 delay: 10duration: 1000Curve.Smooth。如果显式动画 500ms 转完,属性动画还要再飞 500ms,眼睛会觉得图标转完了还在往外飘,像两段没接上的镜头。

动手时可以故意把一边改成 300、另一边保持 1000,跑一遍,观感差异会非常明显。这是叠动画时最常踩的坑,也是这个示例最值得记住的经验。


7. 飞向哪里:先算终点,再让 translate 去追

动画只负责「怎么走」,不负责「走到哪」。圆周坐标在 viewmodel/IconsModel.ets

x = radius × cos(2π × index / n)
y = radius × sin(2π × index / n)

radius145(vp),n 是当前图标个数(3~6)。三个点是正三角,六个点是正六边。index 从 0 开始沿圆周均分。

addImage 按目标个数增删数组,再 refreshPoint 给每一个 IconItem 换一个新的 Point。外层 .translate 绑的是 item.point,坐标一换,属性动画就会把图标滑到新位置。

Point 是普通 {x, y}。更新时是 换成新的 Point 对象,不是改旧对象的 xy。属性动画看到的是「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 个数变了 → addImagepoint 变 → 位移属性动画
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),叠在中心按钮底下。

  1. 手指点中心图。
  2. reset():若上次有图标还处在 clicked,先把它们的 rotate/scale/opacity 用内层属性动画收回去。
  3. animateTo 闭包执行,mainFlag 变为 true
  4. 同一时刻:
    • 包着四个图标的父 Stack 开始绕 Z 轴转到 360°(显式动画)。
    • 中心图开始缩到 0.75,并换成激活态资源(显式动画)。
    • 四个外层 Stacktranslate 分别奔向四个 point(属性动画)。
  5. 约 1 秒后(再乘上 tempo 的观感),图标落在圆周上,中心按钮停在缩小态。
  6. 此时再点其中一颗:只有那颗内层 Image 的 clicked 翻转,绕 Y 轴转一圈并放大变淡;其它图标和父容器都不动。
  7. 再点中心按钮:reset() + mainFlag 回到 false,整套过程反向播一遍,图标飞回圆心。

读代码时可以在 animate()IconTapEffect.onClick 各打一个日志,看两次点击各自改了哪个状态。能分清「改 mainFlag」和「改 clicked」,这个示例就算读懂了。


10. 动画参数怎么记、怎么改着玩

常量集中在 common/constants/Const.ets。和手感直接相关的是这些:

参数 本项目取值 改它时你会看到
duration 1000 整段变快或变慢。显式和位移两边一起改,才像同一个动作
delay 10 起势略顿一下,几乎无感;调大以后会明显「晚一拍再飞」
curve Curve.Smooth 进出更圆滑。可换成 EaseInEaseOutFriction 感受加速方式
tempo 0.68(仅 animateTo 只放慢显式那条(旋转和中心缩放),位移仍按 1000ms。用来体会「两套管线不同步」
playMode Normal 可试 ReverseAlternate,看往返怎么播
OFFSET_RADIUS 145 圆周更大或更小,飞出距离变了,动画时长不变,速度观感会变
INIT_SCALE 0.75 中心按钮按下有多「瘪」
SCALE_RATIO 1.25 单击图标放大多少
ROTATE_ANGLE_360 360 改成 180 立刻能看出绕的是哪根轴

建议的实验顺序:

  1. 只改 AnimationWidgetsanimateToduration300,飞出仍是 1000。看旋转和飞出脱节。
  2. 改回来,只把外层 .animation()duration 改成 300。看图标「瞬移到位」而背景还在转。
  3. 两边都改回 1000,把内层点击的 duration 改成 300。展开照旧,单击反馈变脆——这就是拆层的好处。
  4. 把内层 rotate 的轴改成 { x:0, y:0, z:1 },单击会变成平面内自转,和菜单整体的公转对比一下。
  5. OFFSET_RADIUS 改成 80200,体会「终点变了,同一段 duration 下快慢观感不同」。

做完这五步,对「状态、属性、时长、轴」四件事会比只看文档扎实得多。


11. 总结

先写状态机,再挂动画。 这个菜单的状态其实只有:开/关(mainFlag)、点过没点过(clicked)、落在哪(point)、有几个(quantity)。动画全部是这些值变化时的插值。

一次交互只选一种入口去「启动」。 中心按钮用 animateTomainFlag;单击图标直接改 clicked.animation()。不要两个入口抢同一批属性。

叠动画时对时长。 同一拍里要一起结束的,durationdelaycurve 写成一样的。需要对不齐时再故意拆开,并且你自己知道在错开什么。

不同意图拆到不同节点。 .animation() 的作用域是它所在的组件。位移和点击反馈不是同一种意图,就不要写在同一个 Image 上。

会改布局终点的数据,等手势结束再写。 Slider 拖动只更新数字,松手再改 quantity,位移动画才不会在拖的过程中被连续打断。

几何和动画解耦。 圆周公式在 Model 里算完,View 只 translatepoint。换排布方式时,动画代码可以不动。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐