[鸿蒙从零到一] ArkUI 动画与转场实战:状态驱动、组件过渡与页面衔接

鸿蒙从零到一 ArkUI 动画与转场实战:状态驱动、组件过渡与页面衔接

前言

动画的价值不是让界面"动起来",而是帮助用户理解状态变化:按钮为什么不可用、内容从哪里出现、页面之间是什么关系。没有语义的动画只会拖慢操作,有明确因果关系的动画才能改善反馈和空间连续性。

本文以一个任务看板为例,从属性动画开始,逐步实现组件进退场、列表增删、共享元素衔接和 Navigation 页面转场,并讨论中断处理、性能、无障碍与工程封装。示例使用 ArkTS、ArkUI 和 Stage 模型,具体接口请以项目所使用的 SDK 类型定义为准。


一、先建立 ArkUI 动画的心智模型

ArkUI 是状态驱动 UI。普通渲染关注"状态改变后界面长什么样",动画则补充"界面怎样从旧状态过渡到新状态"。常用能力可以分成四类:

场景 常用能力 适合解决的问题
单个属性持续变化 animation() 缩放、透明度、偏移等连续变化
一组状态同步变化 animateTo() 展开面板、切换选中态、联动多个属性
组件创建和销毁 transition() 弹层、空状态、列表项进退场
页面或元素空间衔接 Navigation 转场、geometryTransition() 页面切换、卡片到详情的连续变形

选择能力时先问三个问题:

  • 动画由哪个状态变化触发?
  • 动画结束后,真实业务状态是什么?
  • 用户连续操作导致动画中断时,界面应该停在哪里?

如果这三个问题答不清楚,代码通常会出现状态与画面不一致、重复点击失效或退出动画不执行等问题。


二、用 animation 构建属性动画

animation() 修饰它之前声明的可动画属性。当这些属性依赖的状态发生改变时,ArkUI 会在新旧值之间插值。

ts 复制代码
@Component
struct FavoriteButton {
  @State private liked: boolean = false

  build() {
    Button() {
      Row({ space: 8 }) {
        Text(this.liked ? '♥' : '♡')
          .fontSize(22)
        Text(this.liked ? '已收藏' : '收藏')
      }
    }
    .fontColor(this.liked ? Color.White : '#1F2329')
    .backgroundColor(this.liked ? '#E5484D' : '#F2F3F5')
    .scale({ x: this.liked ? 1.04 : 1, y: this.liked ? 1.04 : 1 })
    .animation({ duration: 220, curve: Curve.EaseOut })
    .onClick(() => {
      this.liked = !this.liked
    })
  }
}

这里的颜色和缩放由同一个 liked 状态驱动,因此会一起过渡。需要注意修饰符顺序:animation() 只作用于它之前可动画的属性。若把某个属性写在 animation() 后面,它不会自动获得同样的动画配置。

属性动画适合局部、简单、直接由状态映射出的变化。不要在一个组件链上堆叠多个作用范围不清晰的 animation(),否则后续维护者很难判断某个属性使用哪套时长和曲线。


三、用 animateTo 统一编排状态变化

当多个组件、多个属性需要在一次交互中同步变化时,animateTo() 更清晰。它把闭包中的状态修改作为一个动画事务处理。

ts 复制代码
@Component
struct FilterPanel {
  @State private expanded: boolean = false

  build() {
    Column({ space: 12 }) {
      Row() {
        Text('筛选条件')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
        Blank()
        Text(this.expanded ? '收起' : '展开')
      }
      .width('100%')
      .onClick(() => {
        animateTo({
          duration: 260,
          curve: Curve.EaseOut
        }, () => {
          this.expanded = !this.expanded
        })
      })

      Column({ space: 10 }) {
        TextInput({ placeholder: '负责人' })
        TextInput({ placeholder: '标签' })
      }
      .width('100%')
      .height(this.expanded ? 104 : 0)
      .opacity(this.expanded ? 1 : 0)
      .translate({ y: this.expanded ? 0 : -8 })
      .clip(true)
    }
    .padding(16)
  }
}

高度、透明度和位移同时变化,用户会感知到面板从标题下方展开。这里使用 clip(true),避免收起过程中子组件绘制到容器外。

业务代码中不建议把异步请求放进 animateTo() 闭包。更稳妥的顺序是:先完成状态校验或网络请求,再在动画事务中提交视图状态。这样动画不会依赖不可预测的异步时长。

