项目演示

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

目录

  1. 前言:为什么需要过渡动画
  2. Transition 过渡动画核心概念
  3. API 24 中 TransitionEffect 详解
  4. 基础过渡效果详解
  5. 组合过渡效果实战
  6. 入场与离场动画的分别控制
  7. 动画曲线与缓动函数
  8. 列表场景的交错过渡动画
  9. 复杂布局中的过渡动画设计
  10. 性能优化与最佳实践
  11. 常见问题与解决方案
  12. 完整实战项目示例
  13. 总结与展望

前言:为什么需要过渡动画

在移动应用开发中,用户体验(UX)已经成为衡量应用质量的核心指标之一。一个优秀的应用不仅需要功能完善、性能稳定,更需要在视觉层面给用户带来愉悦的感受。过渡动画作为提升用户体验的重要手段,在鸿蒙应用开发中占据着举足轻重的地位。

1.1 过渡动画的价值

过渡动画不仅仅是"让界面动起来这么简单,它承载着多重价值:

第一,引导用户注意力。当界面状态发生变化时,适当的过渡动画能够自然地引导用户的视线,让用户清晰地感知到界面元素的变化过程,而不是突兀的出现或消失。例如,当一个新的组件从底部滑入时,用户的目光会自然地跟随动画轨迹移动,从而注意到新出现的内容。

第二,增强空间层次感。通过位移、缩放、透明度等动画效果,可以在二维屏幕上营造出三维空间感。组件的淡入淡出、缩放变化能够让用户直观地感受到元素之间的层级关系,理解哪个元素在前景、哪个元素在背景。

第三,提升操作反馈。用户的每一次操作都应该得到及时的视觉反馈。过渡动画可以作为操作反馈的一种形式,告诉用户"你的操作已经生效了"。比如点击按钮后,相关内容以动画形式展开,用户就能明确知道自己的点击产生了效果。

第四,塑造品牌调性。动画风格是应用品牌形象的一部分。轻快活泼的动画给人年轻时尚的感觉,稳重优雅的动画则传递出专业可靠的印象。通过统一的动画设计语言,可以强化应用的品牌辨识度。

1.2 鸿蒙系统中的动画体系

HarmonyOS NEXT(API 24)提供了完善的动画体系,大致可以分为以下几类:

  • 组件过渡动画(Transition):组件插入/删除时的过渡效果,本文重点讨论
  • 属性动画(animateTo):组件属性变化时的动画过渡
  • 显式动画:通过 animation 属性直接绑定动画
  • 页面转场动画:页面之间切换时的过渡效果
  • 手势动画:跟随手势操作的实时动画

其中,transition 过渡动画专门用于处理组件的"出现"和"消失"场景,是 ArkUI 声明式开发范式中非常重要的动画能力。

1.3 本文的内容安排

本文将从基础概念入手,循序渐进地讲解 HarmonyOS NEXT API 24 中 transition 过渡动画的使用方法。全文包含大量可运行的代码示例,每个示例都配有详细的注释和效果说明。无论你是刚接触鸿蒙开发的新手,还是希望深入了解过渡动画的进阶开发者,都能从本文中获得有价值的内容。


Transition 过渡动画核心概念

2.1 什么是 Transition 过渡动画

Transition 过渡动画是 ArkUI 框架提供的一种组件级动画能力,用于描述组件在插入(Insert)和删除(Delete)时的动画效果。简单来说,就是当一个组件从无到有出现时,或者从有到无消失时,应该以什么样的动画方式呈现。

与属性动画不同,transition 动画的触发条件是组件的构建/销毁,而不是属性值的变化。这意味着 transition 必须配合条件渲染(if/else)使用。

2.2 核心要素:条件渲染

在 ArkUI 的声明式开发范式中,组件的显示与隐藏通常通过 if/else 条件渲染来控制。当条件为 true 时,组件被构建并插入到组件树中;当条件变为 false 时,组件从组件树中移除。

@State isShow: boolean = true

build() {
  Column() {
    if (this.isShow) {
      // 当 isShow 为 true 时,这个 Text 组件才会存在
      Text('Hello Transition')
        .fontSize(24)
    }
    
    Button('切换显示')
      .onClick(() => {
        this.isShow = !this.isShow
      })
  }
}

在上面的代码中,当点击按钮切换 isShow 的值时,Text 组件会直接出现或消失,没有任何过渡效果,视觉上会比较突兀。而 transition 动画的作用,就是让这个出现和消失的过程变得平滑自然。

2.3 API 24 的重大变化

在早期版本的 ArkUI 中,transition 的配置方式是通过传入一个配置对象:

// 旧版写法(API 12 及更早,已不推荐)
.transition({
  type: TransitionType.Insert,
  opacity: 0,
  translate: { x: 100 },
  animation: {
    duration: 500,
    curve: Curve.EaseInOut
  }
})

而在 HarmonyOS NEXT API 24 中,transition API 进行了重要升级,采用了 TransitionEffect 链式调用的方式:

// 新版写法(API 24,推荐)
.transition(
  TransitionEffect.opacity(0)
    .combine(TransitionEffect.translate({ x: 100 }))
    .animation({
      duration: 500,
      curve: Curve.EaseInOut
    })
)

这种新的 API 设计具有以下优势:

  1. 类型安全更强:通过方法链的方式,每一步都有明确的类型定义
  2. 组合更灵活:使用 combine 方法可以清晰地组合多种效果
  3. 可读性更好:链式调用的代码结构更符合声明式 UI 的风格
  4. 扩展更方便:新增效果时只需添加新的方法,不破坏现有结构

本文所有示例均基于 API 24 的 TransitionEffect 新 API 编写。

2.4 过渡动画的生命周期

理解 transition 动画的生命周期对于正确使用它至关重要。一个完整的过渡动画生命周期包括以下几个阶段:

插入动画(Insert)

  1. 状态变化:条件从 false 变为 true
  2. 初始状态:组件以 TransitionEffect 定义的初始状态开始(如透明度为 0、偏移一定距离)
  3. 动画执行:组件从初始状态平滑过渡到正常状态
  4. 动画结束:组件完全显示,处于正常渲染状态

删除动画(Delete)

  1. 状态变化:条件从 true 变为 false
  2. 动画执行:组件从正常状态平滑过渡到 TransitionEffect 定义的结束状态
  3. 动画结束:组件完全消失,从组件树中移除

需要注意的是,在删除动画执行期间,虽然组件正在消失,但它仍然占据着布局空间,直到动画完全结束才会被真正移除。


API 24 中 TransitionEffect 详解

3.1 TransitionEffect 类概述

TransitionEffect 是 API 24 中新增的核心类,用于创建和组合过渡动画效果。它提供了一系列静态方法用于创建基础过渡效果,并通过实例方法进行组合和配置。

TransitionEffect 的设计遵循了链式调用函数式组合的思想,每个方法都返回一个新的 TransitionEffect 实例,使得代码可以流畅地链式书写。

3.2 基础过渡效果的静态方法

TransitionEffect 提供了以下静态方法用于创建基础过渡效果:

方法 说明 参数
opacity(opacity: number) 透明度过渡效果 目标透明度值(0~1)
translate(translate: TranslateOptions) 位移动画效果 位移配置对象 {x?, y?, z?}
scale(scale: ScaleOptions) 缩放动画效果 缩放配置对象 {x?, y?, z?, centerX?, centerY?}
rotate(rotate: RotateOptions) 旋转动画效果 旋转配置对象 {angle?, x?, y?, z?, centerX?, centerY?}
move(edge: TransitionEdge) 边缘移入/移出效果 边缘方向枚举值

下面我们逐一详细讲解每个方法。

3.3 opacity - 透明度过渡

opacity 是最常用的过渡效果之一,用于实现淡入淡出效果。

方法签名:

static opacity(opacity: number): TransitionEffect

参数说明:

  • opacity:目标透明度值,取值范围 0~1。
    • 0 表示完全透明(不可见)
    • 1 表示完全不透明(正常显示)
    • 中间值表示半透明状态

使用示例:

@Entry
@Component
struct OpacityDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('切换显示')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 30 })

      if (this.isShow) {
        Text('淡入淡出效果')
          .fontSize(24)
          .fontColor('#FFFFFF')
          .padding(20)
          .backgroundColor('#36D')
          .borderRadius(12)
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 1000 })
          )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

效果说明:

  • 当组件出现时(Insert):从透明度 0 逐渐变为透明度 1(淡入)
  • 当组件消失时(Delete):从透明度 1 逐渐变为透明度 0(淡出)

可以看到,opacity(0) 定义的是过渡的"初始状态"(对于插入)或"结束状态"(对于删除),而正常状态(opacity: 1)是动画的另一端。

3.4 translate - 位移动画

translate 用于实现组件的位移过渡效果,即组件从某个位置滑动进入/滑出。

方法签名:

static translate(translate: TranslateOptions): TransitionEffect

TranslateOptions 接口:

interface TranslateOptions {
  x?: number | string;  // X 轴位移量,单位 vp
  y?: number | string;  // Y 轴位移量,单位 vp
  z?: number | string;  // Z 轴位移量,单位 vp
}

位移方向说明:

  • x > 0:向右偏移
  • x < 0:向左偏移
  • y > 0:向下偏移
  • y < 0:向上偏移

使用示例 - 从左侧滑入:

