引言

想象一个场景:用户在列表中添加了一条新数据,这条数据"啪"地一下直接出现——没有过渡,没有动画,就像网页上突然多了一个 DOM 节点。对于开发者来说,这是最高效的实现;但对于用户来说,这很突兀。人眼天然会被运动所吸引,一个平滑的进场动画能让用户直观地感受到"有东西新增了",而不需要额外的提示。

HarmonyOS ArkUI 提供了 TransitionEffect API 来解这个问题。它让你能够为组件的"出现"和"消失"分别定义动画效果——淡入淡出、滑动移入、缩放弹入、旋转进入,甚至进场和退场使用完全不同的动画。这些效果通过声明式 API 调用即可实现,不需要手动管理动画状态。

本文将构建一个"转场动画实验室",深入讲解 TransitionEffect 的核心用法:内置效果(OPACITY、SLIDE、SLIDE_SWITCH)、自定义效果(move、scale、rotate、translate)、以及 combine 组合和 asymmetric 非对称动画。

读完本文你将能够:

  • 使用 TransitionEffect.OPACITY / SLIDE / SLIDE_SWITCH 等内置转场效果
  • 使用 TransitionEffect.move() / scale() / rotate() 构建自定义转场
  • 使用 .combine() 将多个效果组合为一个复合动画
  • 使用 .asymmetric() 为进场和退场定义不同的动画效果
  • 理解 .transition()animateTo 的区别和使用场景

TransitionEffect 概述

什么是 TransitionEffect

TransitionEffect 是 ArkUI 中专用于组件挂载/卸载动画的 API。它与 animateTo 有一个本质区别:

  • animateTo 操作的是已存在组件的属性变化——比如一个 Text 从左边移到右边,Text 本身始终在组件树中。
  • TransitionEffect 操作的是组件出现/消失的过程——当组件通过条件渲染(if)或列表变化(ForEach)进入或离开组件树时触发。

换句话说,animateTo 回答"组件从状态 A 到状态 B 怎么过渡",而 TransitionEffect 回答"组件从不存在到存在(或从存在到不存在)怎么过渡"。

触发条件

TransitionEffect 在以下场景触发:

  1. 条件渲染if (condition) { Component().transition(effect) } —— condition 从 false 变为 true 时播放进场动画,从 true 变为 false 时播放退场动画。
  2. ForEach 增删:列表数据变化导致子项新增或移除时,该子项的 transition 自动触发。
  3. Navigation 页面转场:通过 pageTransition 定义页面级别的进出动画(与组件级 transition 是不同 API)。

API 形态

TransitionEffect 是一个不可变类,所有方法返回新的 TransitionEffect 实例,支持链式调用:

// 基础形式
.transition(TransitionEffect.OPACITY)

// 自定义动画参数
.transition(TransitionEffect.OPACITY.animation({ duration: 400, curve: Curve.EaseOut }))

// 组合多个效果
.transition(
  TransitionEffect.scale({ x: 0, y: 0 })
    .combine(TransitionEffect.OPACITY)
    .animation({ duration: 450, curve: Curve.EaseOut })
)

// 进场和退场使用不同效果
.transition(TransitionEffect.asymmetric(
  // 进场效果
  TransitionEffect.move(TransitionEdge.START).combine(TransitionEffect.OPACITY)
    .animation({ duration: 500, curve: Curve.EaseOut }),
  // 退场效果
  TransitionEffect.scale({ x: 0.5, y: 0.5 }).combine(TransitionEffect.OPACITY)
    .animation({ duration: 400, curve: Curve.EaseIn })
))

核心 API 逐项解析

内置转场效果

ArkUI 提供了三种开箱即用的转场效果,它们的效果是预定义的,你只需要调用即可:

TransitionEffect.OPACITY

透明度转场。进场时组件从完全透明(opacity=0)渐变到完全不透明(opacity=1),退场时反向操作。

