终点、光圈与 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 简单表示为:
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/else或switch决定当前需要渲染哪个子树; - 在动效引擎中只修改
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。