@Entry
@Component
struct TranslateDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('从左侧滑入/滑出')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 30 })

      if (this.isShow) {
        Text('从左侧滑入')
          .fontSize(20)
          .fontColor('#FFFFFF')
          .padding({ left: 30, right: 30, top: 15, bottom: 15 })
          .backgroundColor('#4CAF50')
          .borderRadius(8)
          .transition(
            TransitionEffect.translate({ x: -150 })
              .animation({
                duration: 600,
                curve: Curve.EaseOut
              })
          )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

效果说明:

  • 入场:从左侧 150vp 外的位置向右滑动到正常位置
  • 离场:从正常位置向左滑动 150vp 后消失

3.5 scale - 缩放动画

scale 用于实现组件的缩放过渡效果,可以让组件从小到大放大出现,或从大到小缩小消失。

方法签名:

static scale(scale: ScaleOptions): TransitionEffect

ScaleOptions 接口:

interface ScaleOptions {
  x?: number;           // X 轴缩放比例
  y?: number;           // Y 轴缩放比例
  z?: number;           // Z 轴缩放比例(3D)
  centerX?: number;     // 缩放中心点 X 坐标
  centerY?: number;     // 缩放中心点 Y 坐标
}

缩放比例说明:

  • 1 表示原始大小
  • 0 表示缩小到看不见
  • >1 表示放大
  • <1 表示缩小

使用示例 - 从中心放大:

@Entry
@Component
struct ScaleDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('缩放效果')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 30 })

      if (this.isShow) {
        Column() {
          Text('缩放')
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
        }
        .width(150)
        .height(150)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#FF6B6B')
        .borderRadius(75)
        .transition(
          TransitionEffect.scale({ x: 0, y: 0 })
            .animation({
              duration: 800,
              curve: Curve.EaseOutBack
            })
        )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

效果说明:

  • 入场:从缩放比例 0(中心点)放大到原始大小,带有弹性效果
  • 离场:从原始大小缩小到 0 后消失

3.6 rotate - 旋转动画

rotate 用于实现组件的旋转过渡效果。

方法签名:

static rotate(rotate: RotateOptions): TransitionEffect

RotateOptions 接口:

interface RotateOptions {
  angle?: number | string;  // 旋转角度,单位度
  x?: number;               // 旋转轴 X 分量
  y?: number;               // 旋转轴 Y 分量
  z?: number;               // 旋转轴 Z 分量
  centerX?: number | string; // 旋转中心 X 坐标
  centerY?: number | string; // 旋转中心 Y 坐标
}

使用示例 - 旋转入场:

@Entry
@Component
struct RotateDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('旋转效果')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 30 })

      if (this.isShow) {
        Text('旋转出现')
          .fontSize(24)
          .fontColor('#FFFFFF')
          .padding(20)
          .backgroundColor('#9C27B0')
          .borderRadius(12)
          .transition(
            TransitionEffect.rotate({ angle: 180 })
              .animation({
                duration: 800,
                curve: Curve.EaseInOut
              })
          )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

效果说明:

  • 入场:旋转 180 度后恢复正常角度
  • 离场:从正常角度旋转 180 度后消失

3.7 move - 边缘移入/移出

move 是一个便捷方法,用于实现组件从屏幕边缘滑入/滑出的效果。

方法签名:

static move(edge: TransitionEdge): TransitionEffect

TransitionEdge 枚举:

enum TransitionEdge {
  LEFT,    // 左边缘
  RIGHT,   // 右边缘
  TOP,     // 上边缘
  BOTTOM,  // 下边缘
}

使用示例 - 从底部滑入:

@Entry
@Component
struct MoveDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('底部滑入')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 30 })

      if (this.isShow) {
        Text('从底部滑入')
          .fontSize(20)
          .fontColor('#FFFFFF')
          .padding(20)
          .backgroundColor('#FF9800')
          .borderRadius(12)
          .transition(
            TransitionEffect.move(TransitionEdge.BOTTOM)
              .animation({
                duration: 500,
                curve: Curve.Bounce
              })
          )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

效果说明:

  • 入场:从屏幕底部边缘滑入,带有弹跳效果
  • 离场:向屏幕底部边缘滑出

3.8 combine - 组合多种效果

combineTransitionEffect 最重要的实例方法之一,用于将多种过渡效果组合在一起,实现更丰富的动画效果。

方法签名:

combine(other: TransitionEffect): TransitionEffect

使用方式:

TransitionEffect.opacity(0)
  .combine(TransitionEffect.translate({ x: -100 }))
  .combine(TransitionEffect.scale({ x: 0.5, y: 0.5 }))

上面的代码将透明度、位移、缩放三种效果组合在了一起,组件会同时进行淡入、从左侧滑入、放大三种动画。

关于组合效果的更多实战内容,我们将在第 5 章详细讲解。

3.9 animation - 配置动画参数

animation 方法用于配置过渡动画的参数,包括时长、曲线、延迟等。

方法签名:

animation(value: AnimateParam): TransitionEffect

AnimateParam 接口:

interface AnimateParam {
  duration?: number;          // 动画时长,单位毫秒,默认 1000ms
  curve?: Curve | string;     // 动画曲线
  delay?: number;             // 动画延迟时间,单位毫秒
  iterations?: number;        // 动画播放次数
  playMode?: PlayMode;        // 动画播放模式
  tempo?: number;             // 动画播放速率
}

常用配置:

.transition(
  TransitionEffect.opacity(0)
    .animation({
      duration: 500,        // 动画时长 500ms
      curve: Curve.EaseOut, // 缓出曲线
      delay: 100            // 延迟 100ms 开始
    })
)

关于动画曲线的详细内容,我们将在第 7 章专门讲解。


基础过渡效果详解

在第 3 章中,我们简要介绍了各种基础过渡效果。本章将通过更多实际案例,深入讲解每种过渡效果的使用场景和技巧。

4.1 透明度过渡的艺术

透明度过渡是最基础也是最常用的过渡效果。虽然简单,但用好透明度动画能大大提升应用的质感。

4.1.1 不同时长的视觉感受

动画时长对用户体验有显著影响。让我们通过一个对比示例来感受一下:

@Entry
@Component
struct OpacityDurationDemo {
  @State isShow: boolean = true

  build() {
    Column({ space: 20 }) {
      Button('切换所有')
        .onClick(() => {
          this.isShow = !this.isShow
        })

      if (this.isShow) {
        Text('200ms - 快速')
          .fontSize(16)
          .padding(15)
          .backgroundColor('#E3F2FD')
          .borderRadius(8)
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 200 })
          )
      }

      if (this.isShow) {
        Text('500ms - 适中')
          .fontSize(16)
          .padding(15)
          .backgroundColor('#BBDEFB')
          .borderRadius(8)
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 500 })
          )
      }

      if (this.isShow) {
        Text('1000ms - 缓慢')
          .fontSize(16)
          .padding(15)
          .backgroundColor('#90CAF9')
          .borderRadius(8)
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 1000 })
          )
      }

      if (this.isShow) {
        Text('2000ms - 很慢')
          .fontSize(16)
          .padding(15)
          .backgroundColor('#64B5F6')
          .borderRadius(8)
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 2000 })
          )
      }
    }
    .width('100%')
    .padding(30)
  }
}

时长选择建议:

  • 200ms 以内:非常快速,适用于频繁操作的小元素,给人灵敏的感觉
  • 300ms ~ 500ms:最常用的范围,大多数 UI 元素的过渡都适合这个时长
  • 500ms ~ 1000ms:稍慢,适用于重要内容的出现,给人郑重的感觉
  • 1000ms 以上:很慢,通常用于特殊的仪式感动画或引导场景
4.1.2 透明度与层次感

在实际项目中,我们经常需要处理多层元素的显示和隐藏。通过给不同层级的元素设置不同的透明度和动画时长,可以营造出丰富的空间层次感。

@Entry
@Component
struct OpacityLayerDemo {
  @State isShow: boolean = false

  build() {
    Stack() {
      // 背景遮罩层
      if (this.isShow) {
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('rgba(0, 0, 0, 0.5)')
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 300 })
          )
      }

      // 内容卡片
      if (this.isShow) {
        Column() {
          Text('弹窗标题')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .margin({ bottom: 15 })

          Text('这是弹窗内容,背景遮罩先出现,内容后出现,形成层次感。')
            .fontSize(14)
            .fontColor('#666')

          Button('关闭')
            .margin({ top: 20 })
            .onClick(() => {
              this.isShow = false
            })
        }
        .width(300)
        .padding(25)
        .backgroundColor('#FFFFFF')
        .borderRadius(16)
        .transition(
          TransitionEffect.opacity(0)
            .animation({
              duration: 400,
              delay: 100  // 延迟 100ms,等遮罩出现一半再出现
            })
        )
      }

      // 打开按钮
      Button('打开弹窗')
        .onClick(() => {
          this.isShow = true
        })
    }
    .width('100%')
    .height('100%')
  }
}

设计要点:

  1. 背景遮罩的动画快一些(300ms),先出现
  2. 内容卡片的动画稍慢(400ms),并且延迟 100ms 开始
  3. 这样的时间差能让用户感受到"先变暗,再弹出内容"的层次

4.2 位移动画的方向性

位移动画是表达"从哪来、到哪去"的最佳方式。不同的滑动方向传递着不同的语义。

4.2.1 各方向位移的语义
滑动方向 语义含义 适用场景
从左往右 返回、前进 侧滑菜单、返回上一级
从右往左 进入新页面 详情页、新功能入口
从上往下 下拉、展开 下拉菜单、通知面板
从下往上 上推、弹出 底部弹窗、操作面板
从中心向外 展开、放大 菜单展开、详情展开
4.2.2 底部滑入面板实战

底部滑入面板(Bottom Sheet)是移动端非常常见的交互模式。让我们用 transition 来实现一个:

@Entry
@Component
struct BottomSheetDemo {
  @State isShow: boolean = false

