ArkUI 动画体系与手势交互

本文梳理 ArkUI 的动画与手势两套体系:属性动画与 animateTo 显式动画的差别、关键帧动画、transition 转场与 geometryTransition 共享元素、Tap/Pan/Pinch/Swipe 手势的组合与竞争规则、拖拽与响应区域设置,以及动画丢帧的排查方法。

引言

ArkUI 的动画能力分成三条相对独立的线:属性动画描述「某个属性值怎么变」,转场动画描述「组件怎么进出」,手势描述「用户怎么触发变化」。三条线经常被混在一起写,结果是动画抖动、手势不响应、或者同一个交互在两台设备上表现完全不同。

真正的难点在时序与优先级。一次点击可能同时触发属性动画、转场动画和状态更新,谁先谁后由框架决定;一次滑动可能同时被父容器的滚动和子组件的 Pan 手势捕获,谁赢由手势竞争规则决定。不理解这两套规则,就只能靠试,改一个参数崩另一个地方。

本文按「属性动画、转场动画、手势、性能」四块展开。状态管理与刷新机制是动画的基础,建议先读 ArkUI 声明式 UI 与状态管理 ;布局容器的用法见 ArkUI 布局系统与自定义组件 ,本篇不重复布局内容。

目录

  1. ArkUI 动画体系总览
  2. 属性动画与 animation 属性
  3. 显式动画 animateTo
  4. 关键帧动画 keyframeAnimateTo
  5. 转场动画 transition 与 TransitionEffect
  6. 共享元素转场 geometryTransition
  7. 手势体系与基础手势
  8. 手势组合 GestureGroup
  9. 手势竞争与优先级
  10. 拖拽与响应区域
  11. 动画性能与丢帧排查
  12. 权衡取舍
  13. 常见坑清单
  14. 小结

1. ArkUI 动画体系总览

先建立一张分类表,后面每一节都对应其中一行。

类别触发方式代表 API作用对象典型用途
属性动画状态变化自动触发animation组件属性尺寸、颜色、透明度渐变
显式动画代码主动调用animateTo闭包内的属性变化点击后的整体位移动画
关键帧动画代码主动调用keyframeAnimateTo闭包内的属性变化多段式路径动画
转场动画组件出现或消失transition组件整体弹窗、列表项进出
共享元素页面切换geometryTransition跨页面同一元素列表项放大到详情页

选择顺序很明确:能用属性动画解决就不要用显式动画,能用显式动画就不要自己算插值。框架提供的动画都跑在渲染线程上,自己用定时器算插值既耗电又容易丢帧。

需要区分两个概念:animation 是「声明式」的,它挂在组件上,只要该组件的属性值发生变化就自动补间;animateTo 是「命令式」的,它包住一段会修改状态的代码,只有这段代码引起的变化才有动画。前者的作用范围是本组件,后者可以跨多个组件。

2. 属性动画与 animation 属性

属性动画的写法是在组件上追加 animation,它必须放在会变化的属性之后。

@Entry
@Component
struct ToggleCard {
  @State expanded: boolean = false;

  build() {
    Column() {
      Text('展开内容')
        .fontSize(16)
    }
    .width(this.expanded ? 320 : 160)
    .height(this.expanded ? 240 : 80)
    .backgroundColor(this.expanded ? '#FF6B00' : '#CCCCCC')
    .animation({ duration: 300, curve: Curve.EaseInOut })
  }
}

animation 的位置是硬约束:它只对写在它之前的属性生效。如果把 animation 写在 width 之前,宽度变化就不会有动画,而颜色变化有——这类「一半属性有动画」的现象几乎都是位置写错导致的。

animation 的参数里最值得调的是 curve 与 delay:

参数含义常用取值
duration动画时长(毫秒)150 到 400,超过 500 会显得拖沓
curve缓动曲线Curve.EaseInOut、Curve.Friction、Curve.Spring
delay延迟开始列表项错峰出场时用
iterations播放次数默认 1,-1 表示无限循环
playMode播放方向Normal、Reverse、Alternate

