在这里插入图片描述

📖 引言

如果说 UI 是应用的"皮囊",那动画就是应用的"灵魂"。

一个没有动画的应用,就像一张静态的海报——信息都在,但没有生气。好的动画能让界面"活"起来:按钮按下的反馈、页面切换的过渡、列表滚动的惯性、加载时的转动……这些细节累积起来,就是"高级感"的来源。

但动画不是越多越好。乱用动画,会让界面显得花哨、廉价,甚至干扰用户操作。好的动画应该是"润物细无声"的——用户感觉到舒服,但说不出来为什么舒服。

ArkUI 提供了非常丰富的动画能力:属性动画、显式动画、转场动画、弹簧动画、路径动画……但很多开发者只会用最简单的 .animation() 加个持续时间,其他的都不太清楚。

本文我们就从动画的本质讲起,深入到 ArkUI 的三大动画体系,结合「民族图鉴」项目中的实际场景(卡片点击效果、页面转场、点赞动画、下拉刷新等),带你系统性地掌握鸿蒙动画开发。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解动画的本质与核心要素(时间、插值、属性变化)
  • ✅ 掌握属性动画(component animation)的原理与使用场景
  • ✅ 掌握显式动画(animateTo)的原理与使用技巧
  • ✅ 理解转场动画(transition)的三种类型与使用方式
  • ✅ 学会弹簧动画、缓动曲线的选择与参数调优
  • ✅ 掌握列表动画、共享元素动画等高级效果
  • ✅ 避免动画的常见坑,写出流畅、自然、高性能的动画

💡 需求分析

为什么需要动画?

动画不是"锦上添花"的装饰品,它有实实在在的功能价值:

1. 反馈用户操作

  • 用户点了按钮,按钮缩一下,用户就知道"我点到了"
  • 没有反馈,用户会疑惑"刚才点到了吗?",甚至重复点击

2. 引导用户注意力

  • 新内容出现时,淡入 + 从下往上滑,用户自然就注意到了
  • 重要的操作结果,用动画强调,用户不容易忽略

3. 表达空间关系

  • 页面从右边滑进来 = “这是下一层页面”
  • 页面从底部弹上来 = “这是一个临时弹窗”
  • 元素从 A 位置过渡到 B 位置 = “这是同一个东西”

4. 提升品牌质感

  • 统一的动画风格,是产品调性的一部分
  • 动画做得好,用户会觉得"这个 App 很精致"

动画的设计原则

在写代码之前,先搞清楚"什么样的动画是好动画":

原则 说明 反例
动画时长 200-300ms,不要让用户等 一个转场动画 1 秒钟,用户急死
缓动曲线自然,不要生硬的线性动画 匀速运动,像机器人一样
动画要克制,不要什么都动 满屏飞,用户不知道看哪
动画有意义,不是为了动而动 毫无理由的旋转、缩放
动画不能卡,60fps 是底线 掉帧严重,还不如不做

💡 动画的黄金法则:用户感觉不到动画的存在,但感觉应用"很流畅"、“很跟手”——这就是最好的动画。

「民族图鉴」的动画场景

「民族图鉴」项目中有很多典型的动画场景:

场景 动画类型 技术方案
卡片按下效果 属性动画 .animation() + 状态变量
页面切换 转场动画 animateTo + transition
收藏点赞动效 显式动画 animateTo + 弹簧曲线
数字滚动(浏览数) 属性动画 @State + 计算属性
Tab 切换下划线 属性动画 .animation()
下拉刷新 组合动画 旋转 + 位移动画
图片加载淡入 显式动画 animateTo + opacity
悬浮窗展开收起 转场动画 transition

🛠️ 核心实现

步骤1:动画的核心概念

在讲具体的 API 之前,先搞懂动画的几个核心概念。所有动画系统,不管是 Web 的 CSS 动画、Android 的属性动画、还是 iOS 的 CoreAnimation,底层原理都是一样的。

1.1 动画的本质

动画的本质是什么?

动画 = 属性值随时间变化

比如一个位移动画,就是把 translateY 的值,在 300ms 内,从 0 变到 100。每一帧更新一次值,重绘一次,连起来就是动画了。

时间(ms):   0    50   100   150   200   250   300
translateY:  0    10   30    50    70    90   100

每一帧(通常是 16.6ms 一帧,60fps)更新一次属性值,渲染引擎根据新的值重绘界面。因为帧之间的变化很小,人眼就看到了"连续的动画"。

1.2 动画的三要素

任何动画都有三个核心要素:

1. 动画属性(Property)
什么属性在变?位置?大小?透明度?颜色?旋转?

2. 时长(Duration)
动画持续多久?100ms?300ms?1秒?

3. 缓动曲线(Easing / Curve)
属性值怎么随时间变化?匀速?先快后慢?先慢后快?还是弹一下?

