开篇:老炮儿聊动画

大家好,我是小邢哥,一个写了14年代码的老程序员。

这些年,我见证了太多技术的起起落落——从jQuery一统天下,到React、Vue三分天下,从Android、iOS双雄争霸,到如今HarmonyOS异军突起。技术在变,但有些东西从来没变过,那就是用户体验永远是王道

说到用户体验,就不得不提动画

你有没有发现,同样是一个按钮点击效果,有的App让你感觉"嗯,丝滑",有的App让你觉得"卡巴,生硬"?这中间的差距,往往就差在那零点几秒的动画上。

我常跟团队里的年轻人说:代码是写给机器看的,但动画是写给人心看的

今天,咱们就来把HarmonyOS 6.0的属性动画扒个底朝天。这篇文章,我会用14年的老炮儿视角,给你讲透:

  • 属性动画是什么,怎么用

  • 动画曲线背后的数学原理(放心,不讲公式)

  • 各种动画效果的实战技巧

  • 那些官方文档不会告诉你的坑

废话不多说,开整!


一、先搞懂:什么是属性动画?

1.1 从一个生活场景说起

在讲代码之前,咱们先聊个生活场景。

想象你面前有一辆玩具小汽车,你要把它从桌子左边移到右边。你可以怎么做?

方法一:直接拿起来放过去

"啪"一下,车就到右边了。简单粗暴,但是——没有任何过程,突然就变了位置。

方法二:用手推着它慢慢滑过去

嗖~嗖~嗖~,车平稳地从左边滑到右边。你能看到整个移动过程,感觉就自然多了。

在UI开发中:

  • 方法一就是直接修改属性——按钮位置从0变成100,没有中间状态

  • 方法二就是属性动画——按钮位置从0平滑过渡到100,有完整的中间过程

1.2 属性动画的本质

所谓属性动画,本质就是:

在一段时间内,让某个属性值从A平滑变化到B

这里有三个关键词:

  • 时间:动画持续多久?500毫秒?1秒?

  • 属性:要变的是啥?位置?大小?透明度?颜色?

  • 平滑:怎么变?匀速?先快后慢?弹一弹?

举个例子:

把按钮的透明度,在300毫秒内,从1.0变成0.5

这就是一个最简单的属性动画定义。

1.3 属性动画 vs 帧动画

很多新手容易混淆这两个概念,我给你做个对比:

对比项 帧动画 属性动画
原理 快速播放多张图片 动态计算中间值
类比 翻页动画书 真人表演
灵活性 低(固定的图片序列) 高(任意属性都能动)
内存占用 高(需要多张图片) 低(只存起止值)
可交互性

属性动画的厉害之处在于:你只需要告诉系统起点和终点,中间的过程它自动帮你算

这就像你跟出租车司机说"从这儿到人民广场",司机自己会选路线,你不用一步步指挥"向前100米,右转,再向前200米"。

1.4 HarmonyOS动画体系全景图

在正式开始写代码之前,咱们先看看HarmonyOS的动画体系长啥样:

HarmonyOS动画体系
├── 属性动画(今天的主角)
│   ├── 显式动画 animateTo
│   └── 属性动画 animation
├── 转场动画
│   ├── 页面间转场
│   └── 组件内转场
├── 路径动画
├── 粒子动画
└── 帧动画

今天咱们专攻属性动画,这是所有动画的基础,也是用得最多的。把这个吃透了,其他的触类旁通。


二、显式动画:animateTo

2.1 什么是显式动画?

HarmonyOS给我们提供了两种实现属性动画的方式:

  • 显式动画:用 animateTo 函数

  • 属性动画:用 animation 属性

先说显式动画 animateTo

为什么叫"显式"?因为你要显式地调用一个函数来触发动画。就像你要开灯,得显式地去按一下开关。

2.2 基本语法

来,先看最简单的用法:

@Entry
@Component
struct AnimateToDemo {
  @State boxWidth: number = 100
  