  build() {
    Stack() {
      Column() {
        Text('主内容区域')
          .fontSize(24)
          .margin({ bottom: 30 })

        Button('打开底部面板')
          .onClick(() => {
            this.isShow = true
          })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)

      // 遮罩层
      if (this.isShow) {
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('rgba(0, 0, 0, 0.4)')
          .onClick(() => {
            this.isShow = false
          })
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 300 })
          )
      }

      // 底部面板
      if (this.isShow) {
        Column() {
          // 顶部拖拽条
          Row()
            .width(40)
            .height(4)
            .backgroundColor('#DDD')
            .borderRadius(2)
            .margin({ top: 12, bottom: 16 })

          Text('底部操作面板')
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .margin({ bottom: 20 })

          // 操作项列表
          ForEach(['选项一', '选项二', '选项三', '选项四'], (item: string) => {
            Row() {
              Text(item)
                .fontSize(16)
                .layoutWeight(1)

              Text('>')
                .fontColor('#CCC')
            }
            .width('100%')
            .height(50)
            .padding({ left: 20, right: 20 })
            .borderRadius(8)
            .onClick(() => {
              this.isShow = false
            })
          })

          Button('取消')
            .width('90%')
            .height(44)
            .backgroundColor('#F5F5F5')
            .fontColor('#333')
            .margin({ top: 20, bottom: 20 })
            .onClick(() => {
              this.isShow = false
            })
        }
        .width('100%')
        .backgroundColor('#FFFFFF')
        .borderRadius({ topLeft: 20, topRight: 20 })
        .position({ left: 0, bottom: 0 })
        .transition(
          TransitionEffect.translate({ y: 400 })
            .animation({
              duration: 400,
              curve: Curve.EaseOut
            })
        )
      }
    }
    .width('100%')
    .height('100%')
  }
}

实现要点:

  1. 使用 Stack 布局,底部面板通过 position 固定在底部
  2. 遮罩层使用透明度过渡,先于面板出现
  3. 面板使用 translate({ y: 400 }) 从下方 400vp 处滑入
  4. 使用 Curve.EaseOut 曲线,让动画开始快结束慢,符合物理直觉

4.3 缩放动画的视觉张力

缩放动画具有很强的视觉冲击力,适用于强调重要元素或营造活泼的氛围。

4.3.1 不同缩放比例的效果
@Entry
@Component
struct ScaleVariantsDemo {
  @State isShow: boolean = true

  build() {
    Column({ space: 20 }) {
      Button('切换显示')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 20 })

      Row({ space: 15 }) {
        if (this.isShow) {
          Column() {
            Text('0.5倍')
              .fontSize(12)
              .fontColor('#FFF')
          }
          .width(70)
          .height(70)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#FF6B6B')
          .borderRadius(8)
          .transition(
            TransitionEffect.scale({ x: 0.5, y: 0.5 })
              .animation({ duration: 500 })
          )
        }

        if (this.isShow) {
          Column() {
            Text('0倍')
              .fontSize(12)
              .fontColor('#FFF')
          }
          .width(70)
          .height(70)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#4ECDC4')
          .borderRadius(8)
          .transition(
            TransitionEffect.scale({ x: 0, y: 0 })
              .animation({ duration: 500 })
          )
        }

        if (this.isShow) {
          Column() {
            Text('1.5倍')
              .fontSize(12)
              .fontColor('#FFF')
          }
          .width(70)
          .height(70)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#FFA726')
          .borderRadius(8)
          .transition(
            TransitionEffect.scale({ x: 1.5, y: 1.5 })
              .animation({ duration: 500 })
          )
        }
      }
    }
    .width('100%')
    .padding(30)
    .justifyContent(FlexAlign.Center)
  }
}

效果说明:

  • 0.5倍:从一半大小放大到正常大小,感觉比较柔和
  • 0倍:从无到有放大,视觉冲击力最强
  • 1.5倍:从 1.5 倍缩小到正常大小,有"弹回来"的感觉
4.3.2 缩放中心点的影响

默认情况下,缩放是以组件的中心点为基准的。但我们可以通过 centerXcenterY 来改变缩放的中心点,从而实现不同的效果。

@Entry
@Component
struct ScaleCenterDemo {
  @State isShow: boolean = true

  build() {
    Column({ space: 20 }) {
      Button('切换显示')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 20 })

      Row({ space: 15 }) {
        if (this.isShow) {
          Column() {
            Text('左上角')
              .fontSize(11)
              .fontColor('#FFF')
          }
          .width(80)
          .height(80)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#E91E63')
          .borderRadius(8)
          .transition(
            TransitionEffect.scale({
              x: 0,
              y: 0,
              centerX: 0,
              centerY: 0
            })
            .animation({ duration: 600 })
          )
        }

        if (this.isShow) {
          Column() {
            Text('中心点')
              .fontSize(11)
              .fontColor('#FFF')
          }
          .width(80)
          .height(80)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#9C27B0')
          .borderRadius(8)
          .transition(
            TransitionEffect.scale({
              x: 0,
              y: 0
            })
            .animation({ duration: 600 })
          )
        }

        if (this.isShow) {
          Column() {
            Text('右下角')
              .fontSize(11)
              .fontColor('#FFF')
          }
          .width(80)
          .height(80)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#3F51B5')
          .borderRadius(8)
          .transition(
            TransitionEffect.scale({
              x: 0,
              y: 0,
              centerX: 80,
              centerY: 80
            })
            .animation({ duration: 600 })
          )
        }
      }
    }
    .width('100%')
    .padding(30)
    .justifyContent(FlexAlign.Center)
  }
}

4.4 旋转动画的巧思

旋转动画在合适的场景下能带来意想不到的惊喜效果。

4.4.1 翻转卡片效果
@Entry
@Component
struct FlipCardDemo {
  @State isFront: boolean = true

  build() {
    Column() {
      Stack() {
        if (this.isFront) {
          Column() {
            Text('正面')
              .fontSize(32)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFF')
          }
          .width(200)
          .height(280)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#FF6B6B')
          .borderRadius(16)
          .transition(
            TransitionEffect.rotate({
              angle: 180,
              y: 1  // 绕 Y 轴旋转
            })
            .animation({
              duration: 800,
              curve: Curve.EaseInOut
            })
          )
        }

        if (!this.isFront) {
          Column() {
            Text('背面')
              .fontSize(32)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFF')
          }
          .width(200)
          .height(280)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#4ECDC4')
          .borderRadius(16)
          .transition(
            TransitionEffect.rotate({
              angle: -180,
              y: 1
            })
            .animation({
              duration: 800,
              curve: Curve.EaseInOut
            })
          )
        }
      }
      .width(200)
      .height(280)
      .margin({ bottom: 30 })

      Button('翻转卡片')
        .onClick(() => {
          this.isFront = !this.isFront
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

组合过渡效果实战

单一的过渡效果往往难以满足复杂的设计需求,而通过 combine 方法组合多种效果,可以创造出丰富多样的过渡动画。

5.1 组合效果的基本原理

combine 方法的作用是将两个 TransitionEffect 组合在一起,让它们同时生效。组合后的效果是各个效果的叠加。

效果A + 效果B = 同时执行 A 和 B

例如:

  • opacity(0) + translate({x: -100}) = 一边淡入一边从左侧滑入
  • scale(0) + rotate({angle: 180}) = 一边放大一边旋转
  • opacity(0) + scale(0) + translate({y: 50}) = 淡入 + 放大 + 上滑

5.2 经典组合效果一:淡入 + 上移

这是最常见的组合效果之一,元素在淡入的同时向上移动一小段距离,给人"浮现"的感觉。

@Entry
@Component
struct FadeInUpDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('切换效果')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 40 })

      if (this.isShow) {
        Column() {
          Text('淡入上移')
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFF')

          Text('Fade In Up')
            .fontSize(14)
            .fontColor('#FFF')
            .margin({ top: 8 })
        }
        .width(200)
        .height(150)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#667EEA')
        .borderRadius(16)
        .transition(
          TransitionEffect.opacity(0)
            .combine(TransitionEffect.translate({ y: 30 }))
            .animation({
              duration: 600,
              curve: Curve.EaseOut
            })
        )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

设计要点:

  • 透明度从 0 到 1,同时 Y 轴位移从 30 到 0
  • 使用 EaseOut 曲线,开始快结束慢,显得轻盈
  • 位移量不宜过大,30vp 左右的轻微移动最优雅

5.3 经典组合效果二:缩放 + 弹性

给缩放动画加上弹性曲线,可以营造出活泼俏皮的感觉,非常适合用于互动性强的场景。

@Entry
@Component
struct BounceScaleDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('弹出效果')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 40 })

      if (this.isShow) {
        Column() {
          Text('🎉')
            .fontSize(60)

          Text('恭喜你!')
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333')
            .margin({ top: 10 })

          Text('操作成功完成')
            .fontSize(14)
            .fontColor('#666')
            .margin({ top: 6 })
        }
        .width(250)
        .padding(30)
        .backgroundColor('#FFF')
        .borderRadius(20)
        .shadow({
          radius: 20,
          color: '#40000000',
          offsetY: 10
        })
        .transition(
          TransitionEffect.opacity(0)
            .combine(TransitionEffect.scale({ x: 0.5, y: 0.5 }))
            .animation({
              duration: 800,
              curve: Curve.EaseOutBack  // 弹性曲线
            })
        )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#F5F5F5')
  }
}

效果说明:

  • 使用 EaseOutBack 曲线,动画会先超过目标值再弹回来
  • 配合缩放效果,就像一个气球被吹起来然后微微回弹
  • 适用于成功提示、奖励发放等正向反馈场景

5.4 经典组合效果三:滑动 + 透明度 + 缩放

三种效果组合在一起,可以营造出更有空间感的过渡效果。

@Entry
@Component
struct SlideScaleFadeDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('组合效果')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 40 })

      if (this.isShow) {
        Column() {
          Text('组合效果')
            .fontSize(28)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFF')

          Text('Slide + Scale + Opacity')
            .fontSize(14)
            .fontColor('#FFF')
            .opacity(0.8)
            .margin({ top: 8 })
        }
        .width(260)
        .height(180)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#4ECDC4')
        .borderRadius(20)
        .transition(
          TransitionEffect.opacity(0)
            .combine(TransitionEffect.translate({ x: -120, y: 60 }))
            .combine(TransitionEffect.scale({ x: 0.4, y: 0.4 }))
            .animation({
              duration: 900,
              curve: Curve.EaseInOut
            })
        )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#F0F2F5')
  }
}

