ArkTS 动画系统深度实战:属性动画、显式动画与页面转场全解析

适用版本:HarmonyOS NEXT / API 12+
难度:中级
阅读时间:约 15 分钟

动画是应用体验的核心竞争力之一。很多开发者在鸿蒙开发中卡在「动画为什么不生效」「转场效果闪烁」等问题上。本文从底层原理出发,系统梳理 ArkTS 动画三大核心体系,附带真实业务场景代码,让你彻底搞清楚"该用哪种动画 API"。


一、ArkTS 动画体系全景

ArkTS 的动画系统分为以下三层:

┌─────────────────────────────────────────────┐
│            页面级动画(转场)                  │
│  pageTransition()  / NavigationTransition   │
├─────────────────────────────────────────────┤
│            组件级动画                         │
│  animateTo()(显式动画)                      │
├─────────────────────────────────────────────┤
│            属性级动画                         │
│  .animation()(属性动画)                     │
└─────────────────────────────────────────────┘

核心区别

类型 触发方式 适用场景 精确度
属性动画 .animation() 状态变化自动触发 简单状态切换
显式动画 animateTo() 手动调用闭包 复杂联动动画
页面转场 pageTransition() 路由跳转自动触发 页面切换效果

二、属性动画:最简单的动画方式

属性动画通过 .animation() 修饰符绑定到组件上,当组件的可动画属性(宽高、透明度、位移等)发生变化时自动播放。

2.1 基础用法

@Entry
@Component
struct AttributeAnimDemo {
  @State private cardWidth: number = 200
  @State private cardOpacity: number = 1.0
  @State private cardRotate: number = 0

