从几何到路径:ArkUI 下的双层容器、缩放偏移与抛掷曲线设计
关键词:ArkUI、ETS、布局几何、贝塞尔曲线、motionPath
TL;DR:统一管理谁在动(Outer vs Inner)、用简单的缩放偏移公式修正终点,再用规则化的贝塞尔控制点生成 motionPath,空间问题就迎刃而解。
上一篇我们解决了「时间轴」问题:如何用 AnimationStepper 编排多阶段动效。
这篇我们聚焦在「空间」:弹窗到底从哪里飞出去、飞向哪里、为什么不会飞偏。
在 ArkUI 这样的声明式 UI 框架里,动画的本质是对布局参数的连续修改。
要让动效既好看又稳定,就绕不开三个几何问题:
- 弹窗的起点坐标如何确定(尤其是有双层容器时)?
- 缩放会带来视觉中心偏移,这个偏移要怎么算?
- 从起点到终点的抛掷路径,如何用简洁的贝塞尔曲线来表达?
一、双层容器:OuterContainer & InnerContent 的职责划分
在很多弹窗实现中,我们会有两层容器:
- OuterContainer:外层容器,负责屏幕占位和遮罩,可能填满整个窗口;
- InnerContent:内层容器,承载真正的弹窗内容(文本、图片、按钮等),可以在外层内部做对齐和间距。
如果你把所有动画都直接作用在 OuterContainer 上,会遇到两个问题:
- 当 OuterContainer 填满屏幕时,缩放/抛掷它相当于「整屏飞走」,体验很怪;
- 遮罩透明度变化和内容缩放/抛掷本质是两类行为,耦合在一个容器上很难精细控制。
更合理的做法是:
- 当 OuterContainer 不填满屏幕时:直接把 OuterContainer 当作抛掷主体(既动内容,也动背景);
- 当 OuterContainer 填满屏幕时:OuterContainer 只做背景渐隐,真正参与缩放/抛掷的是 InnerContent。
在动效引擎里,我们可以用一个简单的状态来表达这个决策:
ts
// 伪代码
// 1 表示抛掷 OuterContainer,2 表示抛掷 InnerContent
levelToMove: 1 | 2 = popupLayout.fillWindow ? 2 : 1
之后所有关于「宽高」「起点坐标」「缩放偏移」「路径计算」的逻辑,都以 levelToMove 为分支条件:
levelToMove == 1:使用 OuterContainer 的宽高和起点;levelToMove == 2:使用 InnerContent 的宽高和起点。
二、尺寸与定位:在多种对齐方式下找「真实起点」
在 ArkUI 里,容器的尺寸和位置往往由以下因素共同决定:
- 容器本身的
width/height; - 是否填满父容器;
- 横向对齐(左/中/右或者依赖 left/right 边距);
- 纵向对齐(上/中/下或者依赖 top/bottom 边距);
- 系统 UI(状态栏、底部导航栏)占用的额外空间。
一个通用的计算方式可以概括为(示例使用类似于源码中的「配置表」写法,代替大量 if/else):
ts
// 计算尺寸
function computeContainerSize(layout, windowSize): { width: number; height: number } {
const fill = layout.fill === 1
const width = fill ? windowSize.width : toVp(layout.width)
const height = fill ? windowSize.height : toVp(layout.height)
return { width, height }
}
// 计算位置:用枚举 -> 坐标的配置表减少分支
function computeContainerPosition(layout, size, windowSize, systemInsets) {
const { width, height } = size
const margin = {
'': 0,
top: toVp(layout.top) + (layout.needStatusBarOffset ? systemInsets.statusBar : 0),
bottom: windowSize.height - height - toVp(layout.bottom) - (layout.needNavBarOffset ? systemInsets.navBar : 0),
left: toVp(layout.left),
right: windowSize.width - width - toVp(layout.right),
}
const xByAlign: Record<number, number> = {
0: margin[layout.leftOrRight ?? ''], // 使用左右边距
1: 0, // 左
2: (windowSize.width - width) / 2, // 中
3: windowSize.width - width, // 右
}
const yByAlign: Record<number, number> = {
0: margin[layout.topOrBottom ?? ''], // 使用上下边距
1: 0, // 上
2: (windowSize.height - height) / 2, // 中
3: windowSize.height - height, // 下
}
const x = xByAlign[layout.horizontalAlign ?? 2] // 默认居中
const y = yByAlign[layout.verticalAlign ?? 2]
return { x, y }
}
这里用 xByAlign / yByAlign 这类配置表代替 if/else,有几个好处:
- 把「枚举值 → 坐标公式」集中在一处,阅读和调试都更直观;
- 新增对齐方式时,只需扩展映射表,而不必修改一堆条件分支;
- 默认值(例如
?? 2表示居中)一目了然。
对于 OuterContainer 和 InnerContent,我们分别调用 computeContainerSize / computeContainerPosition,得到:
outerInitialX, outerInitialY;innerInitialX, innerInitialY;outerWidth, outerHeight;innerWidth, innerHeight。
这样一来,我们就有了两个候选起点坐标,后续只需根据 levelToMove 选一个即可。
小建议:
在实现时可以把尺寸/位置计算封装在一个独立模块中,方便单独做单元测试,避免未来调整布局逻辑时不小心破坏动效。
三、缩放偏移:为什么缩小以后会「飞偏」?
直觉上看,只要起点和终点坐标算对了,直接在路径上做 scale 就行了。
但实际跑起来你会发现:当组件从 1.0 缩小到 0.3 时,视觉上的中心会发生明显偏移。
原因很简单:
- ArkUI 的缩放是绕组件中心点进行的;
- 组件真实占据的矩形左上角,在缩放后会向中心「收缩」;
- 如果你仍然用缩放前的终点坐标去画路径,视觉中心就会偏离你想要的位置。
用二维图示意一下(简化):
宽 W] --> B[缩放后矩形
宽 W * s] B --> C[左上角向右移动 (1 - s) * W / 2]
若平台不支持 mermaid,可复制到 mermaid.live 查看示意。
因此,我们在计算终点坐标时,需要先算出缩放带来的偏移,然后反向「抵消」它。
一个简单的偏移函数可以写成:
ts
function applyScaleOffset(
coordinate: number, // 原本的起点或终点坐标(以左上角为基准)
size: number, // 对应方向上的宽或高
scale: number, // 最终缩放比例
isAdd: boolean // 是加偏移还是减偏移
): number {
const offset = (1 - scale) * size / 2
return coordinate + (isAdd ? offset : -offset)
}
在抛掷路径的终点计算中,我们会这样用:
ts
const innerSize = levelToMove === 1
? { width: outerWidth, height: outerHeight }
: { width: innerWidth, height: innerHeight }
const scaledTargetX = applyScaleOffset(targetX, innerSize.width, finalScale, false)
const scaledTargetY = applyScaleOffset(targetY, innerSize.height, finalScale, false)
含义是:
targetX, targetY是「我们想让视觉中心落在的终点位置」(通常来自目标矩形中心);finalScale是抛掷结束时的缩放比例(例如 0.3);- 我们通过
applyScaleOffset(..., false)把缩放带来的偏移扣除掉,从而保证视觉中心不会偏移。
建议:
这类纯数学函数非常适合写单元测试:给定宽高、缩放比例、起点/终点,验证缩放后视觉中心是否落在预期位置。
四、抛掷路径:用一条三次贝塞尔曲线表达自然「抛物线感」
起点和终点都有了,接下来要决定的是:中间的路径怎么走。
在很多 UI 框架中,我们可以给组件指定一个 motionPath,常见形式是 SVG path,例如:
text
M x0,y0 C cx1,cy1 cx2,cy2 x1,y1
这是一条三次贝塞尔曲线:
- 起点:((x_0, y_0));
- 终点:((x_1, y_1));
- 控制点:((c_{x1}, c_{y1}))、((c_{x2}, c_{y2}))。
我们希望用尽量简单的规则,生成一条看起来既自然,又不需要微调一堆 magic number 的路径。
一种实用策略是:
- 先算出起点和终点的中点
midX, midY; - 再根据「终点在起点上方还是下方」,选择不同的控制点布局:
- 如果终点在下方:整体轨迹略向下吊;
- 如果终点在上方:整体轨迹略向上抛。
伪代码如下:
ts
function buildThrowPath(
start: { x: number; y: number },
end: { x: number; y: number },
): string {
const x0 = start.x
const y0 = start.y
const x1 = end.x
const y1 = end.y
const midX = (x0 + x1) / 2
const midY = (y0 + y1) / 2
let cx1: number, cy1: number, cx2: number, cy2: number
if (y0 < y1) {
// 终点在起点下方:路径整体略向下吊
cx1 = midX
cy1 = y0
cx2 = x1
cy2 = midY
} else {
// 终点在起点上方:路径整体略向上抛
cx1 = x0
cy1 = midY
cx2 = midX
cy2 = y1
}
return `M${x0},${y0} C${cx1},${cy1} ${cx2},${cy2} ${x1},${y1}`
}
mermaid 示意图(抽象):
若平台不支持 mermaid,可复制到 mermaid.live 查看示意。
这套算法的特点是:
- 参数少:无需调整复杂的角度或曲率参数,只关心「上抛」还是「下抛」;
- 足够自然:通过中点和「靠近起点/靠近终点」的控制点布局,得到一个轻微弧线;
- 易于调试:只要打印起点/终点/控制点,在 Canvas 上随便画几条线,就能很快看出问题。
实战小技巧:
可以在 Debug build 中,把路径相关的坐标都打出来,然后用一个简单的调试 Canvas 把路径画出来,肉眼验证轨迹是否符合预期。
五、拖拽与动效协同:避免「瞬移然后再飞」
如果弹窗支持拖拽(用户可以把弹窗拖到屏幕边缘再关闭),那么动效起点就不再是「初始布局位置」,而是「拖拽结束时的位置」。
一个常见坑是:
计算抛掷路径时仍然用初始坐标,结果就是:
- 弹窗先瞬移回初始位置;
- 再从初始位置飞向终点。
为避免这种「回弹式瞬移」,我们在构造 AttributeModifier(或类似装饰器)时,就把拖拽偏移量写回初始坐标:
ts
class OuterContainerModifier {
constructor(private state: WhirlwindState, dragOffsetX: number, dragOffsetY: number) {
// 把拖拽最终偏移合并进起点坐标
state.outerInitialX += dragOffsetX
state.outerInitialY += dragOffsetY
}
apply(instance: ColumnAttribute) {
instance
.position({
x: this.state.outerInitialX,
y: this.state.outerInitialY,
})
// ...
}
}
同时,在设置 motionPath 时,要注意在拖拽过程中禁用路径动画:
ts
instance.motionPath({
path: isDragging ? null : (useCloseAnimation ? state.motionPath : null)
})
这样可以确保:
- 用户手动拖拽时,只影响 position,不触发路径动画;
- 用户松手并触发关闭时,抛掷路径的起点正是拖拽后的最终位置。
六、窗口尺寸变化与旋转:让路径自动适配
在 ArkUI 中,窗口大小和方向可能会变化(折叠屏、多窗口、横竖屏切换等)。
如果不做处理,这类导向式关闭动效非常容易出现:
- 起点/终点偏离;
- 路径在新窗口尺寸下变形;
- 光圈尺寸与目标区域错位。
一个稳妥的做法是:
- 在窗口尺寸变化事件中,重新计算:
- OuterContainer/InnerContent 的尺寸和起点;
- 终点矩形(如果坐标系受窗口影响);
- 抛掷路径(基于新的起终点)。
- 如果在动效过程中发生尺寸变化,可以选择:
- 直接取消当前动效,走兜底关闭;
- 或者在新尺寸下重新开始一个简化版关闭动效。
伪代码示意:
ts
onWindowSizeChanged(() => {
recomputeContainerSizeAndPosition()
recomputeTargetAndPath()
})
经验:
对于不常见的旋转/分屏场景,与其硬撑一套复杂的动态适配逻辑,不如在动效引擎里加一层兜底:一旦检测到尺寸变化,就直接跳过动效,做普通关闭。
七、小结
这一篇我们从三个关键几何问题出发,构建了一个在 ArkUI 下可复用的动效「空间层」方案:
- 用 双层容器 区分「谁在动」「谁在当背景」,通过
levelToMove收敛复杂度; - 用 缩放偏移修正 确保在任意缩放比例下,视觉中心依然命中目标位置;
- 用 三次贝塞尔路径 + 简单控制点策略,生成既自然又易调试的抛掷轨迹;
- 同时兼顾 拖拽协同 和 窗口尺寸变化,避免起点错乱和奇怪的瞬移。
在下一篇中,我们会把视角缩小到「终点」:
从一个目标矩形出发,算出 icon 的最终位置、光圈的大小与偏移,并讨论如何在 ArkUI 中优雅地管理 Lottie/Canvas 生命周期和异常兜底。