  build() {
    Column() {
      // 一个会变化宽度的方块
      Row()
        .width(this.boxWidth)
        .height(100)
        .backgroundColor('#FF6B6B')
        .borderRadius(10)
      
      Button('点我变大')
        .margin({ top: 20 })
        .onClick(() => {
          // 重点在这!显式动画来了!
          animateTo({
            duration: 500,  // 动画时长500毫秒
          }, () => {
            this.boxWidth = 250  // 在闭包中修改状态
          })
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

看到没?animateTo 接收两个参数:

  1. 动画配置对象:告诉系统动画怎么执行

  2. 闭包函数:在这里面修改状态变量

所有在闭包中被修改的状态变量,其引起的属性变化,都会以动画形式呈现。

这就是显式动画的核心思想:把状态修改"包裹"在animateTo里,让变化变成动画

2.3 参数详解

animateTo 的配置对象可以设置很多参数,咱们逐个拆解:

animateTo({
  duration: 500,           // 动画时长(毫秒)
  tempo: 1.0,              // 动画速率,默认1.0
  curve: Curve.EaseInOut,  // 动画曲线(重点!后面专门讲)
  delay: 0,                // 延迟多久开始(毫秒)
  iterations: 1,           // 播放次数,-1表示无限循环
  playMode: PlayMode.Normal,  // 播放模式
  onFinish: () => {        // 动画结束回调
    console.log('动画完成!')
  }
}, () => {
  // 状态变更
})

让我展开说说每个参数:

① duration - 持续时长

单位是毫秒。一般来说:

  • 快速反馈:150-200ms(比如按钮点击效果)

  • 普通过渡:300-500ms(比如页面元素移动)

  • 强调效果:500-800ms(比如重要提示出现)

  • 复杂动画:800ms以上

经验之谈:大部分场景300ms够用,别超过500ms,用户会觉得慢

② tempo - 动画速率

这是个倍速播放器。

  • tempo = 1.0:正常速度

  • tempo = 2.0:快进2倍(实际时长减半)

  • tempo = 0.5:慢放一半(实际时长翻倍)

什么时候用?比如开发调试时,你想慢放看清动画细节,就把tempo设成0.2。

③ curve - 动画曲线

这个太重要了,我后面专门用一整章来讲。先记住它**决定了动画的"节奏感"**。

④ delay - 延迟启动

动画不是立刻开始,而是等一会儿再开始。

使用场景:做序列动画时,让多个动画错开执行。

// 第一个元素立刻开始
animateTo({ duration: 300 }, () => { this.scale1 = 1.2 })

// 第二个元素延迟100ms开始
animateTo({ duration: 300, delay: 100 }, () => { this.scale2 = 1.2 })

// 第三个元素延迟200ms开始
animateTo({ duration: 300, delay: 200 }, () => { this.scale3 = 1.2 })

这样就能做出"依次弹出"的效果,比一起动好看多了。

⑤ iterations - 播放次数

  • 1:播放一次(默认)

  • 3:播放三次

  • -1:无限循环

配合 playMode 使用,可以做出各种循环效果。

⑥ playMode - 播放模式

  • PlayMode.Normal:正常播放,每次从头开始

  • PlayMode.Reverse:反向播放

  • PlayMode.Alternate:交替播放(去—回—去—回...)

  • PlayMode.AlternateReverse:反向交替播放

做呼吸灯效果用 Alternate 最合适:

animateTo({
  duration: 1000,
  iterations: -1,
  playMode: PlayMode.Alternate,
  curve: Curve.EaseInOut
}, () => {
  this.opacity = this.opacity === 1 ? 0.3 : 1
})

这样透明度就会在1和0.3之间来回变化,完美的呼吸效果。

⑦ onFinish - 完成回调

动画结束时触发。注意几个细节:

  • 如果 iterations = -1(无限循环),这个回调永远不会触发

  • 如果动画被中途打断(比如新动画覆盖),也不会触发

2.4 实战案例:做一个点赞按钮

光说不练假把式,咱们来个实战:做一个有动画效果的点赞按钮。

@Entry
@Component
struct LikeButton {
  @State isLiked: boolean = false
  @State iconScale: number = 1
  @State iconRotate: number = 0
  
  build() {
    Column() {
      Image(this.isLiked ? $r('app.media.heart_filled') : $r('app.media.heart_outline'))
        .width(50)
        .height(50)
        .scale({ x: this.iconScale, y: this.iconScale })
        .rotate({ angle: this.iconRotate })
        .onClick(() => {
          this.handleLike()
        })
      
      Text(this.isLiked ? '已点赞' : '点赞')
        .fontSize(14)
        .fontColor(this.isLiked ? '#FF6B6B' : '#999999')
        .margin({ top: 8 })
    }
  }
  
  handleLike() {
    if (!this.isLiked) {
      // 点赞动画:先缩小再弹大,同时旋转
      animateTo({
        duration: 150,
        curve: Curve.EaseIn
      }, () => {
        this.iconScale = 0.8
        this.iconRotate = -15
      })
      
      // 延迟150ms后弹回来
      setTimeout(() => {
        animateTo({
          duration: 300,
          curve: Curve.EaseOut
        }, () => {
          this.iconScale = 1.2
          this.iconRotate = 0
          this.isLiked = true
        })
        
        // 最后回到正常大小
        setTimeout(() => {
          animateTo({
            duration: 150,
            curve: Curve.EaseOut
          }, () => {
            this.iconScale = 1
          })
        }, 300)
      }, 150)
    } else {
      // 取消点赞:简单渐变
      animateTo({
        duration: 200,
        curve: Curve.EaseOut
      }, () => {
        this.isLiked = false
      })
    }
  }
}

这个点赞按钮的动画拆解:

  1. 按下去先缩小一点(反馈感)

  2. 然后弹出来变大(强调感)

  3. 最后恢复正常大小

三段式动画,让一个简单的点赞变得很有质感。

2.5 animateTo的进阶用法

① 同时改变多个属性

一个 animateTo 可以同时修改多个状态变量:

animateTo({ duration: 300 }, () => {
  this.positionX = 200
  this.positionY = 100
  this.rotation = 45
  this.scale = 1.2
  this.opacity = 0.8
})

这些属性会同步变化,在同一时间完成动画。

② 串行动画(一个接一个)

onFinish 回调串起来:

animateTo({
  duration: 300,
  onFinish: () => {
    // 第一段动画结束,开始第二段
    animateTo({
      duration: 300,
      onFinish: () => {
        // 第二段动画结束,开始第三段
        animateTo({ duration: 300 }, () => {
          this.step3State = true
        })
      }
    }, () => {
      this.step2State = true
    })
  }
}, () => {
  this.step1State = true
})

不过说实话,这种回调套回调的写法有点"回调地狱"的味道。HarmonyOS也提供了更优雅的关键帧动画方案,后面会讲。

③ 并行动画(同时执行,但配置不同)

多次调用 animateTo,但修改不同的状态变量:

// 位置动画:300ms,缓入缓出
animateTo({
  duration: 300,
  curve: Curve.EaseInOut
}, () => {
  this.positionX = 200
})

// 透明度动画:500ms,线性
animateTo({
  duration: 500,
  curve: Curve.Linear
}, () => {
  this.opacity = 0.5
})

这样位置和透明度会同时开始变化,但用时和节奏各不相同。

2.6 animateTo的注意事项

老炮儿的经验之谈,踩过的坑你别再踩:

坑1:闭包里不能做状态无关的操作

// ❌ 错误示范
animateTo({ duration: 300 }, () => {
  this.width = 200
  console.log('这行代码会立刻执行!')  // 不是动画结束才执行
  this.doSomething()  // 这也是立刻执行
})

闭包里的代码是立刻执行的,不是动画结束才执行!想在动画结束后做事情,用 onFinish

坑2:频繁调用animateTo会相互打断

// 快速点击多次
onClick(() => {
  animateTo({ duration: 300 }, () => {
    this.scale = 1.2
  })
})

如果用户快速点击,新动画会打断旧动画,可能造成状态混乱。解决方案:加个标志位判断,或者使用防抖。

坑3:并非所有属性都能动画

能做属性动画的有:

  • 位置:positionoffset

  • 大小:widthheight

  • 变换:scalerotatetranslate

  • 透明度:opacity

  • 背景色:backgroundColor

  • 其他数值型属性

不能做动画的:

  • visibility(要么显示要么隐藏,没有中间状态)

  • display

  • 一些文本属性


三、属性动画:animation

3.1 什么是animation?

除了 animateTo,HarmonyOS还提供了另一种属性动画方式:animation 属性。

Row()
  .width(this.boxWidth)
  .height(100)
  .animation({   // 注意:这是个属性,不是函数
    duration: 300,
    curve: Curve.EaseOut
  })

这样写之后,只要 this.boxWidth 变化,就会自动以动画形式过渡,不需要手动调用 animateTo

3.2 animation vs animateTo

这两种方式有什么区别?老炮儿给你画个表:

对比项 animateTo animation
调用方式 函数调用(显式) 属性设置(隐式)
控制粒度 每次动画可以不同配置 固定配置,统一生效
适用场景 复杂动画,需要精确控制 简单动画,状态变就自动动
代码量 稍多 更简洁
动画取消 不好取消 设置 animation(null)

什么时候用 animateTo?

  • 需要在动画中执行多个不同配置

  • 需要监听动画完成事件

  • 需要做序列动画

  • 需要精确控制动画时机

什么时候用 animation?

  • 简单的状态变化动画

  • 希望"设置一次,处处生效"

  • 代码简洁优先

3.3 animation的详细用法

@Entry
@Component
struct AnimationDemo {
  @State boxSize: number = 100
  @State boxColor: string = '#FF6B6B'
  @State boxRadius: number = 10
  
  build() {
    Column() {
      Row()
        .width(this.boxSize)
        .height(this.boxSize)
        .backgroundColor(this.boxColor)
        .borderRadius(this.boxRadius)
        // 关键:给这些属性加上动画
        .animation({
          duration: 400,
          curve: Curve.EaseInOut,
          delay: 0,
          iterations: 1,
          playMode: PlayMode.Normal
        })
      
      Row() {
        Button('变大')
          .onClick(() => {
            this.boxSize = 150  // 直接改状态,自动动画
          })
        
        Button('变色')
          .margin({ left: 10 })
          .onClick(() => {
            this.boxColor = '#4ECDC4'  // 直接改状态,自动动画
          })
        
        Button('圆角')
          .margin({ left: 10 })
          .onClick(() => {
            this.boxRadius = 75  // 直接改状态,自动动画
          })
      }
      .margin({ top: 20 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

看到没?用了 animation 之后,代码变得很干净。不需要每次都包裹 animateTo,状态一变,自动就动画过渡了。

3.4 animation的参数

animation 的参数和 animateTo 基本一致:

.animation({
  duration: 300,           // 时长
  tempo: 1.0,              // 速率
  curve: Curve.EaseInOut,  // 曲线
  delay: 0,                // 延迟
  iterations: 1,           // 次数
  playMode: PlayMode.Normal,  // 播放模式
  onFinish: () => {}       // 完成回调
})

3.5 animation的生效范围

重点来了!animation 只对它上面的属性生效。

Row()
  .width(this.width)        // ✅ 会有动画
  .height(this.height)      // ✅ 会有动画
  .animation({ duration: 300 })  // animation在这里
  .backgroundColor(this.color)   // ❌ 不会有动画!
  .opacity(this.opacity)         // ❌ 不会有动画!

所以,animation要放在需要动画的属性后面

如果你想所有属性都有动画,就把 animation 放最后:

Row()
  .width(this.width)
  .height(this.height)
  .backgroundColor(this.color)
  .opacity(this.opacity)
  .animation({ duration: 300 })  // 放最后,上面的都有动画

3.6 多个animation的情况

如果你想不同属性用不同的动画配置,可以放多个 animation

Row()
  .width(this.width)
  .animation({ duration: 200, curve: Curve.EaseOut })  // 宽度:快速
  .height(this.height)
  .animation({ duration: 500, curve: Curve.Spring })   // 高度:弹簧
  .backgroundColor(this.color)
  .animation({ duration: 1000, curve: Curve.Linear })  // 颜色:缓慢线性

这样每组属性就有自己独立的动画配置了。

3.7 禁用动画

有时候你想临时禁用动画,可以传 null 或者 undefined

Row()
  .width(this.width)
  .animation(this.enableAnimation ? { duration: 300 } : null)

或者设置 duration 为 0:

.animation({ duration: 0 })  // 等于没有动画

3.8 实战案例:卡片展开效果

@Entry
@Component
struct CardExpand {
  @State isExpanded: boolean = false
  
  build() {
    Column() {
      Column() {
        // 卡片头部
        Row() {
          Text('订单详情')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
          
          Blank()
          
          Image($r('app.media.arrow_down'))
            .width(20)
            .height(20)
            .rotate({ angle: this.isExpanded ? 180 : 0 })
            .animation({ duration: 300, curve: Curve.EaseOut })
        }
        .width('100%')
        .padding(16)
        .onClick(() => {
          this.isExpanded = !this.isExpanded
        })
        
        // 卡片内容(可展开收起)
        if (this.isExpanded) {
          Column() {
            this.InfoRow('订单编号', '202412040001')
            this.InfoRow('商品名称', 'HarmonyOS手机壳')
            this.InfoRow('订单金额', '¥99.00')
            this.InfoRow('收货地址', '北京市朝阳区...')
          }
          .padding(16)
          .opacity(this.isExpanded ? 1 : 0)
          .animation({ duration: 300, curve: Curve.EaseOut })
        }
      }
      .width('90%')
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .shadow({
        radius: 10,
        color: 'rgba(0,0,0,0.1)',
        offsetX: 0,
        offsetY: 2
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
    .padding({ top: 50 })
  }
  
  @Builder
  InfoRow(label: string, value: string) {
    Row() {
      Text(label)
        .fontSize(14)
        .fontColor('#999999')
      Blank()
      Text(value)
        .fontSize(14)
        .fontColor('#333333')
    }
    .width('100%')
    .margin({ bottom: 8 })
  }
}

这个案例展示了 animation 的便捷性:箭头图标的旋转、内容区的透明度变化,都是自动动画的,代码很清爽。


四、动画曲线:灵魂所在

4.1 什么是动画曲线?

终于到了重头戏——动画曲线(Animation Curve)

如果说动画时长决定了"动画多久完成",那么动画曲线就决定了"动画怎么完成"。

还是用生活场景来理解:

假设你要从A点走到B点,用时10秒。

  • 匀速:每秒走相同的距离,稳稳当当

  • 先慢后快:开始慢悠悠,后来越跑越快

  • 先快后慢:一开始冲刺,快到了就减速

  • 弹簧效果:到了终点还要弹一下,再稳定下来

这就是不同的"运动曲线",在动画领域,我们叫它动画曲线缓动函数

4.2 为什么动画曲线重要?

让我举个真实的例子。

同样是一个弹窗出现动画,持续300毫秒:

用线性曲线(Linear)

  • 弹窗匀速从小变大

  • 感觉:机械、生硬、像机器人

用缓出曲线(EaseOut)

  • 弹窗快速变大,到最后慢慢减速

  • 感觉:自然、舒适、像真实物体

用弹簧曲线(Spring)

  • 弹窗快速变大,超过目标大小一点点,再弹回来

  • 感觉:活泼、有弹性、像皮球

同样的时长,不同的曲线,用户感受天差地别。

这就是为什么我说:动画曲线是动画的灵魂

4.3 HarmonyOS内置曲线详解

HarmonyOS提供了一系列预设曲线,都在 Curve 枚举里:

① Curve.Linear - 线性

curve: Curve.Linear

最简单的曲线,匀速变化。

适用场景:

  • 进度条

  • 需要表现"机械感"的场景

  • 对时间精确同步的场景(比如和音乐节奏同步)

不推荐用于:

  • 大部分UI动画(太生硬)

② Curve.Ease - 标准缓动

curve: Curve.Ease

开始和结束都有点慢,中间快一些。经典的"慢-快-慢"节奏。

适用场景:

  • 通用的UI动画

  • 不知道用什么曲线时的默认选择

③ Curve.EaseIn - 缓入

curve: Curve.EaseIn

开始慢,越来越快。像东西从静止开始加速。

适用场景:

  • 元素离开画面(飞出去)

  • 需要"蓄力"感觉的动画

④ Curve.EaseOut - 缓出

curve: Curve.EaseOut

开始快,越来越慢。像东西飞过来,快到了减速。

适用场景:

  • 元素进入画面

  • 弹窗出现

  • 抽屉展开

  • 最常用的曲线,没有之一

⑤ Curve.EaseInOut - 缓入缓出

curve: Curve.EaseInOut

开始慢,中间快,结束慢。完整的加速-减速过程。

适用场景:

  • 元素在场景内移动(从A点到B点)

  • 循环动画

  • 需要"自然"感觉的大多数场景

⑥ Curve.FastOutSlowIn - 快出慢入

curve: Curve.FastOutSlowIn

Material Design推荐的标准曲线,特点是一开始就很快,然后缓慢减速。

适用场景:

  • 安卓风格的UI

  • 响应用户操作的动画

⑦ Curve.LinearOutSlowIn - 线出慢入

curve: Curve.LinearOutSlowIn

开始线性加速,后半段慢慢减速。用于进入场景的动画。

8 Curve.FastOutLinearIn - 快出线入

curve: Curve.FastOutLinearIn

一开始就快,然后保持线性到结束。用于离开场景的动画。

⑨ Curve.ExtremeDeceleration - 极速减速

curve: Curve.ExtremeDeceleration

非常激进的减速曲线,一开始就很快,然后迅速减到几乎停止。

适用场景:

  • 强调"迅速响应"的交互

  • 短时间动画

⑩ Curve.Sharp - 锐利

curve: Curve.Sharp

锐利的曲线,快速启动和快速结束。

⑪ Curve.Smooth - 平滑

curve: Curve.Smooth

非常平滑的曲线,过渡自然。

4.4 曲线选择速查表

用了14年,我总结出一套"曲线选择经验":

场景 推荐曲线 原因
弹窗出现 EaseOut 快速响应,自然减速
弹窗消失 EaseIn 加速离开
元素移动 EaseInOut 自然的加速减速
按钮反馈 EaseOut 快速响应
加载动画(循环) EaseInOut 平滑循环
列表项出现 EaseOut 快速呈现
下拉刷新 Spring 弹性感
开关切换 Spring 物理感

4.5 弹簧曲线:让动画有物理感

前面提到的曲线都是"贝塞尔曲线",特点是给定时间必定结束。

弹簧曲线不一样,它模拟的是真实世界的弹簧物理运动,效果更自然。

HarmonyOS提供了三种弹簧曲线:

① Curve.Spring - 基础弹簧

curve: Curve.Spring

会有一个"过冲"效果——超过目标值再弹回来。

② curves.springMotion() - 弹簧运动曲线

import { curves } from '@kit.ArkUI'

animateTo({
  curve: curves.springMotion(0.5, 0.8)
}, () => {
  this.scale = 1.2
})

参数说明:

  • 第一个参数:响应时间(0-1),越小越快

  • 第二个参数:阻尼比(0-1),越小弹得越多

③ curves.responsiveSpringMotion() - 响应式弹簧

curve: curves.responsiveSpringMotion(0.35, 0.9)

这个是专门为跟手动画设计的。什么是跟手动画?比如:

  • 拖动卡片

  • 滑动开关

  • 下拉刷新时的弹性效果

特点是响应速度更快,适合持续交互。

④ 自定义弹簧曲线 curves.springCurve()

curve: curves.springCurve(100, 1, 228, 30)

四个参数分别是:

  • velocity:初始速度

  • mass:质量(越大越沉)

  • stiffness:刚度(越大弹得越快)

  • damping:阻尼(越大弹得越少)

这就像物理模拟,你可以调出各种弹簧效果:

  • 轻盈的小弹簧:小质量,高刚度

  • 沉稳的大弹簧:大质量,高阻尼

4.6 自定义贝塞尔曲线

如果预设曲线都不满足需求,你还可以自定义:

import { curves } from '@kit.ArkUI'

animateTo({
  curve: curves.cubicBezierCurve(0.25, 0.1, 0.25, 1.0)
}, () => {
  this.positionX = 200
})

cubicBezierCurve(x1, y1, x2, y2) 用四个参数定义一条三次贝塞尔曲线。

这四个数字是两个控制点的坐标:

  • (x1, y1):第一个控制点

  • (x2, y2):第二个控制点

如果你不太懂贝塞尔曲线的原理,没关系,推荐你一个神器:

cubic-bezier.com

这个网站可以可视化地调整曲线,调好了直接复制参数。

常见的一些贝塞尔曲线值:

  • Ease:(0.25, 0.1, 0.25, 1.0)

  • EaseIn:(0.42, 0, 1, 1)

  • EaseOut:(0, 0, 0.58, 1)

  • EaseInOut:(0.42, 0, 0.58, 1)

4.7 曲线对比实战

说了这么多,咱们来个实际的对比demo:

import { curves } from '@kit.ArkUI'

@Entry
@Component
struct CurveComparison {
  @State linear: number = 0
  @State ease: number = 0
  @State easeOut: number = 0
  @State spring: number = 0
  
  build() {
    Column() {
      // 4条轨道,4种曲线
      this.TrackRow('Linear', this.linear, '#FF6B6B')
      this.TrackRow('Ease', this.ease, '#4ECDC4')
      this.TrackRow('EaseOut', this.easeOut, '#45B7D1')
      this.TrackRow('Spring', this.spring, '#96CEB4')
      
      Button('开始对比')
        .margin({ top: 30 })
        .onClick(() => {
          // 重置
          this.linear = 0
          this.ease = 0
          this.easeOut = 0
          this.spring = 0
          
          // 延迟一点点再开始,让重置生效
          setTimeout(() => {
            animateTo({ duration: 1000, curve: Curve.Linear }, () => {
              this.linear = 1
            })
            animateTo({ duration: 1000, curve: Curve.Ease }, () => {
              this.ease = 1
            })
            animateTo({ duration: 1000, curve: Curve.EaseOut }, () => {
              this.easeOut = 1
            })
            animateTo({ duration: 1000, curve: curves.springMotion(0.3, 0.8) }, () => {
              this.spring = 1
            })
          }, 50)
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .padding(20)
  }
  
  @Builder
  TrackRow(label: string, progress: number, color: string) {
    Column() {
      Text(label)
        .fontSize(12)
        .fontColor('#666666')
        .margin({ bottom: 5 })
      
      Stack({ alignContent: Alignment.Start }) {
        // 轨道
        Row()
          .width('100%')
          .height(4)
          .backgroundColor('#EEEEEE')
          .borderRadius(2)
        
        // 小球
        Row()
          .width(30)
          .height(30)
          .backgroundColor(color)
          .borderRadius(15)
          .margin({ left: `${progress * 85}%` })  // 移动位置
      }
      .width('100%')
    }
    .margin({ bottom: 20 })
  }
}

运行这个demo,点击按钮,你会看到4个小球同时出发,但运动轨迹完全不同:

  • Linear:匀速直线

  • Ease:慢-快-慢

  • EaseOut:快-慢

  • Spring:会超过终点再弹回来

这就是曲线的力量。


五、动画效果进阶

5.1 关键帧动画

前面我们讲的动画,都是从A到B的单一变化。如果我想做更复杂的动画,比如:

A → B → C → D

怎么办?

HarmonyOS提供了关键帧动画 keyframeAnimateTo

import { keyframeAnimateTo, KeyframeState } from '@kit.ArkUI'

@Entry
@Component
struct KeyframeDemo {
  @State myScale: number = 1
  @State myRotate: number = 0
  
  build() {
    Column() {
      Row()
        .width(100)
        .height(100)
        .backgroundColor('#FF6B6B')
        .scale({ x: this.myScale, y: this.myScale })
        .rotate({ angle: this.myRotate })
      
      Button('关键帧动画')
        .margin({ top: 30 })
        .onClick(() => {
          this.runKeyframeAnimation()
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
  
  runKeyframeAnimation() {
    keyframeAnimateTo({
      iterations: 1,
    }, [
      // 第一段:0-30%时间,缩小并旋转
      {
        duration: 300,
        curve: Curve.EaseIn,
        event: () => {
          this.myScale = 0.8
          this.myRotate = 45
        }
      },
      // 第二段:30%-70%时间,放大并继续旋转
      {
        duration: 400,
        curve: Curve.EaseOut,
        event: () => {
          this.myScale = 1.3
          this.myRotate = 180
        }
      },
      // 第三段:70%-100%时间,恢复原状
      {
        duration: 300,
        curve: Curve.EaseInOut,
        event: () => {
          this.myScale = 1
          this.myRotate = 360
        }
      }
    ])
  }
}

关键帧动画的核心是:把动画拆成多个阶段,每个阶段有自己的时长、曲线和目标值

这比用 setTimeout + animateTo 串起来优雅多了。

5.2 组件内转场动画

除了属性动画,HarmonyOS还有转场动画,用于组件出现/消失时的效果。

@Entry
@Component
struct TransitionDemo {
  @State isShow: boolean = false
  
  build() {
    Column() {
      Button(this.isShow ? '隐藏' : '显示')
        .onClick(() => {
          this.isShow = !this.isShow
        })
      
      if (this.isShow) {
        Row()
          .width(200)
          .height(200)
          .backgroundColor('#FF6B6B')
          .borderRadius(20)
          .margin({ top: 20 })
          // 组件出现的转场效果
          .transition(TransitionEffect.OPACITY.animation({
            duration: 300,
            curve: Curve.EaseOut
          }))
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

TransitionEffect 有很多预设效果:

  • OPACITY:透明度渐变

  • SCALE:缩放

  • SLIDE:滑动

  • MOVE:移动

  • ASYMMETRIC:非对称(出现和消失不同效果)

还可以组合使用:

.transition(
  TransitionEffect.OPACITY
    .combine(TransitionEffect.scale({ x: 0.5, y: 0.5 }))
    .animation({ duration: 300, curve: Curve.EaseOut })
)

5.3 共享元素转场

这是一个特别酷的效果:两个页面间,某个元素平滑过渡。

比如列表页点击一张图片,图片"飞到"详情页变成大图,而不是直接跳转。

// 列表页
Image($r('app.media.photo'))
  .width(100)
  .height(100)
  .sharedTransition('photoId', {
    duration: 300,
    curve: Curve.EaseInOut
  })
  .onClick(() => {
    router.pushUrl({ url: 'pages/Detail' })
  })

// 详情页
Image($r('app.media.photo'))
  .width('100%')
  .height(300)
  .sharedTransition('photoId', {  // 相同的id
    duration: 300,
    curve: Curve.EaseInOut
  })

关键是两个页面的元素用相同的 sharedTransition id,系统就会自动计算过渡动画。

5.4 路径动画

让元素沿着特定路径运动:

@Entry
@Component
struct PathAnimationDemo {
  @State posX: number = 0
  @State posY: number = 0
  
  build() {
    Column() {
      Stack() {
        // 画一条参考路径
        Path()
          .width(300)
          .height(200)
          .commands('M0 100 Q150 0 300 100')  // 二次贝塞尔曲线
          .stroke('#CCCCCC')
          .strokeWidth(2)
          .fill('none')
        
        // 运动的小球
        Row()
          .width(20)
          .height(20)
          .backgroundColor('#FF6B6B')
          .borderRadius(10)
          .position({ x: this.posX, y: this.posY })
      }
      .width(300)
      .height(200)
      
      Button('开始')
        .margin({ top: 30 })
        .onClick(() => {
          // 使用motionPath实现路径动画
          animateTo({
            duration: 2000,
            curve: Curve.Linear,
            iterations: -1
          }, () => {
            // 通过计算贝塞尔曲线上的点来实现
            // 实际开发中可以使用motionPath属性
          })
        })
    }
  }
}

路径动画的完整实现需要使用 motionPath 属性,可以指定SVG路径字符串。

5.5 粒子动画

HarmonyOS还支持粒子动画,用于做一些炫酷的效果:

@Entry
@Component
struct ParticleDemo {
  build() {
    Stack() {
      Particle({
        particles: [{
          emitter: {
            particle: {
              type: ParticleType.POINT,
              config: { radius: 5 },
              count: 100
            }
          }
        }]
      })
      .width('100%')
      .height('100%')
    }
  }
}

粒子动画可以用来做:

  • 庆祝撒花效果

  • 雪花飘落

  • 火焰燃烧

  • 点赞爆发


六、性能优化与最佳实践

6.1 动画性能的关键指标

作为老炮儿,不得不提性能。

动画卡不卡,关键看两个指标:

① 帧率(FPS)

理想情况下,动画应该保持在60FPS,即每秒刷新60次。

  • 60FPS:丝滑

  • 30-60FPS:还行

  • <30FPS:明显卡顿

② 掉帧(Jank)

即使平均帧率不错,如果中途突然卡一下(掉帧),用户也会觉得卡。

6.2 影响动画性能的因素

① 动画属性的选择

不同属性的动画性能差距很大:

性能好 性能中等 性能差
transform(scale, rotate, translate) opacity width, height
opacity backgroundColor padding, margin

原因是:

  • transformopacity 可以由GPU直接处理,不需要重新布局

  • widthheight 等需要重新计算布局,然后重新绘制

所以,能用 transform 实现的效果,就不要用 width/height

// ❌ 不推荐:用width/height实现缩放
Row()
  .width(this.size)
  .height(this.size)

// ✅ 推荐:用scale实现缩放
Row()
  .width(100)
  .height(100)
  .scale({ x: this.scale, y: this.scale })

② 同时动画的元素数量

一次性让100个元素同时动画,肯定比1个元素动画更耗性能。

解决方案:

  • 使用 delay 错开启动时间

  • 复用动画组件

  • 使用虚拟列表(只渲染可见区域)

③ 动画时长

太长的动画会持续占用资源。

建议大部分动画控制在300-500ms以内。

6.3 动画调试技巧

① 慢放动画

设置 tempo: 0.1 可以把动画慢放10倍,方便观察细节:

animateTo({
  duration: 300,
  tempo: 0.1  // 慢放10倍
}, () => {
  this.scale = 1.2
})

② 使用DevEco Studio的性能分析工具

DevEco Studio提供了帧率监控、渲染耗时分析等工具,可以帮你定位性能问题。

③ 简化测试

怀疑某个动画有问题,就把它单独拿出来测试,排除干扰因素。

6.4 常见坑点总结

坑1:动画回调里调用动画

// ❌ 可能出问题
animateTo({
  onFinish: () => {
    animateTo({...}, () => {...})  // 嵌套调用
  }
}, () => {...})

虽然语法上没问题,但嵌套太深会导致逻辑混乱,建议用关键帧动画代替。

坑2:在循环里创建动画

// ❌ 性能差
for (let i = 0; i < 100; i++) {
  animateTo({ duration: 300 }, () => {
    this.items[i].scale = 1.2
  })
}

这样会创建100个独立的动画,性能爆炸。

正确做法是在一个 animateTo 里修改所有状态:

// ✅ 性能好
animateTo({ duration: 300 }, () => {
  for (let i = 0; i < 100; i++) {
    this.items[i].scale = 1.2
  }
})

坑3:忘记处理动画中断

用户可能在动画播放中途执行其他操作,导致状态不一致。

// 加个标志位
@State isAnimating: boolean = false

handleClick() {
  if (this.isAnimating) return
  
  this.isAnimating = true
  animateTo({
    duration: 300,
    onFinish: () => {
      this.isAnimating = false
    }
  }, () => {...})
}

坑4:animation放错位置

// ❌ backgroundColor不会有动画
Row()
  .animation({ duration: 300 })
  .backgroundColor(this.color)

// ✅ backgroundColor会有动画
Row()
  .backgroundColor(this.color)
  .animation({ duration: 300 })

记住:animation 只对它前面的属性生效!

6.5 最佳实践清单

总结一下做动画的最佳实践:

  1. 优先使用 transform(scale, rotate, translate)和 opacity,它们性能最好

  2. 动画时长控制在300-500ms,太短感觉不到,太长觉得慢

  3. 进入用 EaseOut,退出用 EaseIn,移动用 EaseInOut,这是最安全的选择

  4. 需要物理感时用弹簧曲线,特别是跟手操作

  5. 复杂动画用关键帧,不要callback套callback

  6. animation放在属性后面,别忘了作用范围

  7. 同时动画的元素不要太多,必要时用delay错开

  8. 加防抖/标志位,防止动画被频繁触发打断

  9. 开发时用tempo慢放,方便调试

  10. 上线前测试真机性能,模拟器不准


七、实战项目:做一个抽屉菜单

光说不练假把式,咱们来个完整的实战项目:带动画的抽屉菜单

7.1 需求分析

  • 点击按钮,抽屉从左边滑出

  • 抽屉出现时,背景变暗

  • 点击遮罩层,抽屉收回

  • 抽屉内的菜单项依次出现(错开动画)

7.2 完整代码

import { curves } from '@kit.ArkUI'

@Entry
@Component
struct DrawerMenu {
  @State isOpen: boolean = false
  @State drawerTranslateX: number = -280  // 抽屉宽度280
  @State maskOpacity: number = 0
  @State menuItemsVisible: boolean[] = [false, false, false, false, false]

  private menuItems: string[] = ['首页', '个人中心', '设置', '关于我们', '退出登录']

  build() {
    Stack() {
      // 主内容区
      Column() {
        Row() {
          Button('☰')
            .fontSize(24)
            .backgroundColor('transparent')
            .fontColor('#333333')
            .onClick(() => {
              this.openDrawer()
            })

          Text('我的应用')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .margin({ left: 15 })
        }
        .width('100%')
        .height(56)
        .padding({ left: 16, right: 16 })
        .backgroundColor('#FFFFFF')
        .shadow({
          radius: 4,
          color: 'rgba(0,0,0,0.1)',
          offsetX: 0,
          offsetY: 2
        })

        // 页面内容
        Column() {
          Text('这里是主要内容区域')
            .fontSize(16)
            .fontColor('#666666')
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F5F5F5')

      // 遮罩层
      if (this.maskOpacity > 0) {
        Row()
          .width('100%')
          .height('100%')
          .backgroundColor(`rgba(0, 0, 0, ${this.maskOpacity * 0.5})`)
          .onClick(() => {
            this.closeDrawer()
          })
      }

      // 抽屉
      Column() {
        // 抽屉头部
        Column() {
          Image($r('app.media.startIcon'))
            .width(80)
            .height(80)
            .borderRadius(40)

          Text('小邢哥')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .margin({ top: 12 })

          Text('14年编程老炮儿')
            .fontSize(14)
            .fontColor('rgba(255,255,255,0.8)')
            .margin({ top: 4 })
        }
        .width('100%')
        .padding({ top: 60, bottom: 30 })
        .backgroundColor('#FF6B6B')
        .alignItems(HorizontalAlign.Center)

        // 菜单项
        Column() {
          ForEach(this.menuItems, (item: string, index: number) => {
            Row() {
              // Text(this.getMenuIcon(index))
             SymbolGlyph(this.getMenuIcon(index))
                .fontSize(20)
                .width(30)

              Text(item)
                .fontSize(16)
                .fontColor('#333333')
                .margin({ left: 12 })
            }
            .width('100%')
            .height(56)
            .padding({ left: 20 })
            .backgroundColor(this.menuItemsVisible[index] ? '#FFFFFF' : '#F5F5F5')
            .opacity(this.menuItemsVisible[index] ? 1 : 0)
            .translate({ x: this.menuItemsVisible[index] ? 0 : -50 })
            .animation({
              duration: 300,
              curve: Curve.EaseOut
            })
            .onClick(() => {
              console.log(`点击了:${item}`)
              this.closeDrawer()
            })
          })
        }
        .width('100%')
        .padding({ top: 10 })
      }
      .width(280)
      .height('100%')
      .backgroundColor('#FFFFFF')
      .position({ x: 0, y: 0 })
      .translate({ x: this.drawerTranslateX })
    }
    .width('100%')
    .height('100%')
  }

  getMenuIcon(index: number): Resource {
    const icons = [$r("sys.symbol.home_key"),$r("sys.symbol.school"),
      $r("sys.symbol.house_setting"), $r("sys.symbol.message"),$r("sys.symbol.star")]
    return icons[index]
  }

  openDrawer() {
    // 抽屉滑入 + 遮罩出现
    animateTo({
      duration: 300,
      curve: curves.springMotion(0.4, 0.9)
    }, () => {
      this.drawerTranslateX = 0
      this.maskOpacity = 1
      this.isOpen = true
    })

    // 菜单项依次出现
    this.menuItemsVisible.forEach((_, index) => {
      setTimeout(() => {
        this.menuItemsVisible[index] = true
      }, 100 + index * 60)  // 每个菜单项延迟60ms
    })
  }

  closeDrawer() {
    // 先隐藏菜单项
    this.menuItemsVisible = [false, false, false, false, false]

    // 抽屉滑出 + 遮罩消失
    animateTo({
      duration: 250,
      curve: Curve.EaseIn
    }, () => {
      this.drawerTranslateX = -280
      this.maskOpacity = 0
      this.isOpen = false
    })
  }
}

7.3 动画拆解

这个demo用到了几个动画技巧:

  1. 弹簧曲线开启抽屉:让抽屉有"弹出"的感觉

  2. 普通曲线关闭抽屉:快速收起,不需要弹簧

  3. 序列动画:菜单项依次出现,使用setTimeout错开

  4. 组合动画:抽屉滑动 + 遮罩渐变同时进行

这就是一个"麻雀虽小五脏俱全"的动画案例。


八、总结

8.1 知识点回顾

写到这儿,咱们把HarmonyOS 6.0属性动画的核心知识点都过了一遍:

属性动画基础

  • 属性动画的本质:在一段时间内平滑改变属性值

  • 两种实现方式:animateTo(显式)和 animation(属性)

animateTo显式动画

  • 语法:animateTo(config, closure)

  • 参数:duration、curve、delay、iterations、playMode、onFinish

  • 闭包中的状态变更会以动画形式呈现

animation属性动画

  • 语法:.animation(config)

  • 作用范围:只对它前面的属性生效

  • 更简洁,适合简单场景

动画曲线

  • 线性曲线:Linear,匀速变化

  • 缓动曲线:Ease、EaseIn、EaseOut、EaseInOut

  • 弹簧曲线:Spring、springMotion、responsiveSpringMotion

  • 自定义曲线:cubicBezierCurve

进阶动画

  • 关键帧动画:keyframeAnimateTo

  • 转场动画:transition

  • 共享元素转场:sharedTransition

性能优化

  • 优先使用transform和opacity

  • 控制动画时长和数量

  • 使用防抖和标志位

8.2 老炮儿的忠告

最后,作为一个写了14年代码的老程序员,我想说几句真心话:

1. 动画不是越多越好

很多新人觉得动画炫酷,就往里面堆。结果整个App花里胡哨,用户眼花缭乱。

记住:动画是为用户体验服务的,不是为了炫技

该静的时候静,该动的时候动。克制,是高级。

2. 先学会用,再研究原理

这篇文章我讲了很多原理性的东西,但你不需要一开始就全搞懂。

先把 animateToanimation 用熟,做几个项目,遇到问题再深究。实践出真知。

3. 多抄,多改,多练

动画这东西,光看文档是学不会的。你得动手做。

找几个你觉得动画做得好的App,模仿它的效果。先抄,再改,最后创新。

4. 保持好奇心

技术永远在变,今天的HarmonyOS,明天可能又有新东西出来。

保持学习的热情,保持对新技术的好奇心。这才是一个程序员最重要的品质。

好了,这篇一万多字的文章就写到这儿。

如果你觉得有帮助,点个赞,转发一下,让更多人看到。

我是小邢哥,14年编程老炮,下期见!

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