Administrator
发布于 2026-09-03 / 1,016 阅读
0
0

鸿蒙动画系统进阶:animateTo、曲线编排与复杂交互动画的工程化

系列文章《鸿蒙开发从入门到专家》第 53 篇 · 高级

第 7 篇讲自定义组件与动画时覆盖了基础用法。这篇往工程化方向走:多个动画怎么编排、曲线怎么选才不突兀、复杂手势交互场景下动画怎么管理。

ArkUI 的动画体系分三层:属性动画(.animation)、显式动画(animateTo)、转场动画(.transition)。三层各有适用场景,混用容易出问题。

一、三种动画的边界

类型

触发方式

适用场景

控制力

.animation

属性变化自动触发

简单状态切换(展开/折叠)

弱,只能配时长和曲线

animateTo

闭包内显式修改状态

精确控制多属性联动

强,可配 delay/iterations/playMode

.transition

组件插入/移除时触发

列表项增删、页面切换

中,支持淡入淡出+位移

核心区别:.animation 是"你改了属性,框架自动给你加动画",animateTo 是"你告诉框架这一段变化要用动画完成"。前者被动,后者主动。

1.1 属性动画

@Entry
@Component
struct PropertyAnimationDemo {
  @State expand: boolean = false;

  build() {
    Column() {
      Text('Click to expand')
        .fontSize(14)
        .padding(16)
        .backgroundColor(this.expand ? '#E8E8E8' : '#FFFFFF')
        .height(this.expand ? 200 : 48)
        .animation({
          duration: 300,
          curve: Curve.EaseInOut
        })
        .onClick(() => {
          this.expand = !this.expand;
        })
    }
  }
}

expand 变化后,backgroundColor、height 的变化会自动产生 300ms 的过渡动画。代码简洁,但缺点是你无法精确控制动画的开始时机,也无法在动画中做条件判断。

1.2 显式动画

import { curves } from '@kit.ArkUI';

@Entry
@Component
struct AnimateToDemo {
  @State scale: number = 1;
  @State rotation: number = 0;
  @State opacity: number = 1;

  build() {
    Column() {
      Image($r('app.media.icon'))
        .width(120)
        .height(120)
        .scale({ x: this.scale, y: this.scale })
        .rotate({ angle: this.rotation })
        .opacity(this.opacity)

      Button('Play Animation')
        .onClick(() => {
          this.getUIContext()?.animateTo({
            duration: 500,
            curve: curves.easeOut,
            onFinish: () => {
              // 动画结束后的回调
              console.info('Animation finished');
            }
          }, () => {
            this.scale = 1.5;
            this.rotation = 180;
            this.opacity = 0.6;
          });
        })
    }
  }
}

animateTo 的闭包内同时修改三个属性,框架会检测到差异,给三个属性都加动画。它们同步开始、同步结束。

二、曲线选择

曲线决定动画的"节奏感"。选错了,动画要么生硬要么拖沓。

2.1 内置曲线

曲线

视觉效果

适用场景

Linear

匀速

进度条、旋转、持续动画

EaseIn

慢→快

元素退场(飞出屏幕)

EaseOut

快→慢

元素入场(弹入屏幕)

EaseInOut

慢→快→慢

通用过渡(最常用)

FastOutSlowIn

快→慢(Material 标准)

Android 风格过渡

Friction

摩擦感

拖拽惯性、回弹

springMotion

弹簧物理

拖拽跟随、松手回弹

选曲线的口诀:

  • 入场用 EaseOut:元素快速出现再减速停下,自然。

  • 退场用 EaseIn:元素缓慢启动再加速消失,不拖泥带水。

  • 状态切换用 EaseInOut:对称过渡,前后都平滑。

  • 拖拽回弹用 springMotion:有物理感,不像程序控制。

2.2 自定义曲线

内置曲线不够用时,用 curves 模块创建贝塞尔曲线:

import { curves } from '@kit.ArkUI';

// 三阶贝塞尔:cubicBezier(x1, y1, x2, y2)
const customCurve = curves.cubicBezierCurve(0.25, 0.1, 0.25, 1);

// 弹簧曲线:springMotion(response, dampingFraction, overlapDuration)
const springCurve = curves.springMotion(0.5, 0.8, 0);

// 响应式弹簧:responsiveSpringMotion(response, dampingFraction, overlapDuration)
const responsiveSpring = curves.responsiveSpringMotion(0.5, 0.8, 0);