1.3 缓动曲线(Easing Curve)

缓动曲线是动画的"灵魂"。同样的时长、同样的属性变化,用不同的曲线,感觉完全不一样。

常见的缓动曲线

曲线 特点 适用场景
Linear(线性) 匀速变化 旋转、进度条
EaseIn(缓入) 先慢后快 元素离场(从屏幕出去)
EaseOut(缓出) 先快后慢 元素进场(进入屏幕)
EaseInOut(缓入缓出) 先慢后快再慢 大多数场景,最自然
Spring(弹簧) 超出后弹回来 强调性动画,比如点赞
Bounce(弹跳) 像球落地一样弹 趣味性动画,慎用

为什么 EaseOut 最常用?

因为现实世界的物体运动大多是"先快后慢"的——你扔一个东西,它因为摩擦力/空气阻力逐渐减速;你开车,从高速刹车到停止,也是先快后慢。

EaseOut 符合人的直觉,所以看起来最自然。

1.4 FPS 与流畅度

动画要流畅,就要达到 60fps(每秒 60 帧)。也就是每一帧只有 16.6ms 的时间。

如果某一帧的计算时间超过了 16.6ms,这一帧就没赶上,就会出现"掉帧"——用户感觉到"卡"。

60fps = 16.6ms/帧
120fps = 8.3ms/帧(高刷屏)

所以,动画的性能很重要。动画过程中不要做耗时计算,不要频繁创建对象,不要触发大量重绘。

💡 体感流畅度

动画是否流畅,不光看 FPS,还要看曲线。一个 60fps 但用 Linear 的动画,可能还不如一个 30fps 但用 EaseOut 的动画看起来顺。因为人的感知是"非线性"的。


步骤2:属性动画(Component Animation)

属性动画是 ArkUI 中最简单、最常用的动画方式。给组件加一个 .animation() 修饰符,当它的属性变化时,就会自动产生动画。

2.1 基本用法
@Component
struct DemoPage {
  @State isEnlarged: boolean = false;