.transition(TransitionEffect.OPACITY.animation({ duration: 400, curve: Curve.EaseOut }))

这是最常用的转场效果,也是性能开销最小的——它只操作 opacity 属性,不涉及任何布局或变换计算。适合列表项的增删、通知的弹出等场景。

TransitionEffect.SLIDE

滑动转场。进场时组件从左侧滑入到正常位置,退场时从正常位置向右滑出。

.transition(TransitionEffect.SLIDE.animation({ duration: 400, curve: Curve.EaseOut }))

SLIDE 的滑动方向是固定的(左→右进场,右→左退场),适合水平方向的列表操作。

TransitionEffect.SLIDE_SWITCH

滑动切换转场。与 SLIDE 类似,但额外叠加了一个轻微的缩放效果——新元素滑入时带有从 0.8→1.0 的缩放,被替换的元素滑出时带有从 1.0→0.8 的缩放。默认时长为 600ms。

.transition(TransitionEffect.SLIDE_SWITCH.animation({ duration: 500, curve: Curve.EaseOut }))

这个效果适合 Tab 切换、内容替换等场景,比纯 SLIDE 多了一层视觉层次感。

自定义转场效果

当内置效果不满足需求时,可以使用静态方法构建自定义转场:

TransitionEffect.move(edge: TransitionEdge)

从指定边缘移入/移出。TransitionEdge 有四个值:TOPBOTTOMSTARTEND。其中 STARTEND 会根据布局方向自动适配(LTR 语言中 START=LEFT,RTL 语言中 START=RIGHT)。

// 从顶部移入
.transition(TransitionEffect.move(TransitionEdge.TOP)
  .animation({ duration: 400, curve: Curve.EaseOut }))

// 从底部移入
.transition(TransitionEffect.move(TransitionEdge.BOTTOM)
  .animation({ duration: 400, curve: Curve.EaseOut }))

进场时组件从 edge 指定的方向移入到正常位置,退场时组件从正常位置向 edge 方向移出。注意:move 的效果取决于 edge 参数,与组件自身的位置无关——即使组件在屏幕底部,move(TOP) 仍然会让它从顶部外移入。

TransitionEffect.scale(options)

缩放转场。参数 { x?: number, y?: number } 指定缩放的中心点(相对于组件自身的锚点),取值范围 0~1。

// 从 0 放大到 1(弹出效果)
.transition(TransitionEffect.scale({ x: 0, y: 0 })
  .animation({ duration: 450, curve: Curve.EaseOut }))

// 从 0.5 放大到 1(轻微弹出)
.transition(TransitionEffect.scale({ x: 0.5, y: 0.5 })
  .animation({ duration: 400, curve: Curve.EaseIn }))

scale({ x: 0, y: 0 }) 是最激进的缩放效果——组件从不可见的点放大到正常大小,类似"弹出"或"放大出现"。scale({ x: 0.5, y: 0.5 }) 则更温和。

TransitionEffect.rotate(options)

旋转转场。参数 { x?: number, y?: number, z?: number, angle: number } 指定旋转轴和角度:

  • z: 1, angle: 180 — 绕 Z 轴(垂直于屏幕)旋转 180°,即平面内旋转
  • x: 1, angle: 90 — 绕 X 轴(水平轴)旋转 90°,即翻转效果
  • y: 1, angle: 90 — 绕 Y 轴(垂直轴)旋转 90°,即侧翻效果
// 绕 Z 轴旋转 180° + 同时淡入
.transition(
  TransitionEffect.rotate({ z: 1, angle: 180 })
    .animation({ duration: 500, curve: Curve.EaseOut })
    .combine(TransitionEffect.OPACITY.animation({ duration: 300 }))
)

单独的 rotate 效果通常不够自然——一个旋转进入但完全不透明的组件会显得突兀。因此 rotate 通常与 OPACITY 组合使用(用 .combine()),让组件在旋转的同时逐渐显现。

TransitionEffect.translate(options)