Curve.Friction 适合「松手后减速」这类物理感场景,Curve.Spring 适合有回弹的交互。不要所有动画都用 Curve.Linear,匀速运动在人眼看来是机械的,而缓动曲线几乎不增加成本。

3. 显式动画 animateTo

animateTo 把一段代码包裹起来,这段代码引起的所有属性变化都会带上动画。

@Entry
@Component
struct ListPage {
  @State offsetX: number = 0;
  @State scale: number = 1;

  private playEnter(): void {
    this.getUIContext().animateTo({
      duration: 300,
      curve: Curve.EaseOut,
      onFinish: () => {
        console.info('animation finished');
      }
    }, () => {
      // 这个闭包里对状态变量的所有修改都会产生动画
      this.offsetX = 40;
      this.scale = 1.1;
    });
  }

  build() {
    Column() {
      Text('卡片')
        .translate({ x: this.offsetX })
        .scale({ x: this.scale, y: this.scale })
      Button('播放').onClick(() => this.playEnter())
    }
  }
}

两个关键点:其一,必须用 this.getUIContext().animateTo,全局的 animateTo 在 API 12 之后已不推荐,多实例场景下会作用到错误的 UI 实例上;其二,闭包里只能改状态变量,直接操作组件对象不会产生动画。

animateTo 的三个回调值得记住:onFinish 在动画正常结束时触发,onCancel(API 12 起)在被新动画打断时触发。动画被打断是常态而非异常——用户快速连点两次,第一次动画会被第二次打断,因此清理逻辑不能只写在 onFinish 里。

4. 关键帧动画 keyframeAnimateTo

需要在一条时间轴上定义多个阶段时用 keyframeAnimateTo,它把动画拆成若干关键帧,每帧有自己的时长与曲线。

private playShake(): void {
  this.getUIContext().keyframeAnimateTo({ delay: 0 }, [
    { duration: 80, curve: Curve.Sharp, event: () => { this.offsetX = -8; } },
    { duration: 160, curve: Curve.Sharp, event: () => { this.offsetX = 8; } },
    { duration: 80, curve: Curve.Sharp, event: () => { this.offsetX = 0; } }
  ]);
}

这段代码实现「输入错误时左右抖动」的反馈:向左 8vp、向右 16vp、回正,总时长 320ms。关键帧动画的价值在于「一条时间轴多个目标点」,用 animateTo 串三次会因为每次都是独立动画而出现停顿。

注意关键帧之间是相对独立的:每一帧只描述「在这段时间内属性从当前值变到目标值」,它不记录绝对时间点。因此帧的顺序与时长决定了整体节奏,而起始值取决于进入该帧时的属性值。

5. 转场动画 transition 与 TransitionEffect

transition 处理组件的出现与消失,它必须配合 if 条件渲染或 ForEach 的增删使用。

@Entry
@Component
struct DialogDemo {
  @State show: boolean = false;

  build() {
    Stack() {
      Button('切换').onClick(() => { this.show = !this.show; })

      if (this.show) {
        Column() {
          Text('这是一个弹窗').fontSize(18)
        }
        .width(240)
        .height(160)
        .backgroundColor(Color.White)
        .borderRadius(12)
        .transition(TransitionEffect.OPACITY
          .combine(TransitionEffect.scale({ x: 0.8, y: 0.8 }))
          .animation({ duration: 250, curve: Curve.EaseOut }))
      }
    }
    .width('100%')
    .height('100%')
  }
}

TransitionEffect 的三种构造方式是选型的关键:

构造方式语义适用场景
TransitionEffect.OPACITY透明度过渡通用淡入淡出
TransitionEffect.translate位移过渡抽屉、侧滑面板
TransitionEffect.scale缩放过渡弹窗、气泡
TransitionEffect.rotate旋转过渡图标状态切换
TransitionEffect.asymmetric进出使用不同效果进场缩放、出场淡出

asymmetric 是最实用的一个,因为「进场」与「出场」往往需要不同的时长与曲线:进场稍慢让人看清内容,出场要快避免拖沓。

.transition(TransitionEffect.asymmetric(
  TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 40 })),
  TransitionEffect.OPACITY.animation({ duration: 120 })
))