  build() {
    Column() {
      Image($r('app.media.example'))
        .width(this.isEnlarged ? 200 : 100)
        .height(this.isEnlarged ? 200 : 100)
        .borderRadius(this.isEnlarged ? 24 : 12)
        .animation({
          duration: 300,
          curve: Curve.EaseInOut,
        })
        .onClick(() => {
          this.isEnlarged = !this.isEnlarged;
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

isEnlarged 变化时,所有在 .animation() 之前的可动画属性都会自动过渡。

可动画属性包括

  • 尺寸:width、height、size
  • 位置:offset、position、translate
  • 变换:scale、rotate、opacity
  • 外观:backgroundColor、borderRadius、borderWidth
  • 文本:fontSize、fontColor
  • ……等等

不可动画属性

  • 子组件的增减
  • 组件的类型变化
  • 列表的数据源变化(可以用 ForEach 的动画)
2.2 动画的作用范围

.animation() 修饰符遵循一个重要规则:

动画只作用于它前面的属性,以及它所在组件的子组件。

Column() {
  Text('Hello')
    .fontSize(20)      // ❌ 不受下面的 animation 影响(因为 animation 不在 Text 上)
  
  Image($r('app.media.icon'))
    .width(100)        // ✅ 受 animation 影响
    .height(100)       // ✅ 受 animation 影响
    .animation({       // ⚠️ 这是 Image 的 animation
      duration: 300,
    })
    .backgroundColor(Color.Red)  // ❌ 在 animation 后面,不受影响!
}

注意.animation() 写在哪个组件上,就作用于哪个组件。写在父组件上,不会自动作用于子组件(除非子组件也有 animation)。

2.3 实战:卡片按下效果

这是「民族图鉴」中最常用的动画——卡片按下时缩小,松开时恢复。

@Component
export struct EthnicGridCard {
  @State isPressed: boolean = false;

  private ethnic: EthnicBrief;
  private onClick?: () => void;

  build() {
    Column({ space: 8 }) {
      Image(this.ethnic.imageUrl)
        .width(60)
        .height(60)
        .borderRadius(30)
        .objectFit(ImageFit.Cover)

      Text(this.ethnic.name)
        .fontSize(14)
        .fontColor($r('app.color.text_primary'))
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('100%')
    .padding(12)
    .borderRadius(12)
    .backgroundColor($r('app.color.card_background'))
    // 👇 动画:所有属性变化都用 150ms EaseInOut
    .animation({
      duration: 150,
      curve: Curve.EaseInOut,
    })
    // 👇 按下时缩小到 95%,透明度 0.8
    .scale({ x: this.isPressed ? 0.95 : 1, y: this.isPressed ? 0.95 : 1 })
    .opacity(this.isPressed ? 0.8 : 1)
    .onTouch((event: TouchEvent) => {
      if (event.type === TouchType.Down) {
        this.isPressed = true;
      } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
        this.isPressed = false;
        if (event.type === TouchType.Up && this.onClick) {
          this.onClick();
        }
      }
    })
  }
}

为什么用 scale 而不是改 width/height?

因为 scale 是变换属性,不会影响布局,性能更好。如果改 width/height,不仅组件本身要重绘,它周围的组件也可能要重新布局,开销大很多。

为什么时长 150ms?

  • 按下反馈要"跟手",不能太慢
  • 100ms 以下用户感觉不到,200ms 以上觉得拖沓
  • 150ms 是一个比较舒服的数值——快,但不会太突兀
2.4 实战:Tab 切换下划线动画

首页的 Tab 切换,下划线从一个 Tab 平滑滑动到另一个 Tab,也是属性动画的经典应用。

@Component
struct HomeTabs {
  @State currentIndex: number = 0;
  private tabs: string[] = ['推荐', '民族', '文化', '美食', '服饰'];

  build() {
    Row() {
      ForEach(this.tabs, (tab: string, index: number) => {
        Text(tab)
          .fontSize(16)
          .fontWeight(index === this.currentIndex ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(index === this.currentIndex
            ? $r('app.color.primary')
            : $r('app.color.text_secondary'))
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
          .onClick(() => {
            this.currentIndex = index;
          })
      }, (tab: string) => tab)
    }
    .position({ x: 0, y: 0 })
    // 👇 下划线
    .overlay(
      Line()
        .width(24)
        .height(3)
        .backgroundColor($r('app.color.primary'))
        .borderRadius(1.5)
        // 👇 下划线位置用属性动画
        .animateMargin({
          left: this.calculateUnderlineOffset(),
        })
        .animation({
          duration: 250,
          curve: Curve.EaseInOut,
        }),
      { align: Alignment.BottomStart }
    )
  }

  private calculateUnderlineOffset(): number {
    // 计算下划线的左偏移
    // 每个 Tab 的宽度 * index + 居中偏移
    return 0; // 简化,实际计算根据 Tab 宽度
  }
}
2.5 属性动画的优缺点

优点

  • ✅ 写法简单,加个修饰符就行
  • ✅ 自动追踪属性变化,不用手动控制
  • ✅ 性能好,框架做了优化

缺点

  • ❌ 粒度粗——要么所有属性都动,要么都不动
  • ❌ 不好控制动画的开始和结束时机
  • ❌ 不好做复杂的组合动画

步骤3:显式动画(animateTo)

属性动画是"被动"的——属性变了才动。而显式动画是"主动"的——你告诉框架"接下来这段代码里的状态变化,要用动画过渡"。

3.1 基本用法
@Component
struct DemoPage {
  @State isShow: boolean = false;
  @State offsetY: number = 0;

  build() {
    Column() {
      Button('点击开始动画')
        .onClick(() => {
          // 👇 显式动画:花 300ms 用 EaseOut 曲线过渡状态变化
          animateTo({
            duration: 300,
            curve: Curve.EaseOut,
          }, () => {
            this.isShow = true;
            this.offsetY = -50;
          });
        })

      if (this.isShow) {
        Text('Hello Animation')
          .fontSize(18)
          .translate({ y: this.offsetY })
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

animateTo 接受两个参数:

  1. 动画配置:时长、曲线、延迟、重复次数等
  2. 动画闭包:在这个闭包里修改状态,所有状态变化都会用动画过渡
3.2 动画的回调

有时候你需要知道动画什么时候结束,好做下一步操作:

animateTo({
  duration: 300,
  curve: Curve.EaseOut,
  onFinish: () => {
    // 动画结束回调
    console.info('动画结束了');
    this.doSomethingAfterAnimation();
  },
}, () => {
  this.isShow = true;
});

注意:onFinish 在动画正常结束时触发。如果动画被打断(比如又触发了新的动画),onFinish 不会触发。

3.3 实战:收藏点赞动画

民族详情页的收藏按钮,点一下,爱心放大又弹回来,这就是典型的弹簧动画。

@Component
struct FavoriteButton {
  @State isFavorite: boolean = false;
  @State bounceScale: number = 1;
  private onClick?: (isFav: boolean) => void;

  build() {
    Image(this.isFavorite
      ? $r('app.media.heart_filled')
      : $r('app.media.heart_outline'))
      .width(28)
      .height(28)
      .fillColor(this.isFavorite ? '#FF4D4F' : '#999999')
      .scale({ x: this.bounceScale, y: this.bounceScale })
      .onClick(() => {
        this.handleClick();
      })
  }

  private handleClick(): void {
    this.isFavorite = !this.isFavorite;

    // 👇 点击时的弹跳效果
    animateTo({
      duration: 150,
      curve: Curve.EaseOut,
    }, () => {
      this.bounceScale = 1.3; // 先放大
    });

    // 👇 延时弹回来,用弹簧效果
    animateTo({
      duration: 400,
      curve: Curve.Spring, // 弹簧曲线
      delay: 150,          // 等放大结束再弹回来
      onFinish: () => {
        this.bounceScale = 1;
      },
    }, () => {
      this.bounceScale = 1;
    });

    if (this.onClick) {
      this.onClick(this.isFavorite);
    }
  }
}

为什么用两个 animateTo 而不是一个?

因为"放大 → 弹回"是两个阶段。用两个动画串联起来,比用一个曲线更灵活。

弹簧曲线(Spring)的参数

Spring 曲线有几个参数可以调:

animateTo({
  curve: curves.springMotion({
    response: 0.3,    // 响应速度,越大越慢
    dampingFraction: 0.8, // 阻尼系数,1.0 不弹,越小弹得越厉害
    overlapDuration: 0,
  }),
}, () => {
  // ...
});
  • response(响应时间):弹簧完成一次完整振动的时间。越大越慢,越小越快。
  • dampingFraction(阻尼系数)
    • 0 = 完全不衰减,永远弹下去(不要用)
    • < 1 = 欠阻尼,会弹几下才停
    • = 1 = 临界阻尼,刚好不弹,最快到达
    • > 1 = 过阻尼,慢悠悠过去

💡 调弹簧参数的技巧
先定 response(通常 0.2-0.5s),再调 dampingFraction。
想要弹一下的效果:0.6-0.8
想要干脆的效果:0.9-1.0
太弹了就加阻尼,太慢了就减 response。

3.4 实战:数字滚动动画

浏览量、收藏数这些数字变化时,不要直接跳变,而是"滚"到目标值,更有质感。

@Component
struct AnimatedNumber {
  @State displayValue: number = 0;
  private targetValue: number = 0;
  private duration: number = 500;

  aboutToAppear(): void {
    this.displayValue = this.targetValue;
  }

  build() {
    Text(Math.round(this.displayValue).toString())
      .fontSize(14)
      .fontColor($r('app.color.text_secondary'))
  }

  // 外部调用这个方法,数字就会滚动到目标值
  animateToValue(target: number): void {
    this.targetValue = target;
    animateTo({
      duration: this.duration,
      curve: Curve.EaseOut,
    }, () => {
      this.displayValue = target;
    });
  }
}

原理:用一个 displayValue 状态变量,动画过程中它从旧值平滑过渡到新值,每一帧重新计算并显示。

进阶:格式化数字

如果要显示"1.2万"、"3.4k"这样的格式,可以用 computed 或者 getter:

get formattedValue(): string {
  const val = Math.round(this.displayValue);
  if (val >= 10000) {
    return (val / 10000).toFixed(1) + '万';
  } else if (val >= 1000) {
    return (val / 1000).toFixed(1) + 'k';
  }
  return val.toString();
}
3.5 动画的组合与串联

复杂的动画效果,往往需要多个动画组合起来。

并行动画(同时动):

// 在一个 animateTo 里改多个状态,它们会同时动
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
  this.opacity = 1;
  this.offsetY = 0;
  this.scale = 1;
});

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

// 用 delay 错开时间
animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
  this.opacity = 1;
});

animateTo({ duration: 300, curve: Curve.EaseOut, delay: 100 }, () => {
  this.offsetY = 0;
});

或者用 onFinish 串联:

animateTo({
  duration: 200,
  curve: Curve.EaseOut,
  onFinish: () => {
    animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
      this.offsetY = 0;
    });
  },
}, () => {
  this.opacity = 1;
});

💡 推荐用 delay 而不是 onFinish 串联

因为 onFinish 只有动画正常结束才会触发,如果动画被打断,后面的就不执行了。而 delay 的方式,每个动画都是独立的,更可控。

3.6 显式动画的优缺点

优点

  • ✅ 控制力强——想让什么动就让什么动
  • ✅ 可以做复杂的组合动画
  • ✅ 有完成回调,可以串联逻辑

缺点

  • ❌ 写法稍复杂,要用 animateTo 包裹
  • ❌ 如果到处都是 animateTo,代码会比较乱

步骤4:转场动画(Transition)

转场动画专门用于处理组件的出现和消失——比如一个组件从无到有、从有到无,怎么过渡?

4.1 基本用法
@Component
struct DemoPage {
  @State isShow: boolean = false;

  build() {
    Column() {
      Button('切换显示')
        .onClick(() => {
          animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
            this.isShow = !this.isShow;
          });
        })

      if (this.isShow) {
        Text('Hello Transition')
          .fontSize(18)
          .padding(20)
          .backgroundColor(Color.Orange)
          .borderRadius(8)
          // 👇 转场效果:出现时淡入 + 从下往上,消失时相反
          .transition({
            type: TransitionType.All,
            opacity: 1,
            translate: { y: 20 },
          })
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

.transition() 修饰符定义了组件"出现"和"消失"时的过渡效果。注意:transition 必须配合 animateTo 使用,只改 isShow 不加 animateTo 是不会有动画的。

4.2 转场的三种类型

TransitionType 有三种:

类型 说明
TransitionType.All 出现和消失都用同一个效果(自动反向)
TransitionType.Insert 只定义出现时的效果
TransitionType.Remove 只定义消失时的效果

分别定义出现和消失

.transition(
  // 出现时:从左边滑入 + 淡入
  TransitionEffect.INSERT
    .translate({ x: -100, y: 0 })
    .opacity(0)
    .animation({ duration: 300, curve: Curve.EaseOut })
)
.transition(
  // 消失时:从右边滑出 + 淡出
  TransitionEffect.REMOVE
    .translate({ x: 100, y: 0 })
    .opacity(0)
    .animation({ duration: 200, curve: Curve.EaseIn })
)

为什么出现和消失可以用不同的时长?

因为出现是"元素来迎接用户",可以慢一点(300ms),让用户看清;消失是"元素离开",应该快一点(200ms),不要让用户等。

4.3 常用转场效果

1. 淡入淡出

.transition({
  type: TransitionType.All,
  opacity: 0,
})

最朴素的转场,适合弹窗、提示条等。

2. 滑入滑出

// 从底部滑入
.transition({
  type: TransitionType.All,
  translate: { y: 100 },
  opacity: 0,
})

// 从右侧滑入
.transition({
  type: TransitionType.All,
  translate: { x: 100 },
  opacity: 0,
})

适合页面、抽屉、底部弹窗。

3. 缩放

.transition({
  type: TransitionType.All,
  scale: { x: 0.8, y: 0.8 },
  opacity: 0,
})

适合按钮、气泡、卡片。

4. 组合效果

.transition({
  type: TransitionType.All,
  opacity: 0,
  translate: { y: 20 },
  scale: { x: 0.9, y: 0.9 },
})

淡入 + 上移 + 放大,组合起来效果更丰富。这也是 iOS 上很经典的一种转场。

4.4 实战:悬浮播放条的出现/消失

「民族图鉴」音乐页的悬浮播放条,从底部滑上来,消失时滑下去。

@Component
struct MiniPlayer {
  @State isVisible: boolean = false;
  @State currentSong: MusicInfo | null = null;

  build() {
    Column() {
      // 页面其他内容...
      
      if (this.isVisible && this.currentSong) {
        Row() {
          Image(this.currentSong.coverUrl)
            .width(40)
            .height(40)
            .borderRadius(6)

          Column({ space: 4 }) {
            Text(this.currentSong.title)
              .fontSize(14)
              .fontColor($r('app.color.text_primary'))
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            
            Text(this.currentSong.artist)
              .fontSize(12)
              .fontColor($r('app.color.text_secondary'))
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1)
          .margin({ left: 12 })

          Image($r('app.media.play'))
            .width(28)
            .height(28)
            .fillColor($r('app.color.primary'))
        }
        .width('100%')
        .padding(12)
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(12)
        .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
        // 👇 转场:从底部滑入 + 淡入
        .transition({
          type: TransitionType.All,
          translate: { y: 80 },
          opacity: 0,
        })
      }
    }
    .width('100%')
    .height('100%')
  }

  show(song: MusicInfo): void {
    this.currentSong = song;
    animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
      this.isVisible = true;
    });
  }

  hide(): void {
    animateTo({ duration: 200, curve: Curve.EaseIn }, () => {
      this.isVisible = false;
    });
  }
}
4.5 转场动画 vs 属性动画
维度 属性动画 显式动画 转场动画
适用场景 属性值变化 任意状态变化 组件出现/消失
触发方式 属性变化自动触发 animateTo 包裹 if/else + animateTo
控制力 弱(所有属性都动) 强(精确控制) 中(定义进出效果)
复杂度 简单 中等 中等
性能

实际上,这三种动画经常配合使用。比如:

  • 用 animateTo 控制 isShow
  • 用 transition 定义组件怎么出现
  • 用属性动画定义组件内部的属性变化

步骤5:高级动画技巧

5.1 列表动画(ForEach 动画)

列表项的增加、删除、排序,也可以有动画。

@Component
struct AnimatedList {
  @State items: string[] = ['A', 'B', 'C', 'D', 'E'];

  build() {
    List() {
      ForEach(this.items, (item: string, index: number) => {
        ListItem() {
          Text(item)
            .fontSize(16)
            .width('100%')
            .height(56)
            .padding({ left: 16 })
            .backgroundColor($r('app.color.card_background'))
            .borderRadius(8)
        }
        .transition({
          type: TransitionType.All,
          opacity: 0,
          translate: { x: -50 },
        })
        .animation({
          duration: 300,
          curve: Curve.EaseOut,
        })
      }, (item: string, index: number) => `${item}_${index}`)
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }

  addItem(newItem: string): void {
    animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
      this.items.unshift(newItem);
    });
  }

  removeItem(index: number): void {
    animateTo({ duration: 250, curve: Curve.EaseIn }, () => {
      this.items.splice(index, 1);
    });
  }
}

ForEach 动画的关键

  1. 用 animateTo 包裹数据修改
  2. 给 ListItem 加 transition 定义进出效果
  3. key 函数要返回稳定且唯一的值

错峰动画(Staggered Animation)

让列表项一个接一个地出现,而不是同时出现,效果更高级:

// 在 ForEach 中,根据 index 加不同的 delay
ForEach(this.items, (item: string, index: number) => {
  ListItem() {
    // ...
  }
  .transition({
    type: TransitionType.Insert,
    opacity: 0,
    translate: { y: 20 },
  })
  .animation({
    duration: 300,
    curve: Curve.EaseOut,
    delay: index * 50,  // 每个项延迟 50ms
  })
}, (item: string) => item)
5.2 旋转动画

加载指示器、下拉刷新的图标,都需要旋转动画。

@Component
struct LoadingSpinner {
  @State rotateAngle: number = 0;
  private isSpinning: boolean = false;

  build() {
    Image($r('app.media.loading'))
      .width(24)
      .height(24)
      .rotate({ angle: this.rotateAngle })
      .animation({
        duration: 800,
        curve: Curve.Linear,  // 旋转用线性曲线
        iterations: -1,       // -1 表示无限循环
        playMode: PlayMode.Normal,
      })
  }

  start(): void {
    this.isSpinning = true;
    this.rotateAngle = 360; // 触发旋转
  }

  stop(): void {
    this.isSpinning = false;
    this.rotateAngle = 0;
  }
}

注意旋转用 Linear 曲线,因为旋转是匀速的,用 EaseOut 会忽快忽慢,很奇怪。

5.3 共享元素动画

从列表页进入详情页时,列表中的图片"放大"变成详情页的大图——这就是共享元素动画。

// 列表页
@Component
struct ListPage {
  @State selectedId: string = '';
  @State isShowDetail: boolean = false;

  build() {
    Stack() {
      // 列表
      List() {
        ForEach(this.items, (item: EthnicBrief) => {
          ListItem() {
            Image(item.imageUrl)
              .width(80)
              .height(80)
              .borderRadius(8)
              // 共享元素 id,要和详情页一致
              .sharedTransition(`image_${item.id}`, {
                duration: 300,
                curve: Curve.EaseInOut,
              })
              .onClick(() => {
                this.selectedId = item.id;
                animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
                  this.isShowDetail = true;
                });
              })
          }
        }, (item: EthnicBrief) => item.id)
      }

      // 详情页
      if (this.isShowDetail) {
        DetailPage(ethnicId: this.selectedId)
          .transition({ type: TransitionType.All, opacity: 0 })
      }
    }
    .width('100%')
    .height('100%')
  }
}

// 详情页
@Component
struct DetailPage {
  private ethnicId: string;