平移转场。参数 { x?: number, y?: number } 指定平移的起始偏移量(相对于组件正常位置)。与 move 不同,translate 让你可以精确控制偏移的方向和距离。

// 从右上方 100vp 处斜向移入
.transition(
  TransitionEffect.translate({ x: 100, y: -50 })
    .animation({ duration: 400, curve: Curve.EaseOut })
    .combine(TransitionEffect.OPACITY)
)

translate 给了开发者最大的自由度——你可以实现任何方向的平移,也可以实现对角线方向的移动。

组合效果:combine()

.combine() 方法将两个 TransitionEffect 合并为一个——两个效果同时播放,形成复合动画。

TransitionEffect.scale({ x: 0, y: 0 })   // 缩放
  .combine(TransitionEffect.OPACITY)      // + 淡入
  .animation({ duration: 450, curve: Curve.EaseOut })

这个例子中,组件同时在缩放和淡入——从 0 大小的透明状态,在 450ms 内变为正常大小的不透明状态。

combine 的调用顺序不影响动画效果(A.combine(B) 和 B.combine(A) 效果相同),但会影响动画参数的继承:.animation() 只作用于它之前链式调用的效果。如果你需要两个效果有不同的 duration,可以为每个效果分别设置 animation,然后用 combine 合并:

TransitionEffect.rotate({ z: 1, angle: 180 })
  .animation({ duration: 500, curve: Curve.EaseOut })
  .combine(
    TransitionEffect.OPACITY.animation({ duration: 300 })
  )

这里旋转耗时 500ms,而淡入只需 300ms——淡入先完成,旋转还在继续,创造了"旋转着浮现"的效果。

非对称动画:asymmetric()

.asymmetric(appearEffect, disappearEffect) 是 TransitionEffect 最强大的特性之一——它允许进场和退场使用完全不同的动画。

.transition(TransitionEffect.asymmetric(
  // 进场:从左侧滑入 + 淡入
  TransitionEffect.move(TransitionEdge.START)
    .combine(TransitionEffect.OPACITY)
    .animation({ duration: 500, curve: Curve.EaseOut }),
  // 退场:缩小到 50% + 淡出
  TransitionEffect.scale({ x: 0.5, y: 0.5 })
    .combine(TransitionEffect.OPACITY)
    .animation({ duration: 400, curve: Curve.EaseIn })
))

这种非对称设计符合用户的直觉:新元素从某个方向"进来",旧元素以不同的方式"离开"。常见的搭配模式有:

进场 退场 适用场景
move(START) + OPACITY scale(0.5) + OPACITY 列表增删
move(BOTTOM) + OPACITY OPACITY 通知弹出
scale(0) + OPACITY scale(0) + OPACITY 弹窗/对话框
rotate + OPACITY move(END) + OPACITY 页面切换

animation() 参数

.animation({ duration, curve, delay }) 为转场效果指定动画参数:

  • duration: number — 动画持续时间,单位毫秒。推荐范围 300-600ms,太短用户感知不到,太长用户会不耐烦。
  • curve: Curve — 动画曲线,控制动画的速率变化。Curve.EaseOut(快进慢出)适合进场动画,Curve.EaseIn(慢进快出)适合退场动画,Curve.EaseInOut 适合对称场景。
  • delay: number — 延迟时间,单位毫秒。用于错开多个动画的播放时间。
    在这里插入图片描述
    在这里插入图片描述

Demo 设计:转场动画实验室

本文 Demo 实现了一个完整的转场动画实验室,包含以下功能:

页面结构

Column(根容器)
├── Header(深色标题栏:"转场动画实验室" + TransitionEffect 标签)
├── 效果选择器(8 个效果按钮 + 效果描述文字)
├── 动画卡片列表(Scroll + ForEach,每条卡片应用转场效果)
└── 控制按钮栏(添加卡片 / 移除最后 / 清空全部)

8 种转场效果