效果分析:

  • 入场:从左下方(-120, 60)的位置,以 0.4 倍大小、完全透明的状态,滑入到中心位置,同时放大到 1 倍、渐变为完全不透明
  • 三种效果同时进行,营造出元素从远处"飞"过来的空间感

5.5 组合效果的设计原则

在设计组合过渡效果时,有几个原则需要遵循:

第一,简洁性原则。不要为了炫技而堆砌效果。一个好的过渡动画应该自然流畅,用户甚至感觉不到它的存在,但又会潜意识地觉得"这个应用很流畅"。通常组合 2-3 种效果就足够了。

第二,一致性原则。同一个应用内的过渡动画风格应该保持一致。如果页面 A 用的是淡入上移,页面 B 就不应该突然来个旋转缩放。统一的动画语言能提升应用的专业感。

第三,目的性原则。每一个动画效果都应该有它的目的。透明度用于表达层级,位移用于表达来源,缩放用于表达重要性。不要使用没有目的的动画。

第四,适度性原则。动画时长要适中,位移距离要合理。太长太慢会让用户觉得拖沓,太短太快又会让人看不清效果。

5.6 多元素组合过渡

在实际开发中,经常需要让多个元素同时进行过渡动画,并且它们之间需要有时间差,形成错落有致的效果。

@Entry
@Component
struct MultiElementDemo {
  @State isShow: boolean = true