  build() {
    Column() {
      Image(this.ethnic.imageUrl)
        .width('100%')
        .height(300)
        // 同一个共享元素 id
        .sharedTransition(`image_${this.ethnicId}`, {
          duration: 300,
          curve: Curve.EaseInOut,
        })
      // ...
    }
    .width('100%')
    .height('100%')
  }
}

共享元素动画的本质,就是"两个看起来是同一个东西的元素,在位置和大小之间平滑过渡"。框架会自动计算起点和终点的位置、大小,中间帧自动插值。

5.4 手势驱动动画

很多高级交互效果,是手势和动画结合的——比如手指拖动,跟着手指动;手指松开,弹回原位。

@Component
struct DraggableCard {
  @State offsetX: number = 0;
  @State offsetY: number = 0;
  @State isDragging: boolean = false;

  build() {
    Column() {
      Text('拖动我')
        .fontSize(16)
        .fontColor(Color.White)
    }
    .width(200)
    .height(120)
    .backgroundColor($r('app.color.primary'))
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    // 👇 位移用属性动画
    .translate({ x: this.offsetX, y: this.offsetY })
    .scale({ x: this.isDragging ? 1.05 : 1, y: this.isDragging ? 1.05 : 1 })
    .animation({
      duration: this.isDragging ? 0 : 300,  // 拖动时零延迟(跟手),松开时有动画
      curve: Curve.EaseOut,
    })
    .gesture(
      PanGesture()
        .onActionStart(() => {
          this.isDragging = true;
        })
        .onActionUpdate((event: GestureEvent) => {
          this.offsetX += event.offsetX;
          this.offsetY += event.offsetY;
        })
        .onActionEnd(() => {
          this.isDragging = false;
          // 弹回原位
          animateTo({ duration: 300, curve: Curve.Spring }, () => {
            this.offsetX = 0;
            this.offsetY = 0;
          });
        })
    )
  }
}

手势驱动动画的关键