Demo 提供了 8 种转场效果的实时切换:

序号 效果名称 按钮文字 实现方式
1 淡入淡出 淡入淡出 TransitionEffect.OPACITY
2 水平滑动 水平滑动 TransitionEffect.SLIDE
3 滑入缩放 滑入缩放 TransitionEffect.SLIDE_SWITCH
4 上方移入 上方移入 TransitionEffect.move(TOP)
5 下方移入 下方移入 TransitionEffect.move(BOTTOM)
6 缩放弹入 缩放弹入 TransitionEffect.scale({ x: 0, y: 0 })
7 旋转进入 旋转进入 TransitionEffect.rotate({ z: 1, angle: 180 }) + OPACITY
8 组合效果 组合效果 asymmetric(move+OPACITY, scale+OPACITY)

选择不同效果后,后续添加或移除的卡片将使用选中的转场动画。已存在的卡片不受影响。

4 个交互点

  1. 切换转场效果:点击效果标签按钮,实时切换当前使用的转场类型,下方描述文字同步更新。
  2. 添加卡片:点击"添加卡片"按钮,一个带颜色的卡片通过选中的转场效果进入列表。
  3. 移除最后一张:点击"移除最后"按钮,最后一张卡片通过选中的转场效果离开列表。
  4. 清空全部:点击"清空全部"按钮,所有卡片一次性离开列表,每张卡片独立播放退场动画。

核心实现

数据模型

class AnimatedItem {
  id: number;
  label: string;
  color: string;

  constructor(id: number, label: string, color: string) {
    this.id = id;
    this.label = label;
    this.color = color;
  }
}

class EffectOption {
  name: string;
  key: string;

  constructor(name: string, key: string) {
    this.name = name;
    this.key = key;
  }
}

两个数据类分别用于卡片数据和效果选项。EffectOption 的 key 字段用于标识当前选中的效果类型。

效果选择器实现

private effectOptions: EffectOption[] = [
  new EffectOption('淡入淡出', 'opacity'),
  new EffectOption('水平滑动', 'slide'),
  new EffectOption('滑入缩放', 'slideSwitch'),
  new EffectOption('上方移入', 'moveTop'),
  new EffectOption('下方移入', 'moveBottom'),
  new EffectOption('缩放弹入', 'scale'),
  new EffectOption('旋转进入', 'rotate'),
  new EffectOption('组合效果', 'combined'),
];

效果选择器通过 Flex({ wrap: FlexWrap.Wrap }) 实现流式布局,选中的效果按钮使用蓝色高亮:

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(this.effectOptions, (opt: EffectOption) => {
    Text(opt.name)
      .fontSize(12)
      .fontColor(this.selectedEffect === opt.key ? '#FFFFFF' : '#444455')
      .fontWeight(this.selectedEffect === opt.key ? FontWeight.Medium : FontWeight.Normal)
      .padding({ top: 7, bottom: 7, left: 14, right: 14 })
      .borderRadius(8)
      .backgroundColor(this.selectedEffect === opt.key ? '#1677FF' : '#F5F5F5')
      .margin({ right: 8, bottom: 8 })
      .onClick(() => { this.selectedEffect = opt.key; })
  }, (opt: EffectOption) => opt.key)
}
.width('100%')

getTransitionEffect() 核心方法