ts 复制代码
private async submit(): Promise<void> {
  if (this.submitting) {
    return
  }
  this.submitting = true
  try {
    await this.repository.save(this.formData)
    animateTo({ duration: 180, curve: Curve.EaseOut }, () => {
      this.saved = true
    })
  } finally {
    this.submitting = false
  }
}

四、为组件创建和销毁添加 transition

条件渲染会创建或销毁组件。只给组件设置 opacity 并不能保证退出过程可见,因为状态改变后组件可能立即离开组件树。transition() 专门描述组件出现和消失时的效果。

ts 复制代码
@Component
struct ToastDemo {
  @State private showMessage: boolean = false

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Button('保存任务')
        .onClick(() => {
          animateTo({ duration: 220 }, () => {
            this.showMessage = true
          })
        })

      if (this.showMessage) {
        Row({ space: 8 }) {
          Text('✓')
          Text('保存成功')
        }
        .padding({ left: 16, right: 16, top: 10, bottom: 10 })
        .backgroundColor('#202124')
        .fontColor(Color.White)
        .borderRadius(6)
        .margin({ bottom: 24 })
        .transition(
          TransitionEffect.OPACITY
            .combine(TransitionEffect.translate({ y: 16 }))
            .animation({ duration: 220, curve: Curve.EaseOut })
        )
        .onClick(() => {
          animateTo({ duration: 160 }, () => {
            this.showMessage = false
          })
        })
      }
    }
    .width('100%')
    .height(240)
  }
}

进入和退出可以使用不同配置。进入通常强调"内容到达",适合稍慢的减速曲线;退出强调"操作完成",通常更短。若当前 SDK 支持非对称转场,可分别配置 appear 和 disappear 效果。

还要避免用动画替代完整交互。提示条仍应具备自动关闭、手动关闭、重复消息合并等机制,转场只负责表现,不负责业务生命周期。


五、让列表增删保持稳定

列表动画最常见的问题不是"不够顺滑",而是身份不稳定。ForEachLazyForEach 必须使用稳定且唯一的业务键,不能用数组下标充当长期键。

ts 复制代码
interface TaskItem {
  id: string
  title: string
  completed: boolean
}

@Component
struct TaskList {
  @State private tasks: TaskItem[] = []

  build() {
    List({ space: 8 }) {
      ForEach(this.tasks, (task: TaskItem) => {
        ListItem() {
          Row() {
            Text(task.title)
            Blank()
            Button('删除')
              .onClick(() => this.removeTask(task.id))
          }
          .width('100%')
          .padding(12)
        }
        .transition(
          TransitionEffect.OPACITY
            .combine(TransitionEffect.translate({ x: 24 }))
        )
      }, (task: TaskItem) => task.id)
    }
  }

  private removeTask(id: string): void {
    animateTo({ duration: 200, curve: Curve.EaseInOut }, () => {
      this.tasks = this.tasks.filter((item: TaskItem) => item.id !== id)
    })
  }
}

稳定键让框架知道"哪个元素消失、哪些元素只是换了位置"。若使用下标,删除中间项后,后续所有元素的身份都会变化,容易造成内容跳动、状态串位和不必要的重建。

数据量较大时应继续使用懒加载容器,并限制同时播放的复杂效果。列表滚动过程中给每个子项添加阴影、模糊和多层缩放,通常会放大渲染开销。


六、用 geometryTransition 建立共享元素连续性

当用户点击卡片进入详情时,让封面或标题从原位置自然过渡到目标位置,可以明确表达"详情页就是这张卡片的展开"。geometryTransition() 通过相同 ID 关联前后两个几何形态。

ts 复制代码
@Entry
@Component
struct BoardPage {
  @State private selected: boolean = false
  private readonly heroId: string = 'task-cover-42'