一个必须记住的限制:transition 只对组件自身的挂载与卸载生效。如果只是把组件 visibility 改成 Hidden,它仍然在组件树上,转场动画不会触发。这也是为什么「用 visibility 代替 if」的做法会让动画失效——这与 鸿蒙原生应用性能优化与调试 里强调的「用条件渲染而不是隐藏」是同一条建议,只是动机会有所不同。

6. 共享元素转场 geometryTransition

共享元素转场让同一个元素在两个页面之间平滑过渡,例如列表项点击后放大成详情页头图。

// 列表页
@Entry
@Component
struct ListPage {
  @State selectedId: string = '';

  build() {
    Column() {
      ForEach(['a', 'b', 'c'], (id: string) => {
        Image($r('app.media.cover'))
          .width(80)
          .height(80)
          .geometryTransition(`cover_${id}`)
          .onClick(() => {
            this.selectedId = id;
            this.getUIContext().getRouter().pushUrl({ url: 'pages/Detail' });
          })
      }, (id: string) => id)
    }
  }
}

两端的 geometryTransition 参数必须完全一致才能匹配上,而且两端都要处于已挂载状态。常见的失败场景是:列表页在跳转时立即销毁,导致框架找不到起点。解决方式是把跳转放在 onClick 的末尾,让框架先完成一次布局再执行页面切换。

共享元素对布局的约束比较严格:两个页面上该元素的父容器结构越接近,过渡越自然。如果一端是 Column 里的固定尺寸,另一端是 Stack 里的百分比尺寸,过渡过程中会出现明显的形变与跳动。稳妥做法是两端都用固定宽高比加 aspectRatio,把差异留给内容而不是尺寸。

7. 手势体系与基础手势

ArkUI 的手势通过 .gesture() 绑定,基础手势有六种。

手势识别条件关键参数典型用途
TapGesture点击count(连续点击次数)单击、双击
LongPressGesture长按duration(默认 500ms)长按菜单、拖拽前置
PanGesture拖动direction、distance侧滑、拖拽排序
PinchGesture双指捏合distance图片缩放
SwipeGesture快速滑动speed(默认 100vp/s)列表项快捷操作
RotationGesture双指旋转angle图片旋转
@Entry
@Component
struct GestureDemo {
  @State scale: number = 1;
  @State angle: number = 0;

  build() {
    Image($r('app.media.photo'))
      .width(240)
      .height(240)
      .scale({ x: this.scale, y: this.scale })
      .rotate({ angle: this.angle })
      .gesture(
        GestureGroup(GestureMode.Parallel,
          PinchGesture()
            .onActionUpdate((event: GestureEvent) => {
              this.scale = event.scale;
            }),
          RotationGesture()
            .onActionUpdate((event: GestureEvent) => {
              this.angle = event.angle;
            })
        )
      )
  }
}

onActionUpdate 与 onActionEnd 的配合是缩放类交互的关键:onActionUpdate 在手指移动过程中持续触发,onActionEnd 在手势结束时触发一次。在 onActionUpdate 里做重计算或发请求是常见错误,它每秒可能触发几十次,必须只做属性赋值。

PinchGesture 的 event.scale 是相对本次手势起点的比例,不是累计值。因此把 this.scale 直接赋值为 event.scale 会导致手势结束后缩放回弹到 1,正确做法是在手势开始时记录基准值,结束时把最终值固化。

8. 手势组合 GestureGroup

多个手势绑在同一组件上时,默认会互相竞争,用 GestureGroup 可以显式指定组合方式。

模式语义结果
Sequence按顺序依次识别前一个成功后才会识别下一个
Parallel同时识别多个手势可以同时生效
Exclusive互斥,只有一个胜出最先满足条件的胜出

Sequence 最典型的用法是「长按后拖拽」:先识别 LongPressGesture,成功后再识别 PanGesture,这样普通滑动不会触发拖拽。

.gesture(
  GestureGroup(GestureMode.Sequence,
    LongPressGesture({ repeat: false })
      .onAction(() => { this.dragging = true; }),
    PanGesture({ direction: PanDirection.All })
      .onActionUpdate((event: GestureEvent) => {
        this.offsetX += event.offsetX;
      })
      .onActionEnd(() => { this.dragging = false; })
  )
)

