从几何到路径:ArkUI 下的双层容器、缩放偏移与抛掷曲线设计

从几何到路径: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 的缩放是绕组件中心点进行的;
  • 组件真实占据的矩形左上角,在缩放后会向中心「收缩」;
  • 如果你仍然用缩放前的终点坐标去画路径,视觉中心就会偏离你想要的位置。

用二维图示意一下(简化):

graph LR A[未缩放矩形
宽 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 示意图(抽象):

graph LR S((Start)) -- 控制点1 --> C1((C1)) S -- 控制点2 --> C2((C2)) C1 --> E((End)) C2 --> E

若平台不支持 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 生命周期和异常兜底。

相关推荐
养猪喝咖啡2 小时前
ArkTS 文本输入组件(TextInput)详解
harmonyos
养猪喝咖啡2 小时前
HarmonyOS ArkTS 从 Router 到 Navigation 的迁移指南
harmonyos
子榆.3 小时前
Flutter 与开源鸿蒙(OpenHarmony)性能调优实战:从启动速度到帧率优化的全链路指南
flutter·开源·harmonyos
子榆.3 小时前
Flutter 与开源鸿蒙(OpenHarmony)安全加固实战:防逆向、防调试、数据加密全攻略
flutter·开源·harmonyos
低调电报4 小时前
我的第一个开源项目:鸿蒙分布式“口袋健身”教练
分布式·开源·harmonyos
子榆.4 小时前
Flutter 与开源鸿蒙(OpenHarmony)深度集成实战(二):实现跨设备分布式数据同步
flutter·开源·harmonyos
万少14 小时前
HarmonyOS6 接入分享,原来也是三分钟的事情
前端·harmonyos
梧桐ty15 小时前
解耦之道:鸿蒙+Flutter混合工程的微内核架构与模块化实战
flutter·华为·harmonyos
Archilect19 小时前
HarmonyOS ArkTS 倒计时组件实战:性能优化篇 - 从100ms刷新到流畅体验
harmonyos