  • 拖动过程中:零延迟,完全跟手(duration = 0)
  • 松开时:用动画过渡到最终位置(带曲线)
  • 配合弹簧效果,体验更好

⚠️ 常见问题与解决方案

问题1:动画卡顿,掉帧严重

现象
动画不流畅,一卡一卡的,特别是低端机上更明显。

可能原因及解决方案

原因1:动画过程中有大量计算

// ❌ 不好:每次属性变化都计算一次
.animation({ duration: 300 })
.width(this.calculateWidth()) // 如果 calculateWidth 很耗时,动画过程中每帧都算

解决:提前算好,动画过程中只改变量。

// ✅ 好:提前计算好,动画只改简单的值
@State targetWidth: number = 200;

// 在初始化或者点击时计算好
this.targetWidth = this.calculateWidth();

原因2:触发了大量布局重排

改 width、height、padding、margin 这些影响布局的属性,会触发父组件、兄弟组件的重新布局,开销很大。

解决:尽量用 transform 属性(translate、scale、rotate)代替布局属性。

// ❌ 不好:改 width 触发布局
.width(this.isShow ? 200 : 100)

// ✅ 好:用 scale,不影响布局
.scale({ x: this.isShow ? 2 : 1, y: this.isShow ? 2 : 1 })

原因3:动画的元素太多

一整个页面同时做动画,每个元素都要重绘,当然卡。

解决