getTransitionEffect(): TransitionEffect {
  if (this.selectedEffect === 'opacity') {
    return TransitionEffect.OPACITY.animation({ duration: 400, curve: Curve.EaseOut });
  }
  if (this.selectedEffect === 'slide') {
    return TransitionEffect.SLIDE.animation({ duration: 400, curve: Curve.EaseOut });
  }
  if (this.selectedEffect === 'slideSwitch') {
    return TransitionEffect.SLIDE_SWITCH.animation({ duration: 500, curve: Curve.EaseOut });
  }
  if (this.selectedEffect === 'moveTop') {
    return TransitionEffect.move(TransitionEdge.TOP)
      .animation({ duration: 400, curve: Curve.EaseOut });
  }
  if (this.selectedEffect === 'moveBottom') {
    return TransitionEffect.move(TransitionEdge.BOTTOM)
      .animation({ duration: 400, curve: Curve.EaseOut });
  }
  if (this.selectedEffect === 'scale') {
    return TransitionEffect.scale({ x: 0, y: 0 })
      .animation({ duration: 450, curve: Curve.EaseOut });
  }
  if (this.selectedEffect === 'rotate') {
    return TransitionEffect.rotate({ z: 1, angle: 180 })
      .animation({ duration: 500, curve: Curve.EaseOut })
      .combine(TransitionEffect.OPACITY.animation({ duration: 300 }));
  }
  if (this.selectedEffect === 'combined') {
    return TransitionEffect.asymmetric(
      TransitionEffect.move(TransitionEdge.START)
        .combine(TransitionEffect.OPACITY)
        .animation({ duration: 500, curve: Curve.EaseOut }),
      TransitionEffect.scale({ x: 0.5, y: 0.5 })
        .combine(TransitionEffect.OPACITY)
        .animation({ duration: 400, curve: Curve.EaseIn })
    );
  }
  return TransitionEffect.OPACITY;
}

这个方法是 Demo 的核心——它根据 selectedEffect 的当前值,返回对应的 TransitionEffect 对象。每个卡片在模板中都调用 .transition(this.getTransitionEffect()),因此切换效果类型后,新添加或移除的卡片就会使用新的动画效果。

值得注意的是 rotate 效果:它使用了 .combine(TransitionEffect.OPACITY.animation({ duration: 300 }))。因为如果只有旋转而没有透明度变化,组件在旋转的前半段(旋转角 > 90° 时)会显得很奇怪——你看到的是一个正在旋转的完全不透明的卡片。叠加上淡入效果后,卡片在旋转的同时逐渐显现,视觉上自然得多。

卡片列表与转场绑定

if (this.items.length > 0) {
  Scroll() {
    Column() {
      ForEach(this.items, (item: AnimatedItem) => {
        Row() {
          Text(item.label)
            .fontSize(15)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
        }
        .width('100%')
        .padding(18)
        .borderRadius(10)
        .backgroundColor(item.color)
        .margin({ bottom: 10 })
        .transition(this.getTransitionEffect())
      }, (item: AnimatedItem) => item.id.toString())
    }
    .width('100%')
    .padding({ left: Spacing.LG, right: Spacing.LG, top: 14 })
  }
  .layoutWeight(1)
  .scrollBar(BarState.Off)
}

关键点:.transition() 绑定到了 ForEach 内部的每个 Row() 上。当 this.items 数组变化时——新增元素触发进场动画,移除元素触发退场动画。ArkUI 框架自动处理动画的播放和清理,不需要开发者手动管理。

状态更新模式

在 ArkTS 严格模式下,数组的增删必须通过不可变更新模式——先 slice() 创建副本,修改副本,再赋值回去:

addItem(): void {
  let colorIndex: number = this.nextId % this.colors.length;
  let newItems: AnimatedItem[] = this.items.slice();
  newItems.push(new AnimatedItem(this.nextId, '卡片 ' + this.nextId.toString(), this.colors[colorIndex]));
  this.items = newItems;
  this.nextId = this.nextId + 1;
}

removeLast(): void {
  if (this.items.length > 0) {
    let newItems: AnimatedItem[] = this.items.slice();
    newItems.pop();
    this.items = newItems;
  }
}

clearAll(): void {
  this.items = [];
  this.nextId = 1;
}

this.items = newItems 触发了 @State 的变化检测,ArkUI 比较新旧数组,确定哪些元素是新增的(播放进场动画),哪些被移除了(播放退场动画)。

动画时机与性能考量

TransitionEffect 与 animateTo 的选择

