终点、光圈与 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。

相关推荐
nashane4 小时前
HarmonyOS 6学习:CapsLock键失效诊断与长截图完整实现指南
学习·华为·harmonyos
richard_yuu6 小时前
鸿蒙心理测评模块实战|PHQ-9/GAD7双量表答题、实时计分与结果本地化存储
华为·harmonyos
不爱吃糖的程序媛9 小时前
2026年Electron 鸿蒙PC环境搭建指南
人工智能·华为·harmonyos
nashane9 小时前
HarmonyOS 6学习:长截图功能开发中的滚动拼接与权限处理实战
人工智能·华为·harmonyos
大师兄666810 小时前
从零开发一个 HarmonyOS 输入法——KikaInputMethod 完整拆解
harmonyos·服务卡片·harmonyos6·formkit
Python私教15 小时前
鸿蒙 NEXT 也能接 MCP?用 ArkTS 跑通 AI Agent 工具链
人工智能·华为·harmonyos
Swift社区18 小时前
分布式能力在鸿蒙 PC 上到底怎么用?
分布式·华为·harmonyos
nashane1 天前
HarmonyOS 6学习:外接键盘CapsLock与长截图功能的实战调试与完整解决方案
学习·华为·计算机外设·harmonyos
aqi001 天前
一文理清 HarmonyOS 6.0.2 涵盖的十个升级点
android·华为·harmonyos·鸿蒙·harmony