  • 减少同时动画的元素数量
  • 用错峰(stagger),一个一个来
  • 不重要的元素直接跳变,不用动画

💡 性能优化第一原则:先测再优化。用 DevEco Studio 的性能分析工具,看看是哪一帧慢,慢在哪里。不要靠猜。


问题2:动画结束后状态不对

现象
动画结束了,但显示的状态不对——比如应该消失的组件还在,应该显示的没显示。

常见原因:动画被打断了。

比如一个 300ms 的消失动画,刚执行了 100ms,又触发了"显示"的动画。这时候第一个动画的 onFinish 不会执行,如果你的逻辑写在 onFinish 里,就不会执行。

解决

  • 不要过度依赖 onFinish,状态驱动是根本
  • 用状态变量控制最终结果,动画只是"过渡过程"
  • 如果一定要等动画结束,加个标记位
// ❌ 不好:依赖 onFinish
hide() {
  animateTo({
    duration: 300,
    onFinish: () => {
      this.isShow = false; // 如果动画被打断,这行不会执行
    },
  }, () => {
    this.opacity = 0;
  });
}

// ✅ 好:状态和动画一起变
hide() {
  animateTo({ duration: 300 }, () => {
    this.isShow = false; // 状态和动画一起,就算被打断也没关系
    this.opacity = 0;
  });
}

问题3:转场动画不生效

现象
加了 .transition(),但组件出现/消失时没有动画,直接跳变。

常见原因

原因1:忘了加 animateTo

transition 只是定义了"转场效果是什么样的",但你需要用 animateTo 来触发它。

// ❌ 不好:直接改,没动画
this.isShow = !this.isShow;

// ✅ 好:用 animateTo 包裹
animateTo({ duration: 300 }, () => {
  this.isShow = !this.isShow;
});

原因2:transition 加在了错误的组件上

transition 要加在"被 if/else 控制的那个组件"上。

// ❌ 不好:transition 加在 Column 上,但 Column 没有被 if 控制
Column() {
  if (this.isShow) {
    Text('Hello')
  }
}
.transition(...) // 没用,Column 一直都在

// ✅ 好:加在被 if 控制的组件上
if (this.isShow) {
  Text('Hello')
    .transition(...) // 正确,Text 会出现/消失
}

原因3:父组件没有 animation

有时候父组件布局变化的动画,也会影响子组件的转场。


问题4:动画太"假",不自然

现象
动画是动了,但感觉很生硬、很廉价,不够"高级"。

常见原因及改进方法

原因1:用了 Linear 曲线

除了旋转和进度条,其他场景尽量不要用 Linear。匀速运动不符合现实世界的物理规律,看起来很"机械"。

解决:用 EaseOut 或者 EaseInOut。

原因2:时长不对