Exclusive 适合「单击与双击共存」的场景:双击的前半段会先触发单击,用 Exclusive 并配合 TapGesture({ count: 2 }) 可以避免单击误触发——框架会等待双击的判定窗口(默认 300ms)结束后再决定把事件给谁。这 300ms 的延迟是必要的代价,如果不需要双击,就不要声明它,否则单击响应会明显变慢。

9. 手势竞争与优先级

当子组件与父组件都绑定了手势时,默认的竞争规则是子组件优先。想改变这个行为要用 priorityGesture 与 parallelGesture。

绑定方式优先级父子的关系适用场景
gesture子组件优先子组件先响应,父组件被阻断常规交互
priorityGesture父组件优先父组件先响应,可阻断子组件容器级手势,如整页返回
parallelGesture同时响应父子都收到事件需要叠加效果的场景
Column() {
  // 子组件:普通点击
  Text('子内容').onClick(() => { console.info('child click'); })
}
// 父组件用 priorityGesture 抢占,子组件的点击不再触发
.priorityGesture(
  TapGesture().onAction(() => { console.info('parent wins'); })
)

一个高频踩坑场景是 Scroll 内的 PanGesture:默认子组件优先,PanGesture 会抢走滑动事件,导致列表无法滚动。解决方案有两种,一是给 PanGesture 设置 distance 阈值让它不那么容易触发,二是改用 parallelGesture 让两者共存,由业务逻辑判断该响应谁。

手势冲突没有万能解,只能按「谁的意图更明确谁优先」来判断。列表滚动的意图比单项拖动更常见,因此应该让滚动优先;而拖拽排序的意图更明确,此时用长按前置(Sequence)把冲突消解在触发条件上,比调整优先级更可靠。

10. 拖拽与响应区域

拖拽是手势的一个特殊分支,它有独立的 onDragStart / onDrop 体系,可以跨组件甚至跨应用传递数据。

@Entry
@Component
struct DragDemo {
  @State items: string[] = ['A', 'B', 'C'];

  build() {
    Row() {
      ForEach(this.items, (item: string) => {
        Text(item)
          .width(64)
          .height(64)
          .textAlign(TextAlign.Center)
          .backgroundColor('#EEEEEE')
          .draggable(true)
          .onDragStart(() => {
            // 返回值会被传给接收方的 onDrop
            return { extraInfo: item };
          })
          .onDrop((event: DragEvent, extraParams: string) => {
            console.info(`dropped: ${extraParams}`);
            event.setResult(DragResult.DROP_ENABLED);
          })
      }, (item: string) => item)
    }
  }
}

拖拽有两个容易忽略的配置:draggable(true) 必须显式开启,默认不可拖;event.setResult(DragResult.DROP_ENABLED) 决定是否接受本次放置,不调用它会导致放置后被弹回。

响应区域用 responseRegion 与 hitTestBehavior 控制,它们解决「点击热区太小」与「透明区域要不要响应」两类问题:

Text('删除')
  .width(48)
  .height(48)
  // 视觉上 48x48,实际热区扩大到 60x60
  .responseRegion({ x: -6, y: -6, width: 60, height: 60 })

responseRegion 的坐标是相对组件左上角的,负值表示向外扩展。小图标按钮必须设置它,因为 48vp 是视觉可接受的最小尺寸,但触摸友好尺寸要求至少 44vp 以上,边缘图标更需要额外外扩。这与 前端可访问性与国际化 里讲的触达区域原则是一致的。

hitTestBehavior 有三个取值:Default(默认,正常参与命中测试)、Block(自身响应且阻断下层)、Transparent(自身响应且允许穿透到下层)。做叠加层时用 Transparent 能让上层透明区域的点击落到下层,这在自定义遮罩场景里很实用。

11. 动画性能与丢帧排查

动画丢帧的根因只有两类:渲染线程负担过重,或者主线程被长任务阻塞。

现象可能原因排查方向
动画整体卡顿组件层级过深、过度绘制用 Profiler 的帧率泳道看每帧耗时
动画开始时卡一下首帧构建成本高检查动画组件是否首次创建
动画中段掉帧主线程有长任务看 CPU 泳道的主线程占用
手势跟手性差onActionUpdate 里做重活只保留属性赋值

