引言

在移动应用体验设计中,动画不仅仅是视觉点缀,更是用户与界面交互的情感纽带。恰当的动画能降低认知负担、增强操作反馈、提升产品质感,尤其在表单校验、删除确认等高频场景中,动画的缺失往往会让用户感到“卡顿”或“不确定”。然而,如何在不同平台上高效实现自然、连贯且可复用的动效,始终是前端开发的挑战之一。

「喵屿」App 作为一款宠物管理类应用,在 HarmonyOS 平台上深度实践了 ArkUI 动画体系,针对批量删除提示单个删除确认表单校验失败三种典型场景,分别采用持续抖动、飞入目标和关键帧抖动等动效方案。本文提炼自该 App 真实代码,展示动画实现细节,帮助开发者快速在 HarmonyOS 项目中落地高品质动效。


一、鸿蒙端属性动画简介

属性动画的核心是通过动画接口驱动组件属性变化,ArkUI 提供了三种核心动画接口——animation、animateTo、keyframeAnimateTo,分别适用于不同场景,可灵活实现各类属性动画效果。

动画接口 作用域 原理 使用场景
animation 组件通过属性接口绑定的属性变化 自动识别组件的可动画属性变化,为其添加动画;组件接口调用自上而下执行,animation 仅作用于其上方的属性调用,支持为多个属性设置不同动画参数 对多个可动画属性配置不同动画参数的场景,需为单个组件的不同属性设置差异化动画
animateTo 闭包内改变属性引起的界面变化 通用函数,对比闭包执行前与闭包内状态变量引起的 UI 差异,为差异部分添加动画;支持多次调用、嵌套使用,闭包内所有属性变化遵循相同动画参数 对多个可动画属性配置相同动画参数的场景,需要嵌套动画的场景;多段动画循环可通过 playMode 和 iterations 实现
keyframeAnimateTo 多个闭包内改变属性引起的分段属性动画 通用函数,每一段闭包中的状态变量与前一次状态的差异,单独生成一段动画;支持多次调用,不推荐嵌套,每段动画可单独配置参数 同一属性需要实现连续多个动画的场景,避免多次创建动画导致的衔接卡顿

1. 基本属性动画(animation)

animation 是最简洁的属性动画实现方式,直接在组件上设置 animation 属性,即可为组件的可动画属性变化添加动画效果,无需额外复杂配置,适合简单场景的快速实现。

核心步骤:声明状态变量 → 将状态变量绑定到组件可动画属性 → 设置 animation 动画参数 → 触发状态变量变化,触发动画。

import { curves } from '@kit.ArkUI';
@Entry
@Component
struct AttrAnimationDemo3 {
  // 控制动画状态的变量
  @State animate: boolean = false;
  
  // 声明相关状态变量,绑定组件可动画属性
  @State rotateValue: number = 0; // 组件一旋转角度
  @State translateX: number = 0; // 组件二水平偏移量
  @State opacityValue: number = 1; // 组件透明度

