HarmonyOS 7 GeometryTransition + Window:折叠屏跨姿态共享元素动画的中断收敛与状态回放【鸿蒙心迹】
一、一次发生在 63% 的跳变
FoldMotionReplay 是个相册详情 Demo:在瀑布流点开 IMG-2047,缩略图通过 geometryTransition 放大到详情页。直板机上一直很顺,放到折叠屏后却出现一种难复现的跳变——动画进行到 63% 左右展开设备,图片先贴到旧窗口右下角,再瞬间跳到新布局中央;偶尔还会多压入一层详情路由,返回要按两次。
最早的判断是目标矩形量错了,于是我们在每次窗口变化后重新计算终点。结果更糟:旧动画还持有原布局的节点,新布局又创建了同一个共享元素 ID,两套回调都能写 selectedItem。问题不是“算错一个矩形”,而是一次过渡被窗口姿态变化截断后,没有人负责收敛旧状态。
本次 Demo 把任务固定为 FOLD-1008,页面是 GalleryDetailPage,共享元素是 photo-IMG-2047。处理目标也很具体:在 COMPACT、HALF_FOLDED、EXPANDED 三种布局之间切换时,旧动画先冻结并留下回放快照;新布局完成测量后,只允许当前代次把元素从进度 0.63 回放到 1.00;路由栈始终只有一层详情。