三条优化手段的收益最明显:

  • 优先动画化 transform 类属性(translate、scale、rotate、opacity),它们可以只触发绘制而不用重新布局;动画化 width、height、margin 会触发重新测量与布局,成本高一个量级。
  • 用 renderGroup 把子树合成到一张纹理,适合整体位移的场景,代价是子树内部的变化不再独立刷新。
  • 避免在 onActionUpdate 里修改会引起其他组件刷新的状态,一个手势更新触发整页重排是最常见的性能陷阱。

排查流程与 鸿蒙原生应用性能优化与调试 里讲的一致:先用帧率泳道定位丢帧时间点,再切到 CPU 泳道看那一刻主线程在跑什么。不要凭感觉调 duration,把 300ms 改成 200ms 只会让卡顿看起来更快,不会减少丢帧。

权衡取舍

动画与手势的取舍集中在「表现力」与「成本」之间。

决策点方案 A方案 B建议
属性动画 vs 显式动画animation(声明式)animateTo(命令式)单组件用 A,跨组件用 B
转场 vs 手动插值transition定时器算插值一律用 A,手写插值必然丢帧
手势冲突调优先级改触发条件优先 B,用长按前置消解冲突
单击 + 双击共存都声明,用 Exclusive只声明单击无双击需求就别声明,省 300ms 延迟
动画时长短(150 到 200ms)长(400ms 以上)反馈类用 A,引导类可用 B

一条容易被忽略的原则:动画应该服务于「状态变化的可理解性」,而不是装饰。一个 300ms 的位移如果能让用户看清「这个元素从哪来、到哪去」,它就值得;如果只是让页面看起来活泼,那它在低端设备上换来的只会是丢帧。同理,手势的阈值也不宜过小——过小的阈值会让用户误触,这在单手操作与运动中使用的场景下尤其明显。

常见坑清单

  1. animation 写在属性之前。 只有部分属性有动画,表现为「一半动一半不动」。
  2. 用全局 animateTo。 多 UI 实例下作用到错误的实例,API 12 起应改用 this.getUIContext().animateTo。
  3. 只处理 onFinish 不处理 onCancel。 动画被打断时清理逻辑不执行,状态残留。
  4. 用 visibility 代替 if 做显隐。 组件仍在树上,transition 转场不会触发。
  5. geometryTransition 两端参数不一致。 共享元素过渡失效,退化成普通页面切换。
  6. PinchGesture 的 scale 直接赋值给状态。 它是相对起点的比例,手势结束后缩放回弹到 1。
  7. 在 onActionUpdate 里做重计算或发请求。 每秒触发几十次,直接造成掉帧。
  8. Scroll 内的 PanGesture 抢走滑动。 默认子组件优先,需调 distance 阈值或改 parallelGesture。
  9. draggable 未显式开启。 拖拽完全不生效,且没有任何提示。
  10. onDrop 未调用 setResult。 放置后元素被弹回原位。
  11. 小图标按钮未设 responseRegion。 热区过小,边缘点击经常落空。
  12. 动画化 width 与 margin。 触发重新布局,成本远高于 transform 类属性。

小结

ArkUI 的动画与手势是两套规则体系,分开理解就清楚了。动画侧记住三条:属性动画管单组件、显式动画管跨组件、转场动画管挂载与卸载,三者不要混用;手势侧记住三条:默认子组件优先、组合模式决定并发关系、冲突优先靠调整触发条件而不是调优先级。性能侧只有一条底线:只动画化 transform 与 opacity 这类合成属性,手势回调里只做属性赋值。

最后一条建议:动画与手势是用户感知最强的部分,也是最容易在低端设备上暴露问题的部分。开发期就在目标低端机上验证一遍,比在旗舰机上把动画调到丝滑再上线要稳妥得多。想继续深入渲染与内存侧的排查方法,可以回看 鸿蒙原生应用性能优化与调试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. 鸿蒙应用安全:权限模型与 HUKS 密钥管理
  3. 鸿蒙分布式软总线与跨设备迁移