很多开发者在初次接触 ArkUI 动画时会困惑:什么时候用 TransitionEffect,什么时候用 animateTo

一个简化的判断标准:

场景 使用 API 原因
组件出现/消失 TransitionEffect 专为此场景设计,自动处理挂载/卸载时序
组件属性变化(位置、大小、颜色等) animateTo 操作已存在组件的属性过渡
连续动画序列 animateTo + onFinish 支持链式调用和回调
手势跟随 animateTo 支持实时更新目标值

TransitionEffect 的核心价值在于自动化——你不需要在添加数据前设置初始状态,也不需要担心动画完成后组件是否应该存在于组件树中。框架替你处理了这些细节。

性能注意事项

  1. 避免过多的 combine:虽然 combine() 在语义上很清晰,但每个 combine 都对应一个独立的动画引擎轨道。超过 3 个 combine 的效果组合可能在某些低端设备上导致掉帧。实际上,1-2 个效果(如 move + OPACITY)已经足够表达绝大多数转场意图。

  2. duration 不宜过长:转场动画的目的是让用户感知到变化,而不是展示动画本身。推荐 duration 在 300-500ms 之间。超过 600ms 的转场动画会让用户觉得"界面反应慢"。

  3. 列表批量操作:当清空一个包含大量元素的列表时,每个元素都会独立播放退场动画。如果元素数量超过 20-30 个,建议缩短退场动画的 duration(200-300ms),避免总退场时间过长。

  4. OPACITY 是最经济的:在所有转场效果中,OPACITY 的性能开销最小,因为它不涉及布局重计算或变换矩阵运算。在性能敏感的滚动列表中,优先使用 OPACITY。

TransitionEffect 与页面转场

组件级 TransitionEffect(本文讨论的 .transition())与页面级转场(pageTransition)是两个不同层面的 API:

// 组件级转场 — 组件在页面内出现/消失
Component()
  .transition(TransitionEffect.OPACITY)

// 页面级转场 — 整个页面在导航中出现/消失
@Entry
@Component
struct MyPage {
  pageTransition() {
    PageTransitionEnter({ duration: 300 })
      .slide(SlideEffect.Right)
  }
}

两者的适用场景不同:组件级转场用于页面内部的元素动画(如列表增删、弹窗出现),页面级转场用于 Navigation 导航时的页面切换动画。一个常见的做法是两者配合使用——页面用 slide 转场进入,页面内的元素用 OPACITY 转场逐项出现。

总结

本文通过构建一个"转场动画实验室",深入讲解了 HarmonyOS ArkUI 中 TransitionEffect 组件的核心用法:

  1. 三种内置效果OPACITY(透明度)、SLIDE(滑动)、SLIDE_SWITCH(滑动+缩放),提供开箱即用的常见转场。
  2. 四种自定义效果move(edge)(指定方向移入)、scale({x, y})(缩放)、rotate({x, y, z, angle})(旋转)、translate({x, y})(偏移),支持构建任意转场效果。
  3. combine() 组合:将多个转场效果合并为一个复合动画,所有效果同时播放。
  4. asymmetric() 非对称:进场和退场使用完全不同的动画效果,让"进来"和"离开"各有各的视觉语言。
  5. animation() 参数:duration、curve、delay 三个参数控制动画的时长、速率曲线和延迟。

TransitionEffect 是 ArkUI 声明式动画体系的重要组成部分。它与 animateTo 形成了互补——animateTo 处理"已存在组件"的属性动画,TransitionEffect 处理"组件进入/离开"的生命周期动画。两者配合使用,可以构建出流畅、自然、有层次的界面动画系统。

动画不是装饰——它是界面语言的一部分。一个好的转场动画比一行文字说明更能让用户直观理解"这个卡片是新添加的"、“那个通知已经消失了”。用户可能不会意识到动画的存在,但如果缺少它,用户一定会觉得界面有些"生硬"。这就是 TransitionEffect 的价值所在:它让界面的状态变化变得可感知。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