终点、光圈与 Lottie 生命周期:复杂关闭动效的「收尾工程」

终点、光圈与 Lottie 生命周期:复杂关闭动效的「收尾工程」

关键词:ArkUI、ETS、Lottie、Canvas、生命周期、健壮性

在多阶段关闭动效中,「从哪儿飞出去」只是前半程;
真正让用户觉得自然的,是「飞到哪儿」以及「怎么在终点收尾」。

这一篇我们聚焦三件事:

  • 终点区域如何抽象为一个通用的 TargetRect
  • TargetRect 推导出 icon 的终点坐标和光圈(halo)的大小与偏移;
  • Lottie/Canvas 在 ArkUI 场景下的生命周期管理与健壮性设计。 TL;DR:用 TargetRect 描述落点 → 算出 icon/halo 参数 → 把 Lottie 生命周期与事件兜底串起来,就能让关闭动画有一个"扎实的落点"。

一、终点抽象:TargetRect 而不是「某业务组件」

在具体业务中,关闭动效的终点可能是:

  • 首页某个「入口图标」;
  • 底部 Tab 上的一个按钮;
  • 页面内的某个卡片收纳位。

但在动效引擎里,我们不想知道这些细节,只希望拿到一个抽象的矩形

ts 复制代码
export interface TargetRect {
  x: number    // 左上角 x
  y: number    // 左上角 y
  width: number
  height: number
}

外部世界(原生层/业务组件)通过某种回调把 TargetRect 提供给动效引擎:

ts 复制代码
type TargetResolver = () => TargetRect | null

class CloseAnimationEngine {
  constructor(private resolveTarget: TargetResolver) {}

  initTargetArea() {
    const rect = this.resolveTarget()
    if (!rect || rect.width <= 0 || rect.height <= 0) {
      // 非法参数,后续走兜底关闭
      this.markTargetInvalid()
      return
    }
    // 后续基于 rect 计算终点和光圈
  }
}

设计要点:

  • 动效引擎只接收 TargetRect,不直接依赖任何具体组件或业务字段;
  • 参数在入口处就做完校验,后续逻辑只处理「合法的矩形」或「无终点(走兜底)」两种情况。

二、从 TargetRect 到 icon 终点:中心对齐与缩放后的尺寸

假设我们最终要让一个缩小后的弹窗内容(或者独立 icon)落在 TargetRect 中央;

并且这个 icon 本身也会有一个缩放比例 finalScale(例如 0.3),对应某个容器宽高 contentWidth/Height

我们希望:

  • icon 缩放后的视觉矩形,刚好居中放在 TargetRect 中;
  • 即使 TargetRect 很小,icon 也不会太小看不见(这一点可交给业务配置兜底)。

中心对齐的计算可以写成:

ts 复制代码
function computeIconEndPosition(
  rect: TargetRect,
  contentSize: { width: number; height: number },
  finalScale: number,
): { x: number; y: number } {
  const scaledWidth = contentSize.width * finalScale
  const scaledHeight = contentSize.height * finalScale

  const offsetX = (rect.width - scaledWidth) / 2
  const offsetY = (rect.height - scaledHeight) / 2

  const endX = rect.x + offsetX
  const endY = rect.y + offsetY

  return { x: endX, y: endY }
}

这只解决了「缩放后矩形和目标矩形中心对齐」的问题。

结合上一篇的缩放偏移修正,我们会进一步把这个 endX/endY 输入到 applyScaleOffset 中,得到最终供 motionPath 使用的终点坐标。

逻辑链条可以用 mermaid 简单表示为:

graph LR A[TargetRect(x,y,w,h)] --> B[icon缩放尺寸
scaledW,scaledH] B --> C[中心对齐
endX = x + (w - scaledW)/2] C --> D[缩放偏移修正
feed into motionPath endPoint]

若平台不支持 mermaid,可复制到 mermaid.live 查看示意。


三、光圈(Halo):既要包住目标,又不能小得看不见

在终点,我们希望有一个「光圈收尾」:

  • 光圈以 icon 为中心;
  • 光圈的直径至少要覆盖 TargetRect
  • 同时不能小于一个视觉兜底值(例如 132vp)。

因此可以这样计算:

ts 复制代码
function computeHalo(
  rect: TargetRect,
  contentSize: { width: number; height: number },
  finalScale: number,
  minDiameter: number,
) {
  const scaledWidth = contentSize.width * finalScale
  const scaledHeight = contentSize.height * finalScale

  const haloDiameter = Math.max(rect.width, rect.height, minDiameter)

  // 为了让halo中心对齐icon,需要在渲染时做一个偏移
  const haloOffsetX = -(haloDiameter - scaledWidth) / 2
  const haloOffsetY = -(haloDiameter - scaledHeight) / 2

  return {
    diameter: haloDiameter,
    offsetX: haloOffsetX,
    offsetY: haloOffsetY,
  }
}

在渲染层(比如一个 Canvas 容器)中,我们可以这样使用:

ts 复制代码
Canvas(haloCanvasContext)
  .size({ width: halo.diameter, height: halo.diameter })
  .offset({ x: halo.offsetX, y: halo.offsetY })

配合上一节的 icon 终点坐标,整体效果就是:

  • icon 落在 TargetRect 中心;
  • 光圈以 icon 为中心扩散,直径至少覆盖目标区域;
  • 在小目标上仍有足够的视觉存在感。

四、形态切换:dialog → icon → lottie

在关闭动效过程中,我们通常会有三种形态:

  • dialog:原始弹窗形态(矩形卡片);
  • icon:缩小后的图标形态;
  • lottie:终点光圈形态(由 Lottie 播放)。

在 ArkUI 这类声明式 UI 框架中,比较推荐的做法是:

  • 在 UI 层用一个 form 状态描述当前形态;
  • if/elseswitch 决定当前需要渲染哪个子树;
  • 在动效引擎中只修改 form,而不直接操控具体 UI 组件。

伪代码示例:

ts 复制代码
// 状态
@State form: 'dialog' | 'icon' | 'lottie' = 'dialog'

build() {
  Column() {
    if (this.form === 'dialog') {
      this.buildDialogContent()
    } else if (this.form === 'icon') {
      this.buildIcon()
    } else if (this.form === 'lottie') {
      this.buildHalo()
    }
  }
}

在动效引擎里,形态切换大致是这样:

ts 复制代码
// 缩小完成后切换到 icon 形态
stepMap.switchToIcon = {
  change: () => {
    state.form = 'icon'
  },
  animateParam: { duration: 100, onFinish: () => stepper.start(['wobbleIcon']) },
}

// 抛掷完成后,如果需要光圈,则切换到 lottie 形态
function onThrowFinished() {
  if (config.enableHalo) {
    state.form = 'lottie'
    // 更新容器尺寸为halo直径,重置scale等
    state.containerSize = halo.diameter
    state.scale = 1
  } else {
    events.emit('closeAnimationFinished')
  }
}

这样可以确保:

  • UI 结构对形态高度敏感:哪种形态就渲染哪种子树,不会「残留」;
  • 动效引擎只修改状态,UI 渲染由框架接管,符合声明式范式。

五、Lottie + Canvas:在 ArkUI 中管理动画资源

光圈本身通常是通过 Lottie 实现的,ArkUI 提供了基于 Canvas 渲染 Lottie 的能力。

一个通用的做法是:

  • 在 UI 层用一个 Canvas 组件作为容器;
  • 在 Canvas onReady 时初始化 Lottie 动画;
  • 在 Canvas onDisappear 时销毁 Lottie 实例。

伪代码:

ts 复制代码
@Builder
buildHalo() {
  Canvas(this.haloCanvasContext)
    .size({ width: this.halo.diameter, height: this.halo.diameter })
    .offset({ x: this.halo.offsetX, y: this.halo.offsetY })
    .onReady(() => this.initHaloLottie())
    .onDisAppear(() => this.destroyHaloLottie())
}

对应的 Lottie 生命周期管理:

ts 复制代码
initHaloLottie() {
  const sourcePath = 'halo.json'
  let rawContent: Uint8Array | undefined

  try {
    rawContent = uiContext.getHostContext()?.resourceManager.getRawFileContentSync(sourcePath)
  } catch (error) {
    logger.error('Failed to load halo lottie file', error)
    this.emitCloseFinished() // 资源异常时直接兜底关闭
    return
  }

  const jsonText = TextDecoder.create('utf-8').decodeToString(rawContent)
  const animationData = JSON.parse(jsonText)

  // 避免重复实例
  lottie.destroy('halo')

  this.animationItem = lottie.loadAnimation({
    name: 'halo',
    animationData,
    container: this.haloCanvasContext,
    renderer: 'canvas',
    autoplay: true,
    loop: false,
    frameRate: 60,
  })

  this.animationItem.addEventListener('complete', () => {
    this.emitCloseFinished()
  })
}

destroyHaloLottie() {
  lottie.destroy('halo')
  this.animationItem = undefined
}