  build() {
    Column() {
      Button('显示/隐藏全部')
        .onClick(() => {
          this.isShow = !this.isShow
        })
        .margin({ bottom: 40 })

      if (this.isShow) {
        Text('标题文字')
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333')
          .transition(
            TransitionEffect.opacity(0)
              .combine(TransitionEffect.translate({ y: -20 }))
              .animation({
                duration: 500,
                curve: Curve.EaseOut
              })
          )
      }

      if (this.isShow) {
        Text('这是一段描述文字,用于说明标题的内容。它会比标题晚一点出现。')
          .fontSize(16)
          .fontColor('#666')
          .margin({ top: 16, bottom: 24 })
          .transition(
            TransitionEffect.opacity(0)
              .combine(TransitionEffect.translate({ y: -20 }))
              .animation({
                duration: 500,
                curve: Curve.EaseOut,
                delay: 100  // 延迟 100ms
              })
          )
      }

      if (this.isShow) {
        Row({ space: 12 }) {
          Button('按钮一')
            .layoutWeight(1)
            .transition(
              TransitionEffect.opacity(0)
                .combine(TransitionEffect.translate({ y: 20 }))
                .animation({
                  duration: 500,
                  curve: Curve.EaseOut,
                  delay: 200  // 延迟 200ms
                })
            )

          Button('按钮二')
            .layoutWeight(1)
            .backgroundColor('#FFF')
            .fontColor('#36D')
            .transition(
              TransitionEffect.opacity(0)
                .combine(TransitionEffect.translate({ y: 20 }))
                .animation({
                  duration: 500,
                  curve: Curve.EaseOut,
                  delay: 300  // 延迟 300ms
                })
            )
        }
        .width('100%')
      }
    }
    .width('80%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

设计要点:

  1. 标题先出现(0ms 延迟)
  2. 描述文字其次(100ms 延迟)
  3. 两个按钮最后出现(200ms 和 300ms 延迟)
  4. 所有元素的动画时长和曲线保持一致
  5. 这样形成了从上到下、依次出现的视觉节奏

入场与离场动画的分别控制

在很多场景下,我们希望组件的入场动画和离场动画是不同的。比如,一个弹窗从底部滑入,但退出时是向右侧滑出。API 24 支持分别设置入场和离场动画。

6.1 为什么需要分别控制

入场和离场动画承担着不同的语义:

  • 入场动画:吸引注意力,告知用户新内容的来源
  • 离场动画:引导视线离开,告知用户内容去了哪里

让入场和离场使用不同的动画效果,可以让界面交互更加生动有趣。

6.2 分别设置入场和离场的方法

在 API 24 中,可以通过给 transition 传入两个 TransitionEffect 来分别控制入场和离场:

.transition(
  // 入场动画
  TransitionEffect.opacity(0)
    .combine(TransitionEffect.translate({ x: -100 }))
    .animation({ duration: 500 }),
  // 离场动画
  TransitionEffect.opacity(0)
    .combine(TransitionEffect.translate({ x: 100 }))
    .animation({ duration: 300 })
)

或者使用 TransitionEffect 的组合方式,第一个参数是入场,第二个是离场。

6.3 实战案例:左右滑入滑出

@Entry
@Component
struct InOutDifferentDemo {
  @State isShow: boolean = true
  @State currentText: string = '内容 A'

  switchContent() {
    this.isShow = false
    setTimeout(() => {
      this.currentText = this.currentText === '内容 A' ? '内容 B' : '内容 A'
      this.isShow = true
    }, 300)
  }

  build() {
    Column() {
      Button('切换内容')
        .onClick(() => {
          this.switchContent()
        })
        .margin({ bottom: 40 })

      if (this.isShow) {
        Column() {
          Text(this.currentText)
            .fontSize(32)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFF')
        }
        .width(250)
        .height(180)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#667EEA')
        .borderRadius(16)
        .transition(
          // 入场:从左侧滑入
          TransitionEffect.opacity(0)
            .combine(TransitionEffect.translate({ x: -150 }))
            .animation({
              duration: 500,
              curve: Curve.EaseOut
            }),
          // 离场:向右侧滑出
          TransitionEffect.opacity(0)
            .combine(TransitionEffect.translate({ x: 150 }))
            .animation({
              duration: 300,
              curve: Curve.EaseIn
            })
        )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

效果说明:

  • 入场:从左侧滑入,500ms,慢出曲线(优雅地出现)
  • 离场:向右侧滑出,300ms,快入曲线(快速地离开)
  • 离场比入场快,符合用户心理:用户已经看过内容了,离开不需要那么慢

6.4 实战案例:卡片展开与收起

@Entry
@Component
struct ExpandCollapseDemo {
  @State isExpanded: boolean = false

  build() {
    Column() {
      Text('卡片展开收起动画')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 30 })

      Column() {
        // 卡片头部(始终显示)
        Row() {
          Text('可展开卡片')
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .layoutWeight(1)

          Text(this.isExpanded ? '收起' : '展开')
            .fontSize(14)
            .fontColor('#36D')
        }
        .width('100%')
        .onClick(() => {
          this.isExpanded = !this.isExpanded
        })

        // 展开内容(条件渲染,有过渡动画)
        if (this.isExpanded) {
          Column() {
            Text('这是展开后的详细内容。')
              .fontSize(15)
              .fontColor('#666')
              .width('100%')
              .margin({ top: 12 })

            Text('点击卡片头部可以展开或收起内容。')
              .fontSize(15)
              .fontColor('#666')
              .width('100%')
              .margin({ top: 8 })

            Row({ space: 10 }) {
              Button('操作一')
                .layoutWeight(1)
                .height(36)
                .fontSize(13)

              Button('操作二')
                .layoutWeight(1)
                .height(36)
                .fontSize(13)
                .backgroundColor('#FFF')
                .fontColor('#36D')
            }
            .width('100%')
            .margin({ top: 16 })
          }
          .width('100%')
          .transition(
            // 入场:从上往下展开,同时淡入
            TransitionEffect.opacity(0)
              .combine(TransitionEffect.translate({ y: -20 }))
              .animation({
                duration: 400,
                curve: Curve.EaseOut
              }),
            // 离场:向上收起,同时淡出
            TransitionEffect.opacity(0)
              .combine(TransitionEffect.translate({ y: -20 }))
              .animation({
                duration: 250,
                curve: Curve.EaseIn
              })
          )
        }
      }
      .width('85%')
      .padding(20)
      .backgroundColor('#FFF')
      .borderRadius(16)
      .shadow({
        radius: 10,
        color: '#1A000000',
        offsetY: 2
      })
    }
    .width('100%')
    .height('100%')
    .padding({ top: 50 })
    .backgroundColor('#F5F7FA')
    .alignItems(HorizontalAlign.Center)
  }
}

6.5 入场离场不对称设计的技巧

在设计入场和离场动画时,可以参考以下技巧:

时长不对称:通常入场动画比离场动画慢一些。

  • 入场:用户需要看到内容是怎么来的,需要时间感知
  • 离场:用户已经看完了,快速离开即可,不要拖沓

推荐比例:入场时长 : 离场时长 ≈ 2 : 1 或 3 : 2

曲线不对称

  • 入场常用 EaseOut(慢出):开始快,结束慢,元素"滑入"并"稳稳停住"
  • 离场常用 EaseIn(快入):开始慢,结束快,元素"加速离开"

位移不对称

  • 入场位移可以稍大,让用户清楚地感知来源方向
  • 离场位移可以稍小,快速消失即可

动画曲线与缓动函数

动画曲线(Animation Curve)决定了动画在执行过程中的速度变化规律。同样的位移和时长,使用不同的曲线会给人完全不同的感受。

7.1 为什么需要动画曲线

如果没有动画曲线(匀速运动),动画会显得非常机械和僵硬。而现实世界中的物体运动都是有加速度变化的:汽车启动时慢慢加速,停车时慢慢减速;球掉地上会弹几下。

好的动画曲线应该模拟真实世界的物理规律,让动画看起来自然、舒服。

7.2 Curve 枚举值详解

ArkUI 提供了丰富的预设动画曲线,定义在 Curve 枚举中:

曲线 说明 特点
Linear 线性 匀速运动,机械感
Ease 缓动 开始慢,中间快,结束慢
EaseIn 缓入 开始慢,越来越快
EaseOut 缓出 开始快,越来越慢
EaseInOut 缓入缓出 开始和结束都慢,中间快
FastOutSlowIn 快出慢入 类似 EaseInOut,更现代
LinearOutSlowIn 线性出慢入 开始线性,结束变慢
FastOutLinearIn 快出线性入 开始快,结束线性
EaseOutBack 回弹 超过目标再弹回
EaseInBack 反向后退 先退后再加速前进
EaseInOutBack 前后都退 开始和结束都有回弹
Bounce 弹跳 像球落地一样弹跳
Spring 弹簧 类似弹簧的震荡效果

7.3 常用曲线对比示例

@Entry
@Component
struct CurveComparisonDemo {
  @State isAnimating: boolean = false

  build() {
    Column() {
      Button('播放动画')
        .onClick(() => {
          this.isAnimating = false
          setTimeout(() => {
            this.isAnimating = true
          }, 100)
        })
        .margin({ bottom: 30 })

      // Linear
      Row() {
        Text('Linear')
          .width(80)
          .fontSize(12)
          .fontColor('#666')

        if (this.isAnimating) {
          Column()
            .width(30)
            .height(30)
            .borderRadius(15)
            .backgroundColor('#FF6B6B')
            .transition(
              TransitionEffect.translate({ x: 200 })
                .animation({
                  duration: 1500,
                  curve: Curve.Linear
                })
            )
        }
      }
      .width('100%')
      .height(40)
      .alignItems(VerticalAlign.Center)

      // EaseIn
      Row() {
        Text('EaseIn')
          .width(80)
          .fontSize(12)
          .fontColor('#666')

        if (this.isAnimating) {
          Column()
            .width(30)
            .height(30)
            .borderRadius(15)
            .backgroundColor('#FFA726')
            .transition(
              TransitionEffect.translate({ x: 200 })
                .animation({
                  duration: 1500,
                  curve: Curve.EaseIn
                })
            )
        }
      }
      .width('100%')
      .height(40)
      .alignItems(VerticalAlign.Center)

      // EaseOut
      Row() {
        Text('EaseOut')
          .width(80)
          .fontSize(12)
          .fontColor('#666')

        if (this.isAnimating) {
          Column()
            .width(30)
            .height(30)
            .borderRadius(15)
            .backgroundColor('#4ECDC4')
            .transition(
              TransitionEffect.translate({ x: 200 })
                .animation({
                  duration: 1500,
                  curve: Curve.EaseOut
                })
            )
        }
      }
      .width('100%')
      .height(40)
      .alignItems(VerticalAlign.Center)

      // EaseInOut
      Row() {
        Text('EaseInOut')
          .width(80)
          .fontSize(12)
          .fontColor('#666')

        if (this.isAnimating) {
          Column()
            .width(30)
            .height(30)
            .borderRadius(15)
            .backgroundColor('#667EEA')
            .transition(
              TransitionEffect.translate({ x: 200 })
                .animation({
                  duration: 1500,
                  curve: Curve.EaseInOut
                })
            )
        }
      }
      .width('100%')
      .height(40)
      .alignItems(VerticalAlign.Center)

      // EaseOutBack
      Row() {
        Text('EaseOutBack')
          .width(80)
          .fontSize(12)
          .fontColor('#666')

        if (this.isAnimating) {
          Column()
            .width(30)
            .height(30)
            .borderRadius(15)
            .backgroundColor('#9C27B0')
            .transition(
              TransitionEffect.translate({ x: 200 })
                .animation({
                  duration: 1500,
                  curve: Curve.EaseOutBack
                })
            )
        }
      }
      .width('100%')
      .height(40)
      .alignItems(VerticalAlign.Center)

      // Bounce
      Row() {
        Text('Bounce')
          .width(80)
          .fontSize(12)
          .fontColor('#666')

        if (this.isAnimating) {
          Column()
            .width(30)
            .height(30)
            .borderRadius(15)
            .backgroundColor('#00BCD4')
            .transition(
              TransitionEffect.translate({ x: 200 })
                .animation({
                  duration: 1500,
                  curve: Curve.Bounce
                })
            )
        }
      }
      .width('100%')
      .height(40)
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .height('100%')
    .padding({ left: 20, top: 40 })
  }
}

7.4 曲线选择指南

不同的场景适合使用不同的动画曲线:

EaseOut(缓出) - 最常用的入场曲线

  • 适用:元素进入屏幕、弹窗出现、内容展开
  • 感受:快速出现,然后缓缓停稳,给人轻盈优雅的感觉

EaseIn(缓入) - 最常用的离场曲线

  • 适用:元素离开屏幕、弹窗关闭、内容收起
  • 感受:慢慢加速,快速离开,不拖沓

EaseInOut(缓入缓出) - 对称的动画

  • 适用:状态切换、循环动画、强调性动画
  • 感受:平滑自然,没有突兀感

EaseOutBack(回弹) - 有张力的入场

  • 适用:按钮、重要提示、互动性强的元素
  • 感受:活泼有趣,有弹性,能吸引注意力

Bounce(弹跳) - 卡通风格

  • 适用:游戏类应用、儿童应用、趣味性场景
  • 感受:活泼俏皮,充满活力

Linear(线性) - 谨慎使用

  • 适用:进度条加载、颜色渐变、旋转动画
  • 感受:机械、匀速,一般不用于位移动画

7.5 自定义贝塞尔曲线

除了预设曲线,ArkUI 还支持使用贝塞尔曲线自定义动画曲线:

// 使用 cubic-bezier 自定义曲线
.animation({
  duration: 500,
  curve: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)'
})

贝塞尔曲线由四个控制点 P0(0,0)、P1(x1,y1)、P2(x2,y2)、P3(1,1) 定义,其中 P0 和 P3 是固定的,我们通过调整 P1 和 P2 来改变曲线形状。

常用的自定义曲线:

// 更强的回弹效果
cubic-bezier(0.68, -0.55, 0.265, 1.55)

// 非常柔和的缓动
cubic-bezier(0.445, 0.05, 0.55, 0.95)

// 快速的加速
cubic-bezier(0.55, 0.055, 0.675, 0.19)

// 慢速的减速
cubic-bezier(0.215, 0.61, 0.355, 1)

列表场景的交错过渡动画

列表是移动应用中最常见的布局形式之一。给列表项添加上交错过渡动画,能极大地提升列表的视觉吸引力。

8.1 什么是交错过渡

交错过渡(Staggered Transition)指的是列表中的每一项不是同时出现,而是依次有延迟地出现,形成"依次入场"的视觉效果。

这种效果的原理很简单:给每个列表项设置不同的 delay 值,第一项延迟 0ms,第二项延迟 100ms,第三项延迟 200ms,以此类推。

8.2 基础交错列表实现

@Entry
@Component
struct StaggeredListDemo {
  @State itemCount: number = 0

  aboutToAppear() {
    // 页面加载后 300ms 开始显示列表
    setTimeout(() => {
      this.itemCount = 8
    }, 300)
  }

  build() {
    Column() {
      Text('交错过渡列表')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 40, bottom: 20 })

      Button('重新播放')
        .onClick(() => {
          this.itemCount = 0
          setTimeout(() => {
            this.itemCount = 8
          }, 300)
        })
        .margin({ bottom: 20 })

      Scroll() {
        Column({ space: 10 }) {
          ForEach(Array.from({ length: this.itemCount }, (_, i) => i), (index: number) => {
            if (index < this.itemCount) {
              Row() {
                Text(`${index + 1}`)
                  .width(36)
                  .height(36)
                  .textAlign(TextAlign.Center)
                  .fontSize(14)
                  .fontColor('#FFF')
                  .backgroundColor(this.getColor(index))
                  .borderRadius(18)

                Column() {
                  Text(`列表项 ${index + 1}`)
                    .fontSize(16)
                    .fontColor('#333')
                    .fontWeight(FontWeight.Medium)

                  Text('这是列表项的描述文字')
                    .fontSize(12)
                    .fontColor('#999')
                    .margin({ top: 4 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .margin({ left: 12 })

                Text('>')
                  .fontColor('#CCC')
                  .fontSize(16)
              }
              .width('100%')
              .padding(16)
              .backgroundColor('#FFF')
              .borderRadius(12)
              .transition(
                TransitionEffect.opacity(0)
                  .combine(TransitionEffect.translate({ x: 60 }))
                  .animation({
                    duration: 500,
                    curve: Curve.EaseOut,
                    delay: index * 80  // 每项延迟 80ms
                  })
              )
            }
          }, (index: number) => index.toString())
        }
        .width('100%')
        .padding({ left: 16, right: 16 })
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FA')
  }

  private getColor(index: number): string {
    const colors = [
      '#FF6B6B', '#4ECDC4', '#FFA726', '#667EEA',
      '#9C27B0', '#00BCD4', '#8BC34A', '#FF5722'
    ]
    return colors[index % colors.length]
  }
}

实现要点:

  1. 使用 ForEach 遍历列表数据
  2. 每个列表项都用 if 包裹,确保条件变化时触发 transition
  3. delay: index * 80 让每项依次延迟 80ms 出现
  4. 配合 translate({ x: 60 }) 从右侧滑入,形成"鱼贯而入"的效果

8.3 交错时间间隔的选择

交错时间间隔(stagger delay)的选择非常重要:

间隔时长 效果感受 适用场景
30~50ms 非常紧凑,几乎是同时出现 大量数据的列表、快速浏览
50~100ms 恰到好处,能感受到顺序 一般列表、卡片列表
100~150ms 明显的依次出现,节奏感强 重点展示的内容、引导页
200ms 以上 太慢,显得拖沓 特殊的仪式感场景

推荐值:80ms 是一个比较通用的选择,既有节奏感又不会太慢。

8.4 不同方向的交错效果

交错效果不一定是水平方向的,也可以有其他方向和形式:

8.4.1 从下往上的交错
.transition(
  TransitionEffect.opacity(0)
    .combine(TransitionEffect.translate({ y: 40 }))
    .animation({
      duration: 500,
      curve: Curve.EaseOut,
      delay: index * 60
    })
)
8.4.2 缩放交错
.transition(
  TransitionEffect.opacity(0)
    .combine(TransitionEffect.scale({ x: 0.8, y: 0.8 }))
    .animation({
      duration: 400,
      curve: Curve.EaseOutBack,
      delay: index * 50
    })
)
8.4.3 瀑布流交错

对于瀑布流布局,可以给不同列设置不同的延迟:

// 假设有两列,索引为偶数的在左列,奇数的在右列
const delay = (index % 2 === 0) ? index * 100 : index * 100 + 50

8.5 删除时的交错效果

不仅添加时有交错效果,删除时也可以有交错。不过删除时的交错方向通常是反的:

// 删除时,从后往前依次消失(需要额外处理索引)
.transition(
  // 入场:从前往后
  TransitionEffect.opacity(0)
    .combine(TransitionEffect.translate({ y: 20 }))
    .animation({
      duration: 400,
      curve: Curve.EaseOut,
      delay: index * 50
    }),
  // 离场:从后往前(用总数 - 当前索引来计算延迟)
  TransitionEffect.opacity(0)
    .combine(TransitionEffect.translate({ y: -20 }))
    .animation({
      duration: 300,
      curve: Curve.EaseIn,
      delay: (this.itemCount - 1 - index) * 30
    })
)

复杂布局中的过渡动画设计

在实际项目中,过渡动画往往不是孤立的,而是嵌入在复杂的布局结构中。本章探讨在各种常见布局场景中如何合理使用过渡动画。

9.1 导航栏过渡

导航栏是页面的重要组成部分,它的过渡效果直接影响页面切换的整体体验。

@Entry
@Component
struct NavigationTransitionDemo {
  @State page: number = 1

  build() {
    Column() {
      // 导航栏
      Column() {
        Row() {
          if (this.page > 1) {
            Text('< 返回')
              .fontSize(16)
              .fontColor('#36D')
              .onClick(() => {
                this.page--
              })
              .transition(
                TransitionEffect.opacity(0)
                  .combine(TransitionEffect.translate({ x: -20 }))
                  .animation({ duration: 300 })
              )
          }

          Text(this.page === 1 ? '首页' : `页面 ${this.page}`)
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)

          if (this.page < 3) {
            Text('下一步 >')
              .fontSize(16)
              .fontColor('#36D')
              .onClick(() => {
                this.page++
              })
              .transition(
                TransitionEffect.opacity(0)
                  .combine(TransitionEffect.translate({ x: 20 }))
                  .animation({ duration: 300 })
              )
          }
        }
        .width('100%')
        .height(44)
        .padding({ left: 16, right: 16 })
      }
      .width('100%')
      .backgroundColor('#FFF')
      .border({ width: { bottom: 1 }, color: '#EEE' })

      // 内容区
      Stack() {
        if (this.page === 1) {
          Column() {
            Text('首页内容')
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .margin({ bottom: 20 })

            Text('点击"下一步"进入第二页')
              .fontColor('#666')
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .transition(
            TransitionEffect.opacity(0)
              .combine(TransitionEffect.translate({ x: -100 }))
              .animation({ duration: 400 })
          )
        }

        if (this.page === 2) {
          Column() {
            Text('第二页内容')
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .margin({ bottom: 20 })

            Text('中间页面,可以前进或后退')
              .fontColor('#666')
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 300 })
          )
        }

        if (this.page === 3) {
          Column() {
            Text('最后一页')
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .margin({ bottom: 20 })

            Text('点击"返回"回到上一页')
              .fontColor('#666')
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .transition(
            TransitionEffect.opacity(0)
              .combine(TransitionEffect.translate({ x: 100 }))
              .animation({ duration: 400 })
          )
        }
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FA')
  }
}

9.2 标签页(Tabs)切换过渡

标签页切换也是非常常见的场景,配合 transition 可以让切换更加平滑。

@Entry
@Component
struct TabTransitionDemo {
  @State currentTab: number = 0
  private tabs: string[] = ['推荐', '热门', '最新', '关注']

  build() {
    Column() {
      // Tab 栏
      Row() {
        ForEach(this.tabs, (tab: string, index: number) => {
          Text(tab)
            .fontSize(16)
            .fontColor(this.currentTab === index ? '#36D' : '#666')
            .fontWeight(this.currentTab === index ? FontWeight.Medium : FontWeight.Normal)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .onClick(() => {
              this.currentTab = index
            })
        })
      }
      .width('100%')
      .backgroundColor('#FFF')

      // 指示器
      Row() {
        Column()
          .width('25%')
          .height(3)
          .backgroundColor('#36D')
          .borderRadius(1.5)
          .offset({ x: this.currentTab * 25 + '%' })
      }
      .width('100%')
      .padding({ left: 0 })

      // 内容区
      Stack() {
        ForEach(this.tabs, (tab: string, index: number) => {
          if (this.currentTab === index) {
            Column() {
              Text(`${tab} 内容`)
                .fontSize(28)
                .fontWeight(FontWeight.Bold)
                .margin({ bottom: 15 })

              ForEach([1, 2, 3, 4, 5], (item: number) => {
                Row() {
                  Text(`${tab} - 列表项 ${item}`)
                    .fontSize(16)
                    .layoutWeight(1)

                  Text('>')
                    .fontColor('#CCC')
                }
                .width('100%')
                .height(50)
                .padding({ left: 16, right: 16 })
                .backgroundColor('#FFF')
                .borderRadius(8)
                .margin({ bottom: 8 })
                .transition(
                  TransitionEffect.opacity(0)
                    .combine(TransitionEffect.translate({
                      x: this.currentTab > index ? -50 : 50
                    }))
                    .animation({
                      duration: 400,
                      curve: Curve.EaseOut,
                      delay: item * 50
                    })
                )
              })
            }
            .width('100%')
            .height('100%')
            .padding(16)
            .transition(
              TransitionEffect.opacity(0)
                .combine(TransitionEffect.translate({
                  x: this.currentTab > index ? -30 : 30
                }))
                .animation({
                  duration: 350,
                  curve: Curve.EaseOut
                })
            )
          }
        })
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FA')
  }
}

9.3 级联选择器过渡

级联选择器(如省市区选择)中,每一级选项的出现都可以配上过渡动画。

@Entry
@Component
struct CascadeTransitionDemo {
  @State level1Selected: number = -1
  @State level2Selected: number = -1

  private level1Data: string[] = ['电子产品', '服装鞋帽', '食品饮料', '家居用品']
  private level2Data: string[][] = [
    ['手机', '电脑', '平板', '耳机'],
    ['男装', '女装', '童装', '鞋靴'],
    ['零食', '饮料', '生鲜', '速食'],
    ['家具', '厨具', '床品', '收纳']
  ]

  build() {
    Column() {
      Text('级联选择')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .padding(16)
        .width('100%')
        .backgroundColor('#FFF')

      // 第一级
      Scroll() {
        Column({ space: 2 }) {
          ForEach(this.level1Data, (item: string, index: number) => {
            Text(item)
              .width('100%')
              .height(48)
              .padding({ left: 16 })
              .fontSize(15)
              .fontColor(this.level1Selected === index ? '#36D' : '#333')
              .backgroundColor(this.level1Selected === index ? '#E3F2FD' : '#FFF')
              .onClick(() => {
                this.level1Selected = index
                this.level2Selected = -1
              })
          })
        }
        .width('100%')
      }
      .width('40%')
      .backgroundColor('#FAFAFA')

      // 第二级
      if (this.level1Selected >= 0) {
        Scroll() {
          Column({ space: 2 }) {
            ForEach(this.level2Data[this.level1Selected], (item: string, index: number) => {
              Text(item)
                .width('100%')
                .height(48)
                .padding({ left: 16 })
                .fontSize(15)
                .fontColor(this.level2Selected === index ? '#36D' : '#333')
                .backgroundColor(this.level2Selected === index ? '#E3F2FD' : '#FFF')
                .onClick(() => {
                  this.level2Selected = index
                })
                .transition(
                  TransitionEffect.opacity(0)
                    .combine(TransitionEffect.translate({ x: 30 }))
                    .animation({
                      duration: 300,
                      curve: Curve.EaseOut,
                      delay: index * 30
                    })
                )
            })
          }
          .width('100%')
        }
        .layoutWeight(1)
        .transition(
          TransitionEffect.opacity(0)
            .animation({ duration: 200 })
        )
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FA')
  }
}

性能优化与最佳实践

过渡动画虽然能提升用户体验,但如果使用不当也可能导致性能问题。本章介绍 transition 动画的性能优化技巧和最佳实践。

10.1 影响动画性能的因素

过渡动画的性能主要受以下几个因素影响:

第一,动画属性的不同。 不同的 CSS/UI 属性对性能的影响差异很大。通常来说:

  • 高性能属性:opacity(透明度)、transform(变换,包括 translate、scale、rotate)
  • 低性能属性:widthheightpaddingmarginlefttop 等布局属性

好消息是,TransitionEffect 中的 opacitytranslatescalerotate 都属于高性能属性,它们可以直接由 GPU 处理,不会触发布局重排。

第二,同时动画的元素数量。 同时进行动画的元素越多,性能开销越大。特别是列表的交错过渡,虽然每项是依次出现的,但在重叠时间段内可能有大量元素同时在动画。

第三,动画区域的大小。 动画占据的屏幕区域越大,需要渲染的像素越多,性能开销也越大。全屏动画的开销远大于一个小按钮的动画。

10.2 性能优化建议

10.2.1 优先使用 transform 相关属性

尽量使用 translatescalerotate 等 transform 类属性,而不是通过改变 widthheightlefttop 来实现动画效果。

// ✅ 好的做法:使用 translate,性能好
.transition(
  TransitionEffect.translate({ x: 100 })
    .animation({ duration: 300 })
)

// ❌ 不好的做法:改变布局属性,性能差(transition 不支持,但要注意属性动画)
10.2.2 控制同时动画的元素数量

对于列表的交错过渡,要注意控制同时在动画的元素数量。如果列表有 100 项,每项延迟 50ms,动画时长 300ms,那么最多会有 6-7 个元素同时在动画,这是可以接受的。

但如果列表有 1000 项,每项延迟 10ms,那同时动画的元素就太多了。这种情况下,可以考虑:

  • 增加延迟时间,减少同时动画的元素数量
  • 只对视口内的元素应用过渡动画
  • 对超出一定数量的元素使用统一的动画,不再交错
10.2.3 避免过度动画

动画是为了提升体验,而不是炫技。每一个动画都应该有它存在的理由。

需要避免的情况:

  • 页面上所有元素都在动画,让人眼花缭乱
  • 动画时间太长,让用户等待
  • 动画效果太夸张,分散用户注意力

原则:动画应该是"无感"的,用户觉得流畅舒服,但不会特别注意到动画本身。

10.2.4 合理设置动画时长

不同类型的元素适合不同的动画时长:

元素类型 推荐时长 说明
小按钮、图标 150~250ms 快速灵敏
卡片、列表项 250~400ms 适中
弹窗、面板 300~500ms 稍慢,有仪式感
页面级切换 400~600ms 完整连贯
大型引导动画 600~1000ms 慢,有仪式感

10.3 过渡动画的设计原则

在设计过渡动画时,可以遵循以下几个原则:

10.3.1 真实性原则

好的动画应该模拟真实世界的物理规律。物体有惯性,启动和停止都需要时间,所以才有了缓入缓出曲线。物体有弹性,所以才有了弹簧曲线和回弹效果。

让动画符合用户对真实世界的认知,用户才会觉得自然、舒服。

10.3.2 反馈性原则

用户的每一个操作都应该得到及时的反馈。过渡动画是反馈的一种重要形式。

例如:

  • 点击按钮后,按钮有按压效果
  • 打开面板时,面板从底部滑出
  • 关闭弹窗时,弹窗缩小消失

这些动画都在告诉用户:“你的操作生效了”。

10.3.3 引导性原则

过渡动画可以引导用户的注意力,告诉用户什么发生了变化、新内容从哪里来、旧内容到哪里去了。

例如:

  • 新消息从顶部滑入,用户自然会抬头去看
  • 设置面板从右侧滑入,用户知道这是"更多选项"
  • 删除列表项时项向左滑出,用户理解"移除了"
10.3.4 一致性原则

整个应用的动画风格应该保持一致。比如:

  • 所有页面的进入方向一致
  • 所有弹窗的出现方式一致
  • 所有列表项的过渡效果一致

一致性不仅体现在视觉上,也体现在节奏和时长上。统一的动画语言能让用户形成使用习惯,提升应用的专业感。

10.4 无障碍考虑

动画虽然好看,但也要考虑无障碍需求。有些用户可能患有前庭功能障碍,动画可能会让他们感到不适。

建议:

  • 提供关闭动画的选项
  • 重要信息不要只通过动画传达
  • 避免闪烁和快速移动的动画
  • 尊重系统的"减少动画"设置

常见问题与解决方案

在使用 transition 过渡动画时,开发者经常会遇到一些问题。本章总结了一些常见问题及其解决方案。

11.1 过渡动画不生效

问题表现: 给组件设置了 transition,但组件出现/消失时没有动画效果,直接显示或隐藏。

可能的原因和解决方案:

原因 1:没有配合 if/else 使用
transition 动画只在组件被插入或删除时触发。如果只是改变组件的属性(比如 opacity),不会触发 transition。

// ❌ 错误:改变属性不会触发 transition
@State isVisible: boolean = true

Text('内容')
  .opacity(this.isVisible ? 1 : 0)
  .transition(...)

// ✅ 正确:使用 if 控制组件的插入/删除
if (this.isVisible) {
  Text('内容')
    .transition(...)
}

原因 2:transition 设置在了 if 的外层组件上
transition 必须设置在被条件渲染的组件上,而不是它的父组件。

// ❌ 错误:transition 设置在了外层 Column 上
Column() {
  if (isShow) {
    Text('内容')
  }
}
.transition(...)

// ✅ 正确:transition 设置在被条件渲染的组件上
if (isShow) {
  Text('内容')
    .transition(...)
}

原因 3:组件的 key 不稳定
如果使用 ForEach 渲染列表,要确保 key 是稳定且唯一的。如果 key 变化了,框架会认为是新组件替换了旧组件,而不是同一个组件在更新。

// ✅ 正确:使用稳定的 id 作为 key
ForEach(items, (item) => {
  Text(item.name)
    .transition(...)
}, (item) => item.id.toString())  // 使用 id 作为 key

11.2 动画卡顿、掉帧

问题表现: 过渡动画不够流畅,有明显的卡顿感或掉帧现象。

解决方案:

  1. 减少同时动画的元素数量:避免大量元素同时进行复杂动画
  2. 使用高性能属性:优先使用 opacity、translate、scale、rotate
  3. 简化动画效果:减少同时进行的效果数量
  4. 缩短动画时长:时长越长,掉帧越明显
  5. 测试低端设备:在性能较差的设备上测试,确保动画流畅

11.3 布局抖动

问题表现: 动画开始或结束时,布局发生突然的跳动或变化。

可能原因: 过渡动画执行期间,组件仍然占据布局空间。当组件被删除时,它的空间会被其他元素填充,可能导致布局跳动。

解决方案:

  1. 使用 Stack 布局:将动画元素放在 Stack 中,使其脱离文档流,不影响其他元素的布局
  2. 预留空间:给动画元素的父容器设置固定的高度或宽度,避免内容变化导致布局抖动
  3. 使用 position 定位:通过绝对定位固定元素位置

11.4 入场和离场动画弄反了

问题表现: 设置的入场和离场动画效果反了,或者只有一个方向的动画生效。

解决方案:
确保理解 TransitionEffect 的含义:TransitionEffect 定义的是"动画开始前的状态"(入场)或"动画结束后的状态"(离场)。

// 对于入场(Insert):
// 组件从 opacity(0) 的状态,逐渐过渡到正常状态(opacity: 1)
TransitionEffect.opacity(0)  // 初始状态

// 对于离场(Delete):
// 组件从正常状态(opacity: 1),逐渐过渡到 opacity(0) 的状态
TransitionEffect.opacity(0)  // 结束状态

11.5 组合动画不同步

问题表现: 使用 combine 组合了多个效果,但它们看起来不是同步的。

解决方案:
通常情况下,同一个 transition 中 combine 的多个效果是同步的。如果看起来不同步,可能是:

  1. 不同效果的感知不同:透明度变化和位移变化给人的速度感不一样。同样的时长,位移看起来会更快一些。
  2. 曲线选择不当:可以尝试调整动画曲线,让效果更协调。
  3. 尝试分别设置:如果需要更精细的控制,可以考虑拆分成多个组件,分别设置动画。

完整实战项目示例

本章通过一个完整的实战项目,综合运用前面学到的 transition 知识,实现一个功能完整、动画丰富的页面。

12.1 项目需求

我们要实现一个"任务管理"页面,包含以下功能:

  1. 顶部有标题栏和添加按钮
  2. 任务列表,支持添加和删除任务
  3. 任务有完成/未完成两种状态
  4. 添加任务时,新任务从底部滑入
  5. 删除任务时,任务向左滑出
  6. 空状态提示
  7. 任务分类筛选

12.2 完整实现代码

interface Task {
  id: number
  title: string
  completed: boolean
  category: 'work' | 'life' | 'study'
}

@Entry
@Component
struct TaskManagerDemo {
  @State tasks: Task[] = [
    { id: 1, title: '完成项目文档', completed: false, category: 'work' },
    { id: 2, title: '购买生活用品', completed: true, category: 'life' },
    { id: 3, title: '学习 ArkTS 动画', completed: false, category: 'study' },
  ]
  @State showAddDialog: boolean = false
  @State newTaskTitle: string = ''
  @State newTaskCategory: 'work' | 'life' | 'study' = 'work'
  @State currentFilter: 'all' | 'work' | 'life' | 'study' = 'all'
  private nextId: number = 4

  // 分类显示名
  private getCategoryName(cat: string): string {
    const map: Record<string, string> = {
      work: '工作',
      life: '生活',
      study: '学习'
    }
    return map[cat] || cat
  }

  // 分类颜色
  private getCategoryColor(cat: string): string {
    const map: Record<string, string> = {
      work: '#FF6B6B',
      life: '#4ECDC4',
      study: '#FFA726'
    }
    return map[cat] || '#999'
  }

  // 添加任务
  addTask() {
    if (!this.newTaskTitle.trim()) {
      return
    }
    this.tasks.push({
      id: this.nextId++,
      title: this.newTaskTitle.trim(),
      completed: false,
      category: this.newTaskCategory
    })
    this.newTaskTitle = ''
    this.showAddDialog = false
  }

  // 删除任务
  deleteTask(id: number) {
    const index = this.tasks.findIndex(t => t.id === id)
    if (index >= 0) {
      this.tasks.splice(index, 1)
    }
  }

  // 切换任务状态
  toggleTask(id: number) {
    const task = this.tasks.find(t => t.id === id)
    if (task) {
      task.completed = !task.completed
    }
  }

  // 筛选后的任务
  get filteredTasks(): Task[] {
    if (this.currentFilter === 'all') {
      return this.tasks
    }
    return this.tasks.filter(t => t.category === this.currentFilter)
  }

  build() {
    Stack() {
      Column() {
        // 顶部标题栏
        Row() {
          Column() {
            Text('我的任务')
              .fontSize(24)
              .fontWeight(FontWeight.Bold)

            Text(`${this.tasks.filter(t => !t.completed).length} 个待完成`)
              .fontSize(13)
              .fontColor('#999')
              .margin({ top: 2 })
          }
          .layoutWeight(1)

          Button() {
            Text('+')
              .fontSize(24)
              .fontColor('#FFF')
          }
          .width(44)
          .height(44)
          .borderRadius(22)
          .backgroundColor('#36D')
          .onClick(() => {
            this.showAddDialog = true
          })
        }
        .width('100%')
        .padding({ left: 20, right: 20, top: 50, bottom: 16 })
        .backgroundColor('#FFF')

        // 筛选标签
        Scroll() {
          Row({ space: 8 }) {
            ForEach(['all', 'work', 'life', 'study'], (filter: string) => {
              Text(filter === 'all' ? '全部' : this.getCategoryName(filter))
                .fontSize(14)
                .fontColor(this.currentFilter === filter ? '#FFF' : '#666')
                .padding({ left: 16, right: 16, top: 8, bottom: 8 })
                .backgroundColor(this.currentFilter === filter ? '#36D' : '#F0F0F0')
                .borderRadius(16)
                .onClick(() => {
                  this.currentFilter = filter as 'all' | 'work' | 'life' | 'study'
                })
            })
          }
          .padding({ left: 20, right: 20 })
        }
        .width('100%')
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .margin({ bottom: 8 })
        .padding({ bottom: 8 })

        // 任务列表
        Scroll() {
          Column({ space: 10 }) {
            // 空状态
            if (this.filteredTasks.length === 0) {
              Column() {
                Text('📝')
                  .fontSize(60)
                  .margin({ bottom: 16 })

                Text('暂无任务')
                  .fontSize(18)
                  .fontColor('#999')

                Text('点击右上角添加新任务')
                  .fontSize(14)
                  .fontColor('#CCC')
                  .margin({ top: 8 })
              }
              .width('100%')
              .height(300)
              .justifyContent(FlexAlign.Center)
              .transition(
                TransitionEffect.opacity(0)
                  .animation({ duration: 300 })
              )
            }

            // 任务列表
            ForEach(this.filteredTasks, (task: Task, index: number) => {
              if (this.tasks.some(t => t.id === task.id)) {
                Row() {
                  // 复选框
                  Text(task.completed ? '✓' : '')
                    .width(22)
                    .height(22)
                    .textAlign(TextAlign.Center)
                    .fontSize(14)
                    .fontColor('#FFF')
                    .backgroundColor(task.completed ? '#4CAF50' : '#FFF')
                    .border({
                      width: 2,
                      color: task.completed ? '#4CAF50' : '#DDD'
                    })
                    .borderRadius(11)
                    .onClick(() => {
                      this.toggleTask(task.id)
                    })

                  // 任务内容
                  Column() {
                    Text(task.title)
                      .fontSize(16)
                      .fontColor(task.completed ? '#999' : '#333')
                      .decoration({ type: task.completed ? TextDecorationType.LineThrough : TextDecorationType.None })
                      .layoutWeight(1)

                    Row() {
                      Text(this.getCategoryName(task.category))
                        .fontSize(11)
                        .fontColor('#FFF')
                        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                        .backgroundColor(this.getCategoryColor(task.category))
                        .borderRadius(4)
                    }
                    .margin({ top: 6 })
                  }
                  .layoutWeight(1)
                  .margin({ left: 12 })
                  .alignItems(HorizontalAlign.Start)

                  // 删除按钮
                  Text('删除')
                    .fontSize(13)
                    .fontColor('#FF6B6B')
                    .onClick(() => {
                      this.deleteTask(task.id)
                    })
                }
                .width('100%')
                .padding(16)
                .backgroundColor('#FFF')
                .borderRadius(12)
                .shadow({
                  radius: 4,
                  color: '#1A000000',
                  offsetY: 1
                })
                // 过渡动画
                .transition(
                  // 入场:从底部滑入 + 淡入
                  TransitionEffect.opacity(0)
                    .combine(TransitionEffect.translate({ y: 30 }))
                    .animation({
                      duration: 400,
                      curve: Curve.EaseOut
                    }),
                  // 离场:向左侧滑出 + 淡出
                  TransitionEffect.opacity(0)
                    .combine(TransitionEffect.translate({ x: -100 }))
                    .animation({
                      duration: 300,
                      curve: Curve.EaseIn
                    })
                )
              }
            }, (task: Task) => task.id.toString())
          }
          .width('100%')
          .padding({ left: 16, right: 16, bottom: 30 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F5F7FA')

      // 添加任务弹窗遮罩
      if (this.showAddDialog) {
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('rgba(0, 0, 0, 0.5)')
          .onClick(() => {
            this.showAddDialog = false
          })
          .transition(
            TransitionEffect.opacity(0)
              .animation({ duration: 250 })
          )
      }

      // 添加任务弹窗
      if (this.showAddDialog) {
        Column() {
          Text('添加新任务')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .margin({ bottom: 20 })

          TextInput({ placeholder: '请输入任务内容' })
            .width('100%')
            .height(44)
            .fontSize(15)
            .backgroundColor('#F5F5F5')
            .borderRadius(8)
            .margin({ bottom: 16 })
            .onChange((value: string) => {
              this.newTaskTitle = value
            })

          Text('选择分类')
            .fontSize(14)
            .fontColor('#666')
            .width('100%')
            .margin({ bottom: 8 })

          Row({ space: 10 }) {
            ForEach(['work', 'life', 'study'], (cat: string) => {
              Text(this.getCategoryName(cat))
                .fontSize(14)
                .fontColor(this.newTaskCategory === cat ? '#FFF' : '#666')
                .layoutWeight(1)
                .height(36)
                .textAlign(TextAlign.Center)
                .backgroundColor(this.newTaskCategory === cat ? this.getCategoryColor(cat) : '#F0F0F0')
                .borderRadius(8)
                .onClick(() => {
                  this.newTaskCategory = cat as 'work' | 'life' | 'study'
                })
            })
          }
          .width('100%')
          .margin({ bottom: 24 })

          Row({ space: 12 }) {
            Button('取消')
              .layoutWeight(1)
              .height(44)
              .backgroundColor('#F0F0F0')
              .fontColor('#666')
              .onClick(() => {
                this.showAddDialog = false
              })

            Button('添加')
              .layoutWeight(1)
              .height(44)
              .backgroundColor('#36D')
              .onClick(() => {
                this.addTask()
              })
          }
          .width('100%')
        }
        .width('85%')
        .padding(24)
        .backgroundColor('#FFF')
        .borderRadius(20)
        .position({ left: '7.5%', top: '25%' })
        .transition(
          TransitionEffect.opacity(0)
            .combine(TransitionEffect.scale({ x: 0.9, y: 0.9 }))
            .animation({
              duration: 350,
              curve: Curve.EaseOutBack
            })
        )
      }
    }
    .width('100%')
    .height('100%')
  }
}

12.3 项目中的过渡动画总结

在这个任务管理应用中,我们使用了多种过渡动画:

场景 动画效果 时长 曲线
任务添加 从底部滑入 + 淡入 400ms EaseOut
任务删除 向左滑出 + 淡出 300ms EaseIn
空状态 淡入淡出 300ms 默认
弹窗遮罩 淡入淡出 250ms 默认
弹窗内容 缩放 + 淡入 + 弹性 350ms EaseOutBack

这些动画组合在一起,形成了统一、流畅、有层次感的用户体验。


总结与展望

13.1 全文总结

本文全面介绍了 HarmonyOS NEXT API 24 中 transition 过渡动画的使用方法。我们从基础概念入手,逐步深入到高级用法和实战应用。

核心知识点回顾:

  1. TransitionEffect 是 API 24 的新 API,采用链式调用方式,类型安全且灵活可组合
  2. 基础过渡效果包括:opacity(透明度)、translate(位移)、scale(缩放)、rotate(旋转)、move(边缘移动)
  3. combine 方法用于组合多种效果,实现丰富的动画
  4. animation 方法用于配置动画参数:duration、curve、delay 等
  5. 入场和离场可以分别设置不同的动画效果
  6. 交错动画通过 delay 实现,适用于列表等场景
  7. 动画曲线的选择对体验影响很大,不同场景适合不同曲线

13.2 学习路径建议

如果你是刚接触 transition 动画的新手,建议按照以下路径学习:

第一阶段:基础入门

  • 先从 opacity 开始,理解 transition 的基本概念
  • 然后学习 translate,掌握位移动画
  • 再学习 scale 和 rotate,丰富动画效果
  • 最后学习 combine,组合多种效果

第二阶段:进阶提升

  • 学习动画曲线,理解不同曲线的适用场景
  • 掌握入场和离场的分别设置
  • 实践列表交错过渡
  • 尝试更复杂的组合动画

第三阶段:实战应用

  • 在实际项目中运用过渡动画
  • 关注动画性能,学习优化技巧
  • 形成自己的动画设计风格
  • 探索创新的动画效果

13.3 未来展望

随着 HarmonyOS 的不断发展,动画系统也会越来越完善。未来我们可能会看到:

  • 更丰富的预设 TransitionEffect
  • 更灵活的动画控制能力
  • 更好的性能优化工具
  • 更便捷的动画调试工具
  • 更多的物理模拟动画效果

但无论技术如何发展,动画的核心原则是不变的:服务于用户体验,自然、流畅、有意义

13.4 写在最后

过渡动画是一门需要用心打磨的艺术。它不只是代码的堆砌,更是对用户体验的深度思考。一个好的过渡动画,用户可能不会特别注意到它,但会潜意识地觉得"这个应用很流畅、很舒服"。这正是动画的最高境界——润物细无声。

希望本文能帮助你掌握 transition 过渡动画的使用方法,在你的鸿蒙应用开发中创造出更优美、更流畅的动画效果。让我们一起,用代码创造美好体验。


参考文献:

  • HarmonyOS NEXT API 24 官方文档
  • ArkUI 开发指南
  • Material Design 动效设计指南
  • iOS Human Interface Guidelines - Animation

本文示例代码均基于 HarmonyOS NEXT API 24 编写,可直接在 DevEco Studio 中运行。

Logo

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

更多推荐