springMotion 和 responsiveSpringMotion 的区别:前者创建固定参数的弹簧曲线,后者会根据当前动画状态自适应调整参数(比如当前正在执行另一个弹簧动画时,会平滑衔接而不是打断)。

三、多段动画编排

实际项目中单个动画很少见,常见的是"先缩小再弹大再回正"这种多段动画。

3.1 嵌套回调法

最直观但不好维护:

private playElasticScale(): void {
  // 第一段:缩小(200ms)
  this.getUIContext()?.animateTo({
    duration: 200,
    curve: Curve.EaseIn,
    onFinish: () => {
      // 第二段:弹大(300ms)
      this.getUIContext()?.animateTo({
        duration: 300,
        curve: Curve.EaseOut,
        onFinish: () => {
          // 第三段:回正(200ms)
          this.getUIContext()?.animateTo({
            duration: 200,
            curve: Curve.EaseInOut
          }, () => {
            this.scale = 1;
          });
        }
      }, () => {
        this.scale = 1.15;
      });
    }
  }, () => {
    this.scale = 0.9;
  });
}

三层嵌套,改一个时长要数缩进。不推荐在复杂场景使用。

3.2 状态机法

把动画状态抽象成有限状态机:

enum AnimState {
  IDLE,
  SHRINK,
  BOUNCE,
  SETTLE
}

@Entry
@Component
struct StateMachineDemo {
  @State scale: number = 1;
  @State animState: AnimState = AnimState.IDLE;

  private config: Record<AnimState, { duration: number, curve: Curve, target: number, next: AnimState }> = {
    [AnimState.IDLE]: { duration: 0, curve: Curve.Linear, target: 1, next: AnimState.SHRINK },
    [AnimState.SHRINK]: { duration: 200, curve: Curve.EaseIn, target: 0.9, next: AnimState.BOUNCE },
    [AnimState.BOUNCE]: { duration: 300, curve: Curve.EaseOut, target: 1.15, next: AnimState.SETTLE },
    [AnimState.SETTLE]: { duration: 200, curve: Curve.EaseInOut, target: 1, next: AnimState.IDLE },
  };

  playAnimation(): void {
    this.advance();
  }

  private advance(): void {
    const conf = this.config[this.animState];
    if (conf.duration === 0) {
      this.animState = conf.next;
      this.advance();
      return;
    }
    this.getUIContext()?.animateTo({
      duration: conf.duration,
      curve: conf.curve,
      onFinish: () => {
        if (conf.next !== AnimState.IDLE) {
          this.animState = conf.next;
          this.advance();
        }
      }
    }, () => {
      this.scale = conf.target;
    });
  }

  build() {
    Column() {
      Image($r('app.media.icon'))
        .scale({ x: this.scale, y: this.scale })
      Button('Play').onClick(() => this.playAnimation())
    }
  }
}

状态多了之后维护成本仍然高,但逻辑清晰,每段动画的参数集中管理。

3.3 AnimatedGroup 封装

更工程化的做法是封装一个动画序列管理器:

interface AnimStep {
  duration: number;
  curve: Curve | ICurve;
  apply: () => void;
}

class AnimationSequence {
  private steps: AnimStep[] = [];
  private currentStep: number = 0;
  private onFinish?: () => void;

  addStep(duration: number, curve: Curve, apply: () => void): AnimationSequence {
    this.steps.push({ duration, curve, apply });
    return this;
  }

  play(uiContext: UIContext, onFinish?: () => void): void {
    this.onFinish = onFinish;
    this.currentStep = 0;
    this.playNext(uiContext);
  }

  private playNext(uiContext: UIContext): void {
    if (this.currentStep >= this.steps.length) {
      this.onFinish?.();
      return;
    }
    const step = this.steps[this.currentStep];
    uiContext.animateTo({
      duration: step.duration,
      curve: step.curve,
      onFinish: () => {
        this.currentStep++;
        this.playNext(uiContext);
      }
    }, step.apply);
  }
}

// 使用
const sequence = new AnimationSequence()
  .addStep(200, Curve.EaseIn, () => { this.scale = 0.9; })
  .addStep(300, Curve.EaseOut, () => { this.scale = 1.15; })
  .addStep(200, Curve.EaseInOut, () => { this.scale = 1; });

sequence.play(this.getUIContext()!);

四、手势交互动画

拖拽场景是最需要动画工程化的地方。用户拖动元素时,元素跟随手指;松手时,根据拖拽速度决定回弹或吸附。

import { curves } from '@kit.ArkUI';