  • 太短(< 100ms):感觉不到动画,等于没有
  • 太长(> 500ms):拖沓,用户等得着急

推荐时长

  • 微交互(按钮按下、开关):100-200ms
  • 元素出现/消失:200-350ms
  • 页面转场:300-400ms
  • 强调性动画(点赞、提醒):400-600ms

原因3:动画太多太杂

什么都想加动画,结果满屏乱飞,用户不知道看哪里。

解决

  • 动画要有重点,重要的操作才加动画
  • 同一时刻,最多 1-2 个元素在动
  • 克制,克制,再克制

💡 好的动画,用户感觉不到"动画",只觉得"流畅"。
如果用户注意到"哇,这个动画好酷",那这个动画可能太抢戏了。


问题5:动画在低端机上性能差

现象
高端机上很流畅,低端机上掉帧严重。

优化策略

1. 降级策略

检测设备性能,低端机关掉一些非核心动画。

// 简单判断:设备性能等级(实际可以用系统 API 获取)
const isLowEndDevice = this.checkDevicePerformance();

if (!isLowEndDevice) {
  // 只有非低端机才开启动画
  animateTo({ duration: 300 }, () => {
    this.isShow = true;
  });
} else {
  // 低端机直接跳变
  this.isShow = true;
}

2. 减少重绘区域

动画元素加 .renderGroup(true),让它在独立的渲染层里,不影响其他元素。

Image(...)
  .width(100)
  .height(100)
  .translate({ x: this.offsetX })
  .renderGroup(true) // 独立渲染层

3. 降低动画复杂度