  build() {
    Column({ space: 30 }) {
      // 核心:.animation() 修饰符绑定动画参数
      Column() {
        Text('点击我')
          .fontSize(18)
          .fontColor(Color.White)
      }
      .width(this.cardWidth)
      .height(100)
      .backgroundColor('#4096FF')
      .borderRadius(12)
      .opacity(this.cardOpacity)
      .rotate({ angle: this.cardRotate })
      .animation({
        duration: 400,           // 持续时间(ms)
        curve: Curve.EaseInOut,  // 缓动曲线
        delay: 0,                // 延迟
        iterations: 1,           // 播放次数(-1 = 无限循环)
        playMode: PlayMode.Normal
      })
      .onClick(() => {
        this.cardWidth = this.cardWidth === 200 ? 300 : 200
        this.cardOpacity = this.cardOpacity === 1.0 ? 0.6 : 1.0
        this.cardRotate = this.cardRotate === 0 ? 15 : 0
      })

      Button('重置').onClick(() => {
        this.cardWidth = 200
        this.cardOpacity = 1.0
        this.cardRotate = 0
      })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

2.2 常见缓动曲线对照

// 系统内置曲线
Curve.Linear        // 匀速
Curve.Ease          // 慢→快→慢(默认)
Curve.EaseIn        // 慢入快出
Curve.EaseOut       // 快入慢出
Curve.EaseInOut     // 慢入慢出
Curve.FastOutSlowIn // Material Design 标准曲线
Curve.Spring        // 弹性效果

// 自定义贝塞尔曲线
curves.cubicBezierCurve(0.25, 0.1, 0.25, 1.0)

// 弹簧曲线(物理感强)
curves.springCurve(0, 1, 200, 20) // velocity, mass, stiffness, damping

2.3 属性动画的坑:.animation() 位置很重要

// ❌ 错误:animation 加在不需要动画的属性后面
Column()
  .animation({ duration: 300 })  // 放在最前面不对
  .width(this.width)
  .backgroundColor(this.color)

// ✅ 正确:animation 放在需要动画属性的最后
Column()
  .width(this.width)
  .backgroundColor(this.color)
  .animation({ duration: 300 })  // 作用于它前面的所有可动画属性

三、显式动画 animateTo:精确控制的利器

animateTo() 是更强大的动画 API,你可以精确控制哪些状态变化触发动画,并支持完成回调。

3.1 基础用法

@Entry
@Component
struct ExplicitAnimDemo {
  @State private isExpanded: boolean = false
  @State private listHeight: number = 0
  @State private arrowAngle: number = 0

  // 数据列表
  private items: string[] = ['选项A', '选项B', '选项C', '选项D']

  build() {
    Column() {
      // 折叠面板头部
      Row() {
        Text('展开菜单')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .layoutWeight(1)

        Image($r('app.media.arrow_down'))
          .width(20)
          .height(20)
          .rotate({ angle: this.arrowAngle })
          // 箭头用属性动画即可
          .animation({ duration: 300, curve: Curve.EaseInOut })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .backgroundColor('#F5F5F5')
      .borderRadius(8)
      .onClick(() => {
        // 使用 animateTo 精确控制动画范围
        animateTo(
          {
            duration: 350,
            curve: Curve.FastOutSlowIn,
            onFinish: () => {
              console.log('动画完成,isExpanded =', this.isExpanded)
            }
          },
          () => {
            // 闭包内的所有状态变化都会触发动画
            this.isExpanded = !this.isExpanded
            this.listHeight = this.isExpanded ? this.items.length * 48 : 0
            this.arrowAngle = this.isExpanded ? 180 : 0
          }
        )
      })

      // 折叠内容区
      Column() {
        ForEach(this.items, (item: string) => {
          Text(item)
            .fontSize(14)
            .width('100%')
            .height(48)
            .padding({ left: 20 })
            .fontColor('#666666')
        })
      }
      .width('100%')
      .height(this.listHeight)
      .clip(true)  // 必须加 clip,否则超出部分会显示
      .backgroundColor('#FAFAFA')
    }
    .width('100%')
    .padding(16)
  }
}

3.2 实战:点赞动效(scale + opacity 组合)

@Component
struct LikeButton {
  @State private liked: boolean = false
  @State private likeCount: number = 128
  @State private scale: number = 1.0
  @State private heartOpacity: number = 0

  build() {
    Row({ space: 6 }) {
      // 爱心图标
      Stack() {
        // 底层:灰色轮廓
        Image($r('app.media.heart_outline'))
          .width(24)
          .height(24)
          .fillColor(this.liked ? '#FF4D6D' : '#999999')

        // 叠层:点赞粒子效果
        Image($r('app.media.heart_filled'))
          .width(36)
          .height(36)
          .fillColor('#FF4D6D')
          .opacity(this.heartOpacity)
          .scale({ x: this.scale, y: this.scale })
      }
      .width(36)
      .height(36)

      Text(`${this.likeCount}`)
        .fontSize(14)
        .fontColor(this.liked ? '#FF4D6D' : '#666666')
        .animation({ duration: 200 })

    }
    .onClick(() => {
      // 第一段动画:放大
      animateTo({ duration: 150, curve: Curve.EaseOut }, () => {
        this.liked = !this.liked
        this.likeCount += this.liked ? 1 : -1
        this.scale = 1.4
        this.heartOpacity = this.liked ? 0.8 : 0
      })

      // 第二段动画:回弹(延迟 150ms)
      animateTo({ duration: 200, curve: Curve.Spring, delay: 150 }, () => {
        this.scale = 1.0
        this.heartOpacity = 0
      })
    })
  }
}

3.3 animateTo vs .animation() 选择原则

需要动画?
│
├─ 状态改变时"顺便"就触发动画 → .animation()
│    例:按钮展开收缩、颜色切换
│
└─ 需要精确控制"哪次变化"触发动画 → animateTo()
     例:点击特定按钮才触发、多段顺序动画、有完成回调

四、页面转场动画:让路由跳转更丝滑

4.1 使用 pageTransition() 自定义转场

// PageA.ets - 从右侧滑入
@Entry
@Component
struct PageA {
  build() {
    Column() {
      Button('跳转到 PageB').onClick(() => {
        router.pushUrl({ url: 'pages/PageB' })
      })
    }
    .width('100%')
    .height('100%')
  }

  // 进入动画:从右侧滑入
  pageTransition() {
    PageTransitionEnter({ duration: 350, curve: Curve.FastOutSlowIn })
      .slide(SlideEffect.Right)
    
    // 退出动画:向左淡出
    PageTransitionExit({ duration: 300, curve: Curve.EaseIn })
      .opacity(0)
      .translate({ x: -80 })
  }
}
// PageB.ets - 与 PageA 配合
@Entry
@Component
struct PageB {
  build() {
    Column() {
      Button('返回').onClick(() => {
        router.back()
      })
    }
    .width('100%')
    .height('100%')
  }

  pageTransition() {
    // 进入时:从右侧推入
    PageTransitionEnter({ duration: 350, curve: Curve.FastOutSlowIn })
      .slide(SlideEffect.Right)

    // 退出时:向右滑出(back 时)
    PageTransitionExit({ duration: 300, curve: Curve.EaseIn })
      .slide(SlideEffect.Right)
  }
}

4.2 Navigation 路由下的转场(推荐写法)

HarmonyOS API 12 之后推荐使用 Navigation + NavDestination 替代 router,转场控制更细腻:

// AppNavigation.ets
@Entry
@Component
struct AppNavigation {
  @Provide('pageInfos') pageInfos: NavPathStack = new NavPathStack()

  @Builder
  pageMap(name: string) {
    if (name === 'DetailPage') {
      DetailPage()
    } else if (name === 'ProfilePage') {
      ProfilePage()
    }
  }

  build() {
    Navigation(this.pageInfos) {
      // 主页内容
      HomePage()
    }
    .navDestination(this.pageMap)
    // 全局禁用默认转场,改用自定义
    .customNavContentTransition((from, to, operation) => {
      return {
        timeout: 1000,
        transition: (proxy) => {
          // from page 淡出
          from?.finishTransition()
          // to page 从底部滑入
          to?.startSharedTransition({
            id: 'shared_hero',
            duration: 400,
            curve: Curve.FastOutSlowIn
          })
        }
      }
    })
  }
}

4.3 共享元素转场(Hero Animation)

共享元素转场是电商、图库类应用的标配效果,ArkTS 原生支持:

// 列表页:商品卡片
@Component
struct ProductCard {
  @Prop productId: string
  @Prop imageUrl: ResourceStr
  @Prop productName: string

  build() {
    Column() {
      Image(this.imageUrl)
        .width('100%')
        .height(160)
        .objectFit(ImageFit.Cover)
        .borderRadius(8)
        // 关键:geometryTransition 绑定共享 ID
        .geometryTransition(`product_image_${this.productId}`)
        .transition(TransitionEffect.OPACITY)

      Text(this.productName)
        .fontSize(14)
        .margin({ top: 8 })
    }
    .onClick(() => {
      // 跳转详情页,图片会平滑过渡
      this.pageInfos?.pushPath({
        name: 'ProductDetail',
        param: { productId: this.productId }
      })
    })
  }
}

// 详情页:大图展示
@Component
struct ProductDetail {
  @State productId: string = ''
  @State imageUrl: ResourceStr = ''

  build() {
    Column() {
      Image(this.imageUrl)
        .width('100%')
        .height(300)
        .objectFit(ImageFit.Cover)
        // 相同的 geometryTransition ID = 触发共享元素动画
        .geometryTransition(`product_image_${this.productId}`)
        .transition(TransitionEffect.OPACITY)

      // 详情内容...
    }
  }
}

五、高级技巧:自定义转场效果

5.1 TransitionEffect 组合

// 组合效果:淡入 + 向上位移 + 缩放
.transition(
  TransitionEffect.OPACITY
    .combine(TransitionEffect.translate({ y: 40 }))
    .combine(TransitionEffect.scale({ x: 0.95, y: 0.95 }))
    .animation({ duration: 350, curve: Curve.FastOutSlowIn })
)

// 不对称转场:进入和退出效果不同
.transition(
  TransitionEffect.asymmetric(
    // 进入:从下方滑入
    TransitionEffect.translate({ y: 60 }).combine(TransitionEffect.OPACITY),
    // 退出:向右侧淡出
    TransitionEffect.translate({ x: 100 }).combine(TransitionEffect.OPACITY)
  )
)

5.2 keyframeAnimateTo:关键帧动画

复杂的多段动画可以用关键帧,避免多个 animateTo 叠加的混乱:

// 模拟"弹跳加载"效果
animateToImmediately(
  { iterations: -1 },  // 无限循环
  [
    // 关键帧1:上升
    {
      duration: 400,
      event: () => { this.ballY = -60 }
    },
    // 关键帧2:下落(带挤压效果)
    {
      duration: 300,
      curve: Curve.Bounce,
      event: () => {
        this.ballY = 0
        this.ballScaleX = 1.3
        this.ballScaleY = 0.7
      }
    },
    // 关键帧3:恢复形变
    {
      duration: 150,
      event: () => {
        this.ballScaleX = 1.0
        this.ballScaleY = 1.0
      }
    }
  ]
)

六、踩坑总结

问题 原因 解决方案
.animation() 不生效 修改了不可动画属性(如 visibility) 改用 opacity 代替 visibility
animateTo 闭包外变量不动画 只有闭包内的状态变化才触发动画 确保状态变量在闭包内赋值
转场闪烁白屏 pageTransition 配置了进入但没配退出 进入/退出动画需配对
geometryTransition 错位 两端组件尺寸不一致时未指定 mode 添加 .geometryTransition(id, { follow: true })
列表 ForEach 动画卡顿 每次 rebuild 都触发动画 用 @ObjectLink 减少不必要的组件重建
Spring 曲线参数不懂 stiffness(刚度)和 damping(阻尼)含义模糊 stiffness↑弹得快,damping↑越不弹跳

七、最佳实践总结

选择 API 的简单决策树:

简单状态切换(宽高/颜色)?
  └─ 是 → .animation()

需要精确控制 / 有回调?
  └─ 是 → animateTo()

页面级跳转?
  └─ router → pageTransition()
  └─ Navigation → customNavContentTransition()

两个页面有相同元素?
  └─ geometryTransition(共享元素转场)

多段顺序动画?
  └─ animateToImmediately(关键帧)

动画的本质是"状态驱动"——ArkTS 的所有动画 API 都是围绕状态变化展开的。掌握这一思路后,你会发现动画实现远比你想象的简单。


参考资料

Logo

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

更多推荐