@Entry
@Component
struct DragAnimationDemo {
  @State offsetX: number = 0;
  @State offsetY: number = 0;
  @State scale: number = 1;
  private startX: number = 0;
  private startY: number = 0;

  build() {
    Stack() {
      Image($r('app.media.card'))
        .width(200)
        .height(280)
        .translate({ x: this.offsetX, y: this.offsetY })
        .scale({ x: this.scale, y: this.scale })
        .gesture(
          PanGesture()
            .onActionStart((event: GestureEvent) => {
              this.startX = this.offsetX;
              this.startY = this.offsetY;
              // 拖拽开始时放大
              this.getUIContext()?.animateTo({
                duration: 150,
                curve: curves.springMotion(0.3, 0.7)
              }, () => {
                this.scale = 1.1;
              });
            })
            .onActionUpdate((event: GestureEvent) => {
              // 拖拽中实时跟随手指,不用动画
              this.offsetX = this.startX + event.offsetX;
              this.offsetY = this.startY + event.offsetY;
            })
            .onActionEnd((event: GestureEvent) => {
              // 松手后根据偏移量决定回弹或飞出
              if (Math.abs(this.offsetX) > 150) {
                // 飞出
                this.getUIContext()?.animateTo({
                  duration: 300,
                  curve: Curve.EaseIn
                }, () => {
                  this.offsetX = this.offsetX > 0 ? 400 : -400;
                  this.scale = 0.5;
                });
              } else {
                // 回弹
                this.getUIContext()?.animateTo({
                  duration: 400,
                  curve: curves.springMotion(0.5, 0.8)
                }, () => {
                  this.offsetX = 0;
                  this.offsetY = 0;
                  this.scale = 1;
                });
              }
            })
        )
    }
    .width('100%')
    .height('100%')
  }
}

关键点:

  • 拖拽中(onActionUpdate)直接赋值,不用动画。手指移动时每帧都触发回调,如果再套 animateTo 会和手势产生帧冲突。

  • 拖拽开始和结束用 springMotion 曲线。弹簧曲线的物理感比 EaseOut 自然得多。

  • 松手后的逻辑根据偏移量分支处理,类似 Tinder 的卡片滑动。

五、转场动画

组件插入或移除时的过渡效果,用 .transition:

@Entry
@Component
struct TransitionDemo {
  @State items: number[] = [1, 2, 3];

  build() {
    Column() {
      Button('Add')
        .onClick(() => {
          this.getUIContext()?.animateTo({ duration: 300 }, () => {
            this.items.push(this.items.length + 1);
          });
        })

      ForEach(this.items, (item: number) => {
        Text(`Item ${item}`)
          .width('100%')
          .height(50)
          .textAlign(TextAlign.Center)
          .backgroundColor('#F5F5F5')
          .transition({
            type: TransitionType.All,
            opacity: 0,
            translate: { y: 30 }
          })
      }, (item: number) => item.toString())
    }
  }
}

TransitionType.All 表示插入和移除都触发。插入时从 opacity=0, y=30 过渡到 opacity=1, y=0。移除时反向。

注意:使用 transition 必须在 animateTo 闭包内修改数据源,否则框架检测不到变化。

六、性能注意

动画是性能消耗大户。几条原则:

  1. 只动画 transform 和 opacity。 这两个属性走 GPU 合成层,不触发布局重排。动画 width/height/margin 会触发布局重新计算,大量消耗。

  2. 避免动画中触发 build。 animateTo 闭包内修改 @State 会触发组件刷新,但框架做了优化,只刷新受影响的属性。如果在 onFinish 回调里再修改其他 @State,可能触发额外 build。

  3. 长列表中的动画用 .animation 而非 animateTo。 列表项被回收时,animateTo 的闭包引用可能泄漏。.animation 是声明式的,框架管理生命周期。

  4. iterations: -1(无限循环)要小心。 无限循环动画在组件销毁时不会自动停止,需要在 aboutToDisappear 中清理。

小结

动画工程化的核心是"状态管理 + 曲线选择 + 编排控制"。简单场景用属性动画,精确控制用 animateTo,组件增删用 transition。多段动画优先用序列管理器而非嵌套回调。拖拽交互用 springMotion 曲线获得物理感。

下篇讲复杂表单。动态渲染、校验体系、字段联动,是每个中大应用都绕不过的。

参考阅读:

  • 第 7 篇:鸿蒙自定义组件与动画

  • 第 50 篇:设计模式在鸿蒙里的应用(策略模式部分)

  • 官方文档:curves 模块、animateTo


评论