  • 透明度 + 位移 就够了,不要再加阴影、模糊等效果
  • 减少同时动画的元素数量
  • 缩短动画时长

📝 本章小结

核心知识点

本文从动画的本质讲起,系统介绍了 ArkUI 的三大动画体系:

1. 动画基础概念

  • 动画 = 属性值随时间变化
  • 三要素:属性、时长、缓动曲线
  • 60fps = 16.6ms/帧,流畅度的底线
  • EaseOut 是最自然的曲线,大多数场景推荐

2. 属性动画(.animation())

  • 被动触发:属性变化自动过渡
  • 作用范围:修饰符之前的属性
  • 适合:简单的状态变化、按钮反馈、布局调整
  • 优点:简单、自动
  • 缺点:控制粒度粗

3. 显式动画(animateTo)

  • 主动控制:指定闭包内的状态变化用动画
  • 有完成回调,可以串联逻辑
  • 适合:复杂动画、组合动画、需要精确控制的场景
  • 弹簧动画(Spring)适合强调性效果

4. 转场动画(.transition())

  • 专门用于组件出现/消失
  • 三种类型:All / Insert / Remove
  • 必须配合 animateTo 使用
  • 常用效果:淡入淡出、滑入滑出、缩放

5. 高级动画

  • 列表动画:ForEach + transition
  • 共享元素动画:sharedTransition
  • 手势驱动动画:拖动 + 松手回弹
  • 旋转动画:Linear 曲线 + 无限循环

最佳实践总结

优先用属性动画,不够用再上显式动画

能简单就简单。属性动画搞定的,就不要用 animateTo。
代码越简单,越不容易出错。

EaseOut 是默认选择

大多数动画用 EaseOut,自然又舒服。
进场用 EaseOut,离场用 EaseIn,中间过渡用 EaseInOut。
旋转、进度条用 Linear。

动画时长 150-400ms 之间

微交互:100-200ms
元素出现:200-350ms
页面转场:300-400ms
超过 500ms 用户会觉得慢。

用 transform 代替布局属性

translate 比改 position 好
scale 比改 width/height 好
transform 不影响布局,性能更好

动画要克制

不是越多越好,而是恰到好处。
重要的操作才加动画,不重要的直接跳变。
好的动画用户感觉不到,只觉得流畅。

性能优先,不跟手不如没有

卡的动画比没有动画还糟糕。
性能不够就降级,保证流畅是底线。
先测再优化,不要靠感觉。

下一步预告

在下一篇文章中,我们将:

  • 🧩 学习如何从零封装一个高质量的自定义组件
  • 📐 掌握组件的 Props 设计、事件定义、插槽(Slot)使用
  • 🎨 理解组件的样式定制与主题适配
  • 🔄 深入组件的生命周期与状态管理
  • 🚀 用一个「民族详情页头部组件」的完整案例,带你掌握自定义组件的精髓

🔗 相关链接


💡 提示:动画是一个"审美 + 技术"的活。技术好学,审美难练。多看看优秀的 App,注意它们的动画细节——时长、曲线、组合方式。看得多了,自然就知道什么样的动画是好的。然后再回到自己的项目里,一点点打磨,你会发现——动画这东西,差 50ms、换条曲线,感觉就完全不一样。

Logo

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

更多推荐