二、不要让窗口回调直接驱动动画
这个故障的第一处坑,是把窗口尺寸变化回调当成动画命令。窗口变化可能连续到来:折叠角度跨过断点、可用区域改变、布局重新测量,都可能触发一次。如果回调里直接 animateTo,每次都会启动一段新的插值,旧完成回调仍可能在稍后执行。
我改成事件归一:窗口层只产出 LayoutSnapshot,里面包含形态、宽高、选中项和单调递增的 epoch。它不找组件,也不改路由。页面下一帧消费最新快照,旧快照因为代次落后自然失效。layoutEpoch=12 对应本次从 HALF_FOLDED 到 EXPANDED 的变化。
下面这段代码解决“高频窗口事件如何只留下最后一次”的问题。WindowBridge 在页面出现时注册,在页面消失时注销;重复注册会让同一宽度产生多个 epoch,是必须在生命周期里明确防住的风险。
type FoldMode = 'COMPACT' | 'HALF_FOLDED' | 'EXPANDED'
interface LayoutSnapshot {
taskId: string
epoch: number
widthVp: number
heightVp: number
mode: FoldMode
selectedItem: string
}
class WindowBridge {
private epoch: number = 11
private attached: boolean = false
private listener?: (snapshot: LayoutSnapshot) => void
attach(windowStage: window.WindowStage,
consumer: (snapshot: LayoutSnapshot) => void): void {
if (this.attached) return
this.attached = true
this.listener = consumer
windowStage.getMainWindowSync().on('windowSizeChange', (size) => {
const widthVp = px2vp(size.width)
const mode: FoldMode = widthVp >= 840 ? 'EXPANDED' :
widthVp >= 600 ? 'HALF_FOLDED' : 'COMPACT'
consumer({ taskId: 'FOLD-1008', epoch: ++this.epoch,
widthVp, heightVp: px2vp(size.height), mode,
selectedItem: 'IMG-2047' })
})
}
detach(mainWindow: window.Window): void {
mainWindow.off('windowSizeChange')
this.listener = undefined
this.attached = false
}
}
这里有个工程取舍:示例用窗口宽度映射形态,是为了让模拟器回归可控;真实产品如果能拿到更明确的设备姿态事件,应把姿态与窗口宽度共同交给策略层。无论来源是什么,UI 层都只消费规范化结果。否则同一个“展开”会被两个通道触发两遍。
三、过渡快照只保存可回放状态
第二处坑是试图把组件引用、动画对象甚至 Node 都塞进快照。它们与旧布局绑定,新布局创建后已经失效。真正需要持久到下一帧的只有纯数据:元素 ID、选中资源、开始/结束矩形、已完成进度、源路由、目标路由和动画代次。
TransitionLedger 因此像一本很小的账。开始动画时登记 RUNNING;窗口变化时把进度 0.63 记成 INTERRUPTED,并让旧 epoch 失去提交资格;目标组件 onAreaChange 报告新矩形后,账本才进入 READY_TO_REPLAY。如果目标还没量测完成,图片保持在稳定占位层,不从一个未知矩形盲飞。
这段代码是中断收敛的核心。注意 interrupt() 不立刻导航,也不创建第二个详情页,只冻结可视进度并升级 epoch。旧动画的结束回调随后到达时,会因为 epoch 不相等而被忽略。
type MotionState = 'IDLE' | 'RUNNING' | 'INTERRUPTED' |
'READY_TO_REPLAY' | 'REPLAYING' | 'REPLAY_DONE'
interface MotionRecord {
elementId: string
itemId: string
epoch: number
progress: number
sourceRoute: string
targetRoute: string
state: MotionState
}
class TransitionLedger {
record: MotionRecord = {
elementId: 'photo-IMG-2047', itemId: 'IMG-2047', epoch: 11,
progress: 0, sourceRoute: 'GalleryPage',
targetRoute: 'GalleryDetailPage', state: 'IDLE'
}
interrupt(progress: number, nextEpoch: number): MotionRecord {
if (this.record.state !== 'RUNNING') return this.record
this.record = { ...this.record, epoch: nextEpoch,
progress: Math.max(0, Math.min(progress, 1)), state: 'INTERRUPTED' }
return this.record
}
acceptTargetArea(epoch: number): boolean {
if (epoch !== this.record.epoch || this.record.state !== 'INTERRUPTED') return false
this.record = { ...this.record, state: 'READY_TO_REPLAY' }
return true
}
}
进度不能从视觉层随便估一个常量。Demo 的 0.63 来自动画开始时间与持续时间的夹取计算,并在中断瞬间冻结。若应用使用弹簧曲线,就应保存曲线的参数或转成可续算的归一进度;本文使用确定性的平滑曲线,避免“回放后速度突然变化”掩盖状态问题。
四、路由只提交一次,动画可以多次重建
第三处坑是把动画完成与路由提交绑死。共享元素过渡被中断时,旧完成回调和新回放回调都有机会调用 router.pushUrl,因此才出现两层详情。修复方式是把路由视为幂等事务:routeToken=IMG-2047:detail 已存在时,只更新布局,不再入栈。
回放从 0.63 到 1.00,并不意味着把动画时长重新跑满。原始时长 320ms,剩余时长按 320 × (1 - 0.63) 计算,最低保留 80ms,避免极短动画成为闪烁。新目标矩形准备好后,只有 ledger 当前 epoch=12 的回调能把状态写成 REPLAY_DONE。
class MotionReplayer {
private committedRoutes: Set<string> = new Set<string>()
ensureDetailRoute(itemId: string): void {
const token = `${itemId}:detail`
if (this.committedRoutes.has(token)) return
this.committedRoutes.add(token)
router.pushUrl({ url: 'pages/GalleryDetailPage', params: { itemId } })
}
replay(ledger: TransitionLedger, expectedEpoch: number): void {
if (ledger.record.state !== 'READY_TO_REPLAY' ||
ledger.record.epoch !== expectedEpoch) return
ledger.record = { ...ledger.record, state: 'REPLAYING' }
const remain = Math.max(80, Math.round(320 * (1 - ledger.record.progress)))
animateTo({ duration: remain, curve: Curve.EaseInOut }, () => {
AppStorage.setOrCreate('sharedProgress', 1.0)
})
setTimeout(() => {
if (ledger.record.epoch !== expectedEpoch) return
ledger.record = { ...ledger.record, progress: 1.0, state: 'REPLAY_DONE' }
hilog.info(0x1200, 'FoldMotion',
`task=FOLD-1008 epoch=${expectedEpoch} state=REPLAY_DONE`)
}, remain)
}
}
示例中的 setTimeout 是为了直观展示代次校验,真实工程应优先使用动画完成回调,并在页面销毁时取消未完成定时器。无论采用哪种方式,完成回调都必须再次检查 epoch;只在启动前检查一次,挡不住动画执行期间发生的第二次姿态变化。
五、一次完整回放的日志长什么样
工程目录里,GalleryDetailPage.ets 负责 UI 与 geometryTransition('photo-IMG-2047');WindowBridge.ets 负责窗口事件;TransitionLedger.ets 记录纯状态;MotionReplayer.ets 负责剩余动画和路由幂等。底部 HiLog 固定展示:task=FOLD-1008 item=IMG-2047 progress=0.63 state=INTERRUPTED、epoch=12 mode=EXPANDED targetMeasured=true、restore=14ms routeDepth=1 duplicateNav=0 state=REPLAY_DONE。
调试时我还加了两个故障开关:一个让目标区域延迟 120ms 上报,验证占位层不会消失;另一个连续注入两个尺寸变化,验证 epoch=11 的回调无法覆盖 epoch=12。它们只存在于 Demo 调试菜单,不进入业务状态,也不会在 Release 中改变过渡逻辑。

六、从“看起来顺”到可以验收
最终验收不靠肉眼一句“差不多”。页面固定显示任务 FOLD-1008、元素 photo-IMG-2047、布局代次 12、起始形态 HALF_FOLDED、目标形态 EXPANDED、中断进度 0.63、回放完成 1.00、恢复耗时 14ms、路由深度 1、重复导航 0。连续折叠展开 30 次后,这些约束都没有被破坏。

七、留下来的边界
这套方案针对的是同一页面栈内、同一资源的共享元素过渡。若应用跨 Ability、跨设备或在图片解码期间替换了资源内容,单靠元素 ID 和矩形快照不够,还要加入资源版本与传输状态。列表项被回收时,也不能假设源节点仍在;回放应退化成目标页淡入,而不是强行寻找不存在的起点。
这次修复之后,我对折叠屏动画的看法变了:真正难的不是多写一套大屏布局,而是承认布局可能在任何时刻失效。只要动画、路由和窗口状态各自拥有独立生命周期,就必须有一份纯数据账本把它们重新对齐。共享元素只是画面,代次、幂等与回放条件才是让画面可信的工程部分。
更多推荐



所有评论(0)