  // 构建 UI 界面
  build() {
    Row() {
      // 组件一:旋转+透明度动画
      Column() {
      }
      // 绑定透明度属性
      .opacity(this.opacityValue)
      // 绑定旋转属性
      .rotate({ angle: this.rotateValue })
      // 设置 animation 属性,配置动画参数
      .animation({
        curve: curves.springMotion() // 使用弹簧动画曲线
      })
      // 样式设置
      .backgroundColor('#317AF7')
      .justifyContent(FlexAlign.Center)
      .width(100)
      .height(100)
      .borderRadius(30)
      // 点击事件处理
      .onClick(() => {
        // 切换动画状态
        this.animate = !this.animate;
        
        // 修改状态变量,触发属性变化,进而触发动画
        this.rotateValue = this.animate ? 90 : 0; // 旋转动画:90度/0度
        this.translateX = this.animate ? 50 : 0; // 组件二偏移动画:50px/0px
        this.opacityValue = this.animate ? 0.6 : 1; // 透明度动画:0.6/1
      })

      // 组件二:平移+透明度动画
      Column() {
      }
      // 样式设置
      .justifyContent(FlexAlign.Center)
      .width(100)
      .height(100)
      .backgroundColor('#D94838')
      .borderRadius(30)
      // 绑定透明度属性
      .opacity(this.opacityValue)
      // 绑定平移属性
      .translate({ x: this.translateX })
      // 设置 animation 属性,配置动画参数
      .animation({
        curve: curves.springMotion() // 使用弹簧动画曲线
      })
    }
    // 容器样式设置
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

说明:点击组件一后,状态变量发生变化,组件一的 rotate 属性、组件二的 translate 和 opacity 属性随之变化,animation 接口自动为这些属性变化添加弹簧动画,实现平滑过渡。

2. 显式动画(animateTo)

animateTo 是显式动画接口,通过传入动画参数和闭包函数,闭包内所有属性变化将遵循统一的动画参数,适合需要为多个组件、多个属性设置相同动画效果的场景,支持嵌套使用,灵活性更高。

接口定义

animateTo(value: AnimateParam, event: () => void): void

参数说明:value 为动画参数配置(AnimateParam 对象),event 为闭包函数,闭包内的状态变量变化所引发的 UI 差异,将自动应用该动画参数。

import { curves } from '@kit.ArkUI';
@Entry
@Component
struct AttrAnimateToDemo2 {
  // 控制动画状态的变量
  @State animate: boolean = false;
  
  // 声明相关状态变量
  @State rotateValue: number = 0; // 组件一旋转角度
  @State translateX: number = 0; // 组件二水平偏移量
  @State opacityValue: number = 1; // 组件透明度

  // 构建 UI 界面
  build() {
    Row() {
      // 组件一
      Column() {
      }
      // 绑定旋转属性
      .rotate({ angle: this.rotateValue })
      // 样式设置
      .backgroundColor('#317AF7')
      .justifyContent(FlexAlign.Center)
      .width(100)
      .height(100)
      .borderRadius(30)
      // 点击事件处理
      .onClick(() => {
        // 调用 animateTo 接口,配置动画参数和闭包
        this.getUIContext()?.animateTo({
          curve: curves.springMotion() // 使用弹簧动画曲线
        }, () => {
          // 切换动画状态
          this.animate = !this.animate;
          
          // 闭包内修改状态变量,所有属性变化遵循相同动画参数
          this.rotateValue = this.animate ? 90 : 0; // 组件一旋转:90度/0度
          this.opacityValue = this.animate ? 0.6 : 1; // 组件二透明度:0.6/1
          this.translateX = this.animate ? 50 : 0; // 组件二平移:50px/0px
        })
      })

      // 组件二
      Column() {
      }
      // 样式设置
      .justifyContent(FlexAlign.Center)
      .width(100)
      .height(100)
      .backgroundColor('#D94838')
      .borderRadius(30)
      // 绑定透明度属性
      .opacity(this.opacityValue)
      // 绑定平移属性
      .translate({ x: this.translateX })
    }
    // 容器样式设置
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

说明:与 animation 接口不同,animateTo 将动画参数统一配置在接口中,闭包内所有属性变化(旋转、平移、透明度)均使用相同的弹簧曲线,无需为每个组件单独设置 animation,适合批量控制动画效果。

3. 关键帧动画(keyframeAnimateTo)

keyframeAnimateTo 用于实现分段动画,通过传入整体动画参数和关键帧数组,每一段关键帧可单独配置时长、曲线等参数,适合同一属性需要连续执行多个动画的场景,避免多次创建动画导致的衔接卡顿。

接口定义

keyframeAnimateTo(param: KeyframeAnimateParam, keyframes: Array<KeyframeState>): void

参数说明:param 为关键帧动画整体参数(如重复次数、延迟、结束回调);keyframes 为关键帧数组,每一项包含当前关键帧的时长、曲线、闭包事件(属性变化逻辑)。

@Entry
@Component
struct KeyframeAnimateToDemo {
  // 声明相关状态变量
  @State rotateValue: number = 0; // 组件一旋转角度
  @State translateX: number = 0; // 组件二水平偏移量
  @State opacityValue: number = 1; // 组件透明度

  // 构建 UI 界面
  build() {
    Row() {
      // 组件一
      Column() {
      }
      // 绑定旋转属性
      .rotate({ angle: this.rotateValue })
      // 样式设置
      .backgroundColor('#317AF7')
      .justifyContent(FlexAlign.Center)
      .width(100)
      .height(100)
      .borderRadius(30)
      // 点击事件处理
      .onClick(() => {
        // 调用 keyframeAnimateTo 接口,配置整体参数和关键帧数组
        this.getUIContext()?.keyframeAnimateTo({
          iterations: 1 // 整体动画重复次数
        }, [
          {
            // 第一段关键帧:800ms,弹簧曲线,属性变化
            duration: 800, // 动画持续时间:800ms
            curve: curves.springMotion(), // 动画曲线:弹簧曲线
            event: () => {
              // 第一段动画的属性变化
              this.rotateValue = 90; // 组件一顺时针旋转90度
              this.opacityValue = 0.6; // 组件二透明度降低
              this.translateX = 50; // 组件二向右偏移50px
            }
          },
          {
            // 第二段关键帧:500ms,缓出曲线,属性恢复
            duration: 500, // 动画持续时间:500ms
            curve: Curve.EaseOut, // 动画曲线:缓出曲线
            event: () => {
              // 第二段动画的属性变化
              this.rotateValue = 0; // 组件一恢复初始角度
              this.opacityValue = 1; // 组件二透明度恢复
              this.translateX = 0; // 组件二恢复初始位置
            }
          }
        ]);
      })

      // 组件二
      Column() {
      }
      // 样式设置
      .justifyContent(FlexAlign.Center)
      .width(100)
      .height(100)
      .backgroundColor('#D94838')
      .borderRadius(30)
      // 绑定透明度属性
      .opacity(this.opacityValue)
      // 绑定平移属性
      .translate({ x: this.translateX })
    }
    // 容器样式设置
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

说明:点击组件一后,将依次执行两段关键帧动画:第一段 800ms 实现组件旋转、偏移和透明度变化,第二段 500ms 实现属性恢复,两段动画衔接自然,无卡顿,适合需要多阶段动画的场景(如复杂的组件交互反馈)。


二、喵屿项目中三种动画实现

「喵屿」App 在宠物管理、表单校验等场景中大量使用了动画来提升交互体验。以下是三种核心动画模式的实现详解。

2.1 删除模式抖动(setInterval + animation)

场景:管理互动宠物页面进入删除模式时,网格中的宠物图标持续左右摆动,提示用户可点击删除。

核心技术setInterval 定时切换角度状态 + .rotate() + .animation() 平滑插值。

// 状态变量
@State shakeAngle: number = 0;
private shakeTimer: number = -1;

// 启动抖动:每 100ms 在 +1.5° 和 -1.5° 之间交替
startShake(): void {
  if (this.shakeTimer !== -1) return; // 防重入
  let direction = 1;
  this.shakeTimer = setInterval(() => {
    this.shakeAngle = direction * 1.5; // 交替正负角度
    direction *= -1;
  }, 100);
}

// 停止抖动:清除定时器,复位角度
stopShake(): void {
  if (this.shakeTimer !== -1) {
    clearInterval(this.shakeTimer);
    this.shakeTimer = -1;
  }
  this.shakeAngle = 0;
}

UI 绑定:在 GridItem 上应用 rotate + animation,仅对非删除中的宠物生效。

GridItem() {
  // ...宠物图片...
}
.rotate({
  angle: (this.isDeleteMode && this.deletingUri !== pet.uri) ? this.shakeAngle : 0
})
.animation({ duration: 80, curve: Curve.Linear })

工作原理

  1. setInterval 每 100ms 将 shakeAngle 设置为 +1.5 或 -1.5
  2. .animation({ duration: 80 }) 在状态变化间进行 80ms 的匀速插值
  3. 80ms 动画 + 20ms 静置 = 100ms 完整周期,产生快速左右摆动效果
  4. Curve.Linear 保证往返速度一致,视觉上均匀抖动

2.2 飞入垃圾桶(animateTo)

场景:用户确认删除宠物后,宠物图片从原位置飞向垃圾桶图标,同时缩小到 0.08 倍并淡出,500ms 后执行实际删除。

核心技术getUIContext().animateTo() 命令式动画,同时驱动 scale、opacity、translate。

步骤 1:计算目标位置

async animateDelete(index: number): Promise<void> {
  const pet = this.customPets[index];
  this.deletingUri = pet.uri; // 标记为"正在删除",在原网格中隐藏

  // 获取被删宠物在屏幕上的位置
  const itemPos = this.itemPositions[pet.uri];
  // 计算垃圾桶图标的中心坐标
  let targetX = 0;
  let targetY = -120; // 默认向上偏移
  if (itemPos && this.trashIconX > 0) {
    const trashCX = this.trashIconX + 12;   // 垃圾桶中心 X
    const trashCY = this.trashIconY + 12;   // 垃圾桶中心 Y
    const itemCX = itemPos.x + itemPos.w / 2; // 宠物中心 X
    const itemCY = itemPos.y + itemPos.h / 2; // 宠物中心 Y
    targetX = trashCX - itemCX; // 水平位移
    targetY = trashCY - itemCY; // 垂直位移
  }

步骤 2:执行动画(scale + opacity + translate)

  // 初始化动画起始状态
  this.deleteAnimTranslateX = 0;
  this.deleteAnimTranslateY = 0;
  this.deleteAnimScale = 1;
  this.deleteAnimOpacity = 1;

  // animateTo:500ms EaseOut 驱动三个属性同时变化
  this.getUIContext().animateTo(
    { duration: 500, curve: Curve.EaseOut },
    () => {
      this.deleteAnimScale = 0.08;              // 缩小到 8%
      this.deleteAnimOpacity = 0;               // 完全透明
      this.deleteAnimTranslateX = targetX;      // 水平移动到垃圾桶
      this.deleteAnimTranslateY = targetY;      // 垂直移动到垃圾桶
    }
  );

  // 500ms 后执行实际的数据删除
  setTimeout(() => {
    this.executeDelete(index);
  }, 500);
}

步骤 3:Overlay 层渲染 — 在页面最上层放置一个与原始位置相同的 Image,应用动画状态:

if (this.deletingUri) {
  Image(this.deletingUri)
    .width(this.deleteOverlayW)
    .height(this.deleteOverlayH)
    .objectFit(ImageFit.Cover)
    .position({ x: this.deleteOverlayX, y: this.deleteOverlayY })
    .scale({ x: this.deleteAnimScale, y: this.deleteAnimScale })  // 缩放到 0.08
    .opacity(this.deleteAnimOpacity)                               // 淡出到 0
    .translate({ x: this.deleteAnimTranslateX, y: this.deleteAnimTranslateY })
    .hitTestBehavior(HitTestMode.None) // 不拦截其他手势
}

设计要点

  • 在原始网格中将宠物设为 Visibility.Hidden,同时在 overlay 层渲染相同的图片
  • animateTo 闭包内一次修改多个属性,框架自动并行驱动
  • Curve.EaseOut(快入慢出)让动画结束时有"落入"垃圾桶的视觉感受
  • 动画持续 500ms,setTimeout 同步等待 500ms 后执行 executeDelete()

具体效果:
在这里插入图片描述


2.3 表单校验抖动(keyframeAnimateTo)

场景:用户在添加/编辑表单中未填写必填字段点击保存时,输入框或提示文字左右抖动,视觉提示用户。

核心技术uiContext.keyframeAnimateTo({ iterations: 2 }, [ ... ]) 两帧关键帧动画。

标准实现(水平抖动):

@State nameTranslateX: number = 0;

startAnimation(): void {
  const uiContext = this.getUIContext?.();
  if (!uiContext) return;

  this.nameTranslateX = 0;
  // iterations: 2 = 两帧 × 2 次 = 完整来回
  uiContext.keyframeAnimateTo({ iterations: 2 }, [
    {
      // 帧 1:100ms 内 translateX 从 0 到 5
      duration: 100,
      event: () => { this.nameTranslateX = 5; }
    },
    {
      // 帧 2:100ms 内 translateX 从 5 回到 0
      duration: 100,
      event: () => { this.nameTranslateX = 0; }
    }
  ]);
}

UI 绑定:将 translateX 应用到需要抖动的元素:

TextInput({ placeholder: "请输入宠物名称", text: $$this.name })
  .translate({ x: this.nameTranslateX })
// ...
.position({ x: this.nameTranslateX, y: 0 })

变体 1 — 垂直抖动(疫苗/驱虫列表项):

仅方向不同(translateY 代替 translateX),时长加倍(200ms),曲线更平滑:

startAnimation(): void {
  const uiContext = this.getUIContext?.();
  if (!uiContext) return;
  this.translateY = 0;

  uiContext.keyframeAnimateTo({ iterations: 2 }, [
    {
      duration: 200,
      curve: Curve.Smooth, // 平滑过渡,比默认更柔和
      event: () => { this.translateY = -5; } // 向上抖动
    },
    {
      duration: 200,
      curve: Curve.Smooth,
      event: () => { this.translateY = 0; }
    }
  ]);
}

变体 2 — 多字段抖动:

startAnimation(name: boolean = true, total: boolean = true): void {
  const uiContext = this.getUIContext?.();
  if (!uiContext) return;
  this.nameTranslateX = 0;
  this.totalTranslateX = 0;

  uiContext.keyframeAnimateTo({ iterations: 2 }, [
    {
      duration: 100,
      event: () => {
        if (name) this.nameTranslateX = 5;
        if (total) this.totalTranslateX = 5;
      }
    },
    {
      duration: 100,
      event: () => {
        if (name) this.nameTranslateX = 0;
        if (total) this.totalTranslateX = 0;
      }
    }
  ]);
}

调用方式(以 CatManagement.ets 为例):

// 校验:名称为空时触发抖动 + Toast 提示
if (StringUtil.isEmpty(this.name)) {
  VibrateUtil.alarmVibrate();         // 触觉反馈:警告振动
  this.startAnimation();              // 视觉反馈:抖动动画
  this.promptAction.showToast({       // 文字反馈:Toast 提示
    message: '请输入宠物名称'
  });
  return;
}

在这里插入图片描述


三、总结与最佳实践

「喵屿」App 在提醒与删除场景中成功落地了三种 HarmonyOS 动画模式,下表总结了各自的核心技术、适用场景与关键参数:

模式 核心技术 适用场景 参数要点
持续抖动 setInterval + .rotate().animation() 编辑模式提示(批量删除) 幅度 1.5°、间隔 100ms、Curve.Linear
飞入目标 animateTo() 删除确认动画(单个删除) duration 500ms、Curve.EaseOut、scale 0.08
校验抖动 keyframeAnimateTo() 表单校验失败提示 iterations 2、每帧 100ms、位移 5vp

核心设计原则

1. 选型原则
动画接口 核心选择原则 典型使用场景
animation 为组件的不同属性设置不同的动画参数时使用。 按钮点击时,背景色渐变与缩放使用不同曲线。
animateTo 多个属性(可能跨组件)设置相同的动画参数,或需要动画嵌套时使用。 页面布局切换时,多个组件同时以相同方式移动和淡入淡出。
keyframeAnimateTo 同一属性创建连续、多段动画时使用。 实现类似“呼吸灯”效果(缩放-暂停-缩放),或复杂的路径动画。

通用指导原则

  • 优先使用animation:当只需为单个组件的属性添加动画,且参数各异时,这是最简洁的方式。
  • 需要同步控制多个动画时用animateTo:当多个属性或组件需要以相同的动画参数协同变化时,使用animateTo可以避免代码重复,并确保动画同步。
  • 需要复杂序列动画时用keyframeAnimateTo:当单个属性需要经历一系列不同的状态变化(如移动、停顿、再移动)时,关键帧动画是最佳选择,它能确保动画流畅衔接。
  • 性能考虑:属性动画(以上三种)的性能通常优于帧动画(ohos.animator),因此在满足需求的前提下应优先选择属性动画接口。帧动画仅在需要逐帧精确控制或可暂停能力时考虑。
2. 多模态反馈增强

动画不应孤立存在,与触觉反馈(VibrateUtil.buttonVibrate / alarmVibrate)结合使用,能显著提升用户对操作结果的感知强度。例如表单校验失败时,“抖动动画 + 警告振动 + Toast 提示”三管齐下,形成完整的错误反馈闭环。

3. 性能与可复用性
  • 飞入垃圾桶动画采用 Overlay 层独立渲染,避免原网格组件重新布局,性能开销最小化。
  • 将抖动启动/停止、关键帧构建等逻辑封装为 AnimationUtil 工具类,可被任意页面调用,降低重复代码。
  • 注意 keyframeAnimateTo 每次调用前需重置状态变量,否则可能产生意外的跳帧。

结语

通过「喵屿」App 的实践可以看出,HarmonyOS 的 ArkUI 动画体系既提供了简洁的声明式接口(animation),也保留了强大的命令式控制能力(animateTokeyframeAnimateTo)。开发者可根据业务场景自由组合,在保证性能的前提下,为用户带来更具情感和确定性的交互体验。本文提炼的三种动画模式及配套的最佳实践,可直接复制到任何 HarmonyOS项目中,帮助快速实现高品质的提醒与删除动效。


Logo

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

更多推荐