关键点:

  • 每次加载前先 destroy 同名实例,避免重复占用资源;
  • Lottie 生命周期与 Canvas 生命周期严格绑定,Canvas 消失时必须销毁动画;
  • complete 事件触发关闭完成信号,超时兜底(见下一节)则作为 backup。

六、健壮性设计:一次性事件、非法参数、超时兜底

复杂动效最怕的不是「不够炫」,而是「出问题时把用户卡死在半路」。

为了让关闭动效可靠可控,我们在设计时刻意做了几件「防御性编程」的事情:

1. TargetRect 严格校验

resolveTarget 返回值入口处就做完所有合法性校验:

ts 复制代码
const rect = resolveTarget()
if (!rect || rect.width <= 0 || rect.height <= 0) {
  logger.warn('Invalid target rect, fallback to simple close')
  this.useWhirlwind = false   // 或者标记为无终点
}

这样后续所有计算都可以假定 TargetRect 是合法的,不必在各处重复判空。

2. 动效开始事件一次性消费

无论是从原生层还是业务层发出的「开始关闭动效」信号,都建议:

  • 用一次性订阅(例如 emitter.once),防止一段生命周期内多次触发;
  • 或者在动效引擎里加一个「运行中」状态标记,忽略后续重复触发。
ts 复制代码
emitter.once('CLOSE_ANIMATION_START', (payload: CloseAnimPayload) => {
  if (!this.canRunWhirlwind(payload)) {
    this.emitCloseFinished()
    return
  }
  this.startWithPayload(payload)
})

3. 超时兜底:永远保证弹窗能被关闭

即使我们做了完整的 complete 监听,也难以避免以下情况:

  • Lottie 内部异常,complete 不触发;
  • Canvas 提前销毁;
  • 某个阶段被外部打断。

因此在开始关闭动效时,可以同时启动一个「安全定时器」:

ts 复制代码
startSafeTimeout() {
  setTimeout(() => {
    if (!this.closed) {
      logger.warn('Close animation timeout, force close')
      this.emitCloseFinished()
    }
  }, 3000) // 3秒兜底
}

这样可以保证:
无论发生什么,用户最多等待一个固定的时间窗口,弹窗一定会被关闭。


七、小结

这一篇我们讲的都是「收尾工程」:

  • 把终点抽象为一个 TargetRect,与具体业务组件解耦;
  • TargetRect 推导出 icon 终点和光圈(halo)的尺寸与偏移,确保视觉上「打得准」且「包得住」;
  • 在 ArkUI 中通过 Canvas + Lottie 实现光圈动画,并使用严格的生命周期管理保证资源可控;
  • 通过一次性事件订阅、参数校验和超时兜底,让复杂动效在异常情况下仍然安全关闭。

到这里,Whirlwind 式关闭动效的技术细节基本完整了:

时间轴上有 AnimationStepper 编排,空间上有双层容器和几何修正,终点有精确的目标映射和光圈收尾。

在最后一篇《性能 & 工程化篇》中,我们会站到更高一层,从性能、埋点、灰度、配置和资源管理等角度,讨论如何把这种复杂动效做成「可以长期演进」的工程能力,而不是一次性的 Demo。

相关推荐
鸿蒙开发工程师—阿辉2 小时前
HarmonyOS 5 高效使用命令:HDC 使用指南
华为·harmonyos
帅哥一天八碗米饭3 小时前
HarmonyOS ArkTS 组件复用详解:理解 @Reusable 装饰器
harmonyos
帅哥一天八碗米饭3 小时前
HarmonyOS ArkTS 实战:阅读器顶部栏“高度收缩 + 背景透明度过渡”(@AnimatableExtend 方案,能直接抄)
harmonyos
万少3 小时前
HarmonyOS6 接入快手 SDK 指南
前端·harmonyos
帅哥一天八碗米饭3 小时前
HarmonyOS ArkTS:自动“缓存池复用监控日志”怎么做
harmonyos
kirk_wang3 小时前
Flutter 鸿蒙项目 Android Studio 点击 Run 失败 ohpm 缺失
flutter·android studio·harmonyos
qq_463408423 小时前
React Native跨平台技术在开源鸿蒙中开发一个奖励兑换模块,增加身份验证和授权机制(如JWT),以防止未授权的积分兑换
react native·开源·harmonyos
Fate_I_C3 小时前
Flutter鸿蒙0-1开发-工具环境篇
flutter·华为·harmonyos·鸿蒙
二流小码农4 小时前
鸿蒙开发:一个底部的曲线导航
android·ios·harmonyos