  build() {
    Stack() {
      if (!this.selected) {
        Column() {
          Image($r('app.media.task_cover'))
            .width(120)
            .height(80)
            .objectFit(ImageFit.Cover)
            .geometryTransition(this.heroId)
          Text('版本发布检查')
        }
        .onClick(() => {
          animateTo({ duration: 320, curve: Curve.EaseInOut }, () => {
            this.selected = true
          })
        })
      } else {
        Column() {
          Image($r('app.media.task_cover'))
            .width('100%')
            .height(240)
            .objectFit(ImageFit.Cover)
            .geometryTransition(this.heroId)
          Text('版本发布检查')
            .fontSize(26)
          Text('确认签名、版本号、隐私声明与灰度策略。')
        }
        .onClick(() => {
          animateTo({ duration: 280, curve: Curve.EaseInOut }, () => {
            this.selected = false
          })
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}

共享元素 ID 必须在当前动画范围内唯一且稳定。列表场景应由业务 ID 派生,例如 task-cover-${task.id}。不要给多个同时可见元素复用同一个 ID。

源元素与目标元素的内容、裁剪方式和宽高比越接近,转场越自然。若封面在列表中是正方形、详情中突然变成长横图,需要明确设置 objectFit 和裁剪策略,否则中间帧可能出现拉伸。


真实应用中的页面切换通常由 NavigationNavPathStack 管理。页面转场应服从导航状态,而不是用两个布尔变量模拟页面栈。

ts 复制代码
@Entry
@Component
struct TaskApp {
  private pathStack: NavPathStack = new NavPathStack()

  @Builder
  pageMap(name: string, param: object) {
    if (name === 'TaskDetail') {
      TaskDetailPage({
        taskId: (param as Record<string, string>).taskId,
        pathStack: this.pathStack
      })
    }
  }

  build() {
    Navigation(this.pathStack) {
      TaskBoard({
        onOpen: (taskId: string) => {
          this.pathStack.pushPath({
            name: 'TaskDetail',
            param: { taskId }
          })
        }
      })
    }
    .title('任务看板')
    .navDestination(this.pageMap)
    .mode(NavigationMode.Stack)
  }
}

系统默认转场已经覆盖大多数页面。自定义 Navigation 转场时,应重点处理以下边界:

  • 入栈、出栈与返回手势方向一致。
  • 页面被快速连续打开时,不重复压入相同业务页面。
  • 转场期间仍以 NavPathStack 为真实导航状态。
  • 返回手势取消后,页面状态和元素透明度能够恢复。
  • 弹窗、半模态和完整页面使用不同空间语义。

不要为了统一视觉而给所有页面套同一种缩放效果。层级页面适合水平或共享元素衔接,临时任务适合从底部出现,跨模块跳转则可以使用更克制的淡入淡出。


八、曲线、时长与节奏如何选择

动画参数应该形成产品级规范,而不是散落在代码中的魔法数字。

动作 建议时长 曲线倾向
按压、开关反馈 100~180 ms 快速减速
小组件进退场 180~260 ms 进入减速,退出加速
面板展开收起 220~320 ms 平滑缓入缓出
页面或共享元素衔接 280~420 ms 强调空间连续性

可以集中定义动效令牌:

ts 复制代码
export class MotionTokens {
  static readonly quick: number = 140
  static readonly normal: number = 220
  static readonly page: number = 320
  static readonly standardCurve: Curve = Curve.EaseInOut
  static readonly enterCurve: Curve = Curve.EaseOut
}

统一令牌能减少体验漂移,也方便针对低性能设备或减少动态效果偏好进行整体调整。时长不是越短越好:太短看不清空间关系,太长则阻碍高频操作。


九、正确处理连续点击与动画中断

动画期间完全禁止交互通常不是最佳方案。用户可能想立即反向收起面板,系统应从当前视觉状态平滑过渡到新目标,而不是等待旧动画结束。

对于提交、支付等不可重复业务操作,应由业务状态防重:

ts 复制代码
@State private submitting: boolean = false

Button(this.submitting ? '提交中' : '提交')
  .enabled(!this.submitting)
  .opacity(this.submitting ? 0.6 : 1)
  .onClick(() => this.submit())

对于展开、切换、返回等可逆视觉操作,则可以响应新状态,让框架处理从当前插值位置到新目标的过渡。不要用固定延时把状态"锁死",这会在掉帧、应用切后台或系统动画倍率变化时失去同步。

动画完成回调只适合触发表现层收尾,不应成为保存关键业务数据的唯一时机。应用被中断时,完成回调未必有机会执行。


十、性能优化:优先改变合成属性

动画掉帧常见原因是每一帧都触发昂贵布局或绘制。优化时遵循以下顺序:

  • 优先使用 opacitytranslatescalerotate 等合成友好的属性。
  • 谨慎持续改变宽高,尤其是复杂布局树中的百分比尺寸。
  • 减少大面积模糊、阴影、透明叠层和超大图片参与动画。
  • 列表中只为可见且需要反馈的元素播放动画。
  • 避免在状态 getter 或 build() 相关路径执行重计算。
  • 在真机和接近目标配置的设备上观察,而不只依赖预览器。

例如侧边面板可以固定最终宽度,再通过位移显示和隐藏;相比每帧把宽度从零计算到目标值,这通常更稳定:

ts 复制代码
Column() {
  // 面板内容
}
.width(280)
.translate({ x: this.open ? 0 : -280 })
.opacity(this.open ? 1 : 0)

如果动画仍不流畅,应使用 DevEco Studio 的性能分析工具确认瓶颈属于布局、绘制、图片解码还是业务线程阻塞,再针对证据优化。


十一、无障碍与减少动态效果

动画不能成为理解内容的唯一方式。颜色、位移和缩放之外,还应提供文本、图标状态或辅助说明。例如保存成功不能只靠绿色闪烁,还应有"保存成功"的可读文本。

工程中可以把"减少动态效果"抽象为统一配置:

ts 复制代码
export function motionDuration(normalDuration: number, reduceMotion: boolean): number {
  return reduceMotion ? 0 : normalDuration
}

当用户偏好减少动态效果时,应关闭大幅位移、旋转和视差,保留必要的短淡入淡出,或者直接完成状态切换。具体系统偏好的读取方式会随 API 版本变化,应通过当前 SDK 提供的无障碍或系统设置能力接入。

同时检查焦点行为:弹层出现后焦点应进入弹层,关闭后回到触发控件;页面转场不能让屏幕阅读器继续访问已经不可见的旧页面。


十二、测试与排障清单

动画测试不能只验证"最终页面存在"。至少覆盖以下内容:

  • 动画前后的业务状态和组件树正确。
  • 快速连续点击不会产生重复页面或重复提交。
  • 返回、手势取消和应用切后台后状态可恢复。
  • 列表增删使用稳定键,内容不会串位。
  • 深色模式、横竖屏和字体放大下没有裁剪。
  • 低性能设备上没有明显长帧和输入延迟。
  • 减少动态效果开启后仍能完成全部操作。

常见排障方向如下:

现象 优先检查
属性突然跳到最终值 animation() 的修饰符顺序、状态是否真的变化
退出动画不显示 是否使用条件渲染配合 transition() 和动画事务
列表项内容串位 键生成函数是否使用了数组下标或可变字段
共享元素闪烁 ID 是否唯一、源与目标是否同时存在、裁剪和图片尺寸是否一致
页面返回后状态异常 导航栈是否为唯一事实来源、完成回调是否承担了业务状态
真机明显掉帧 是否动画化布局属性、图片是否过大、主线程是否执行重任务

总结

ArkUI 动画的核心仍然是状态管理。animation() 适合局部属性变化,animateTo() 适合统一提交一组视图状态,transition() 负责组件进入和退出,geometryTransition() 与 Navigation 转场负责保持页面之间的空间连续性。

生产级动效还需要稳定的业务键、可中断交互、统一时长与曲线、性能分析和无障碍降级。先把状态、身份和导航关系建模正确,再增加有意义的过渡,界面才能既流畅又可靠。

相关推荐
何时梦醒1 小时前
React + TypeScript + Vite 实战:从零构建 Color Picker 应用
前端·javascript·架构
谁在黄金彼岸1 小时前
Nuxt.js 详解(一):Vue 开发者为什么要关注 Nuxt
前端
英勇无比的消炎药1 小时前
TinyRobot v0.5.0 深度解读(三):Layout 组件——如何组织复杂工作区的页面布局
前端·vue.js
默_笙1 小时前
😋 我让 DeepSeek-R1 在浏览器本地跑了起来,后端同事说"你认真的?"
前端·javascript
英勇无比的消炎药1 小时前
TinyRobot v0.5.0 深度解读(四):CLI 脚手架——从零搭建 AI 应用的工程化实践
前端·vue.js·github
程序员黑豆2 小时前
鸿蒙应用开发之双向绑定实战:从 V1 到 V2 的完整迁移指南
前端·harmonyos
Escape2 小时前
为什么你的 AI 越聊越傻?从 Token 到 Agent,彻底搞懂 AI Agent的秘密㊙️
前端·人工智能·后端
李明卫杭州2 小时前
Vue3 响应式概念 ↔ React 对应方案(类组件与函数组件)对照笔记
前端·vue.js·react.js
windliang2 小时前
Claude Code 源码分析(二):CC 的心脏 queryLoop Agent主循环
前端·ai编程·claude