一、前言:Coverflow 的「路线 B」是什么?

先交代背景。Coverflow 这种「一个连续的 centerIndex 分数,驱动多张卡片做位置/缩放/旋转/模糊」,在 Flutter 里是「父级算好每一帧的 layout,塞进 Stack 子节点」。但 ArkUI 的渲染模型完全不同——这正是所有坑的根源。

E017 采用「路线 B(连续进度与布局解耦)」:

onTouch / Controller / createAnimator.onFrame
        ↓
  centerIndex (@State, continuous ∈ ℝ)      ← 唯一真相
        ↓
  CoverflowCore: d → position / scale / rotateX|Y / zIndex / blur
        ↓
  CoverflowCardNode: @Prop centerIndex + live* → @State geo* → 属性绑定

核心思想一句话:centerIndex 是唯一真相,一个实数。拖动、吸附、按钮、自动播放,都只写这个 @State centerIndex。所有卡片的几何,都由「到中心的距离 d」这个纯函数算出。

这套「距离公式」本身可以原样从 Flutter 移植成纯 ArkTS(CoverflowCore.ets,零 UI 依赖、可单测)。真正难的是「怎么让计算结果刷新到屏幕上」——这就是本文的主角。


二、核心数学:距离驱动一切(这部分可放心搬)

2.1 一张卡片的位置、缩放、旋转、zIndex,全是 d 的函数

d = virtualIndex - centerIndex,即卡片到中心的「有符号距离」。核心函数:

// 沿主轴的位置:|d|≤1 用 nearSpacing,|d|>1 用 farSpacing
export function cardAxisOffset(distance, nearSpacing, farSpacing): number {
  if (Math.abs(distance) <= 1) return distance * nearSpacing;
  const sign = distance >= 0 ? 1 : -1;
  return sign * nearSpacing + sign * (Math.abs(distance) - 1) * farSpacing;
}

// 主轴尺寸:中心→near(0.88)→far(0.72) 分段线性缩放
export function cardPrimarySize(distanceAbs, itemSize, nearScale, farScale): number {
  if (distanceAbs < 1) return itemSize - (itemSize - itemSize*nearScale) * distanceAbs;
  if (distanceAbs < 2) { const t = distanceAbs - 1; return itemSize*nearScale - (itemSize*nearScale - itemSize*farScale) * t; }
  return itemSize * farScale;
}

// zIndex:中心卡最后画(最高 z),其余按距离降序
export function cardZIndex(virtualIndex, centerIndex): number {
  return virtualIndex === Math.round(centerIndex) ? 999 : -Math.abs(centerIndex - virtualIndex);
}

// 水平:rotateY;纵向:rotateX
export function cardRotateYDeg(virtualIndex, centerIndex, skewAngleDeg): number {
  return skewAngleDeg * (centerIndex - virtualIndex);
}

2.2 两个「别照抄 Flutter 常数」的坑

Flutter

ArkUI

skew 角度单位

弧度(如 -0.35 rad)

(如 -20°)

perspective

Matrix4 的 m[3][2] 小数

.rotate观察距离(E017 默认 480,越小 3D 越强

这是「设备观感优先于源码同构」的具体体现:数学结构可以抄,但常数要在真机上重新标定。

2.3 Classic 模式:Core 里 resolve,不是 Lab 化妆品

CoverflowMode.Classic(平面轮播)在 resolveCoverflowOptions锁死一组参数,而不是让 Lab 事后改:

if (value.mode === CoverflowMode.Classic) {
  value.skewAngleDeg = 0;    // 不倾斜
  value.nearScale = 1;       // 不缩放
  value.farScale = 1;
  value.heightFalloff = 0;
  value.perspective = 1;     // 无透视
  // ...
  // 关键:center-to-center 间距必须 ≥ 主轴尺寸,否则重叠
  // 小 near 值当作「边距」处理:primary + gap
  if (options.nearCardSpacing < primary) {
    value.nearCardSpacing = primary + options.nearCardSpacing;
  }
  // 跟手:一页拖动 ≈ 一个中心步
  value.swipeUnit = Math.max(24, value.nearCardSpacing);
}

两个「平面 vs 3D」的语义差异(最容易踩):

  • 小 near 值是边距不是中心距:3D 模式下 near=20 是「重叠很近」,Classic 模式下同一个 20 应该理解成「卡片间距 20」,要加回到 primary 上,否则卡重叠。

  • swipeUnit 语义不同:3D 用较小值(如 80)获得「脆脆的」跟手感;Classic 的视觉步长就是中心距,必须 swipeUnit = nearCardSpacing,否则拖动会「过敏」。


三、核心陷阱:「数值对、UI 不动」

这是 E017 卡最久的一个坑,也是本文最重要的部分。

3.1 现象

debug 文案显示 idx=1.23 在正常变化、snap→2 正常执行,但卡片就是不动,或者只有文案在变、卡片像冻住了一样。

3.2 根因:ForEach 的 key 稳定 ≠ transform 会重绑

最直觉的写法(也是 Flutter 搬过来的直觉)是:

// ❌ 反模式:ForEach 用 layout 快照,key 不变 → 节点复用,transform 不重绑
ForEach(this.layouts, (layout: CoverflowCardLayout) => {
  Column()
    .position({ x: layout.left, y: layout.top })
    .rotate({ y: layout.rotateYDeg })
    .scale({ x: layout.scale, y: layout.scale })
}, (layout) => layout.virtualIndex.toString())

问题在于:ForEach 的 key 只负责「挂哪几张卡」。当 centerIndex 连续变化(如 1.0 → 1.1 → 1.2),layouts 数组的字段变了,但 key(virtualIndex)没变,于是 ArkUI 复用了旧节点,却不重绑那些 .position/.rotate/.scale

结果就是:Text(debugLabel) 因为绑了 @State 正常刷新(「数值对」),而卡片的 transform 因为没走 @State 绑定路径,永远停在第一帧(「UI 不动」)。

3.3 正解:CoverflowCardNode@Prop @Watch → @State geo*

// ✅ 正模式:把几何写进子组件的 @State,通过 @Watch 重算
@Component
struct CoverflowCardNode {
  @Prop @Watch('onGeometryDeps') centerIndex: number = 0;
  @Prop virtualIndex: number = 0;
  @State geoLeft: number = 0;
  @State geoTop: number = 0;
  @State geoScale: number = 1;
  @State geoRotateY: number = 0;
  @State geoBlur: number = 0;
  // ...

  onGeometryDeps(): void {
    // 用 CoverflowCore.layoutCard 重算,写入 geo*(会触发属性重绑)
    const layout = layoutCard(this.virtualIndex, this.centerIndex, /* ... */);
    this.geoLeft = layout.left;
    this.geoTop = layout.top;
    this.geoScale = layout.width / this.itemWidth;
    this.geoRotateY = layout.rotateYDeg;
    // ...
  }

  build() {
    Column()
      .position({ x: this.geoLeft, y: this.geoTop })
      .scale({ x: this.geoScale, y: this.geoScale })
      .rotate({ y: this.geoRotateY })
      .blur(this.geoBlur)
  }
}

规则(R4,固化为通用句):

ForEach 的 key 只负责「挂哪几张卡」;「卡在哪、转多少」必须由子组件本地 @State 驱动。

这就是「路线 B」与 Flutter 直觉的本质差异:不能假设父级算完 layout 塞进 ForEach 就完事,必须让每个卡片子组件自己持有几何 @State,由 centerIndex@Watch 触发重算重绑。

3.4 配套的诊断口诀

实验文档里有一条金句,值得每个做 ArkUI 连续 UI 的人记住:

debug 文案会动、卡片不动 → 先查渲染绑定,别先改公式。

配套的二分诊断法:

文案变化

卡片不动

含义

会变

会动

通路正常

会变

不动

渲染绑定问题(本坑主因)

不变

不动

手势/Controller 没进组件


四、其余六个真机硬规则

4.1 R1:Controller 禁止 @Prop

// ❌ 深拷贝:Lab 调 next() 打在未 attach 的副本上
@Prop controller: CoverflowController = new CoverflowController();

// ✅ 引用共享
controller: CoverflowController = new CoverflowController();

症状:按钮完全无反应。根因是 @Prop深拷贝对象,Lab 里 controller.next() 调用的是宿主持有的那个实例,而组件内 attach 的是另一个副本——两者脱节。

4.2 R2:连续手势区不要放进父级竖向 Scroll

Scroll 会吃掉横向拖动,表现为「偶发离散步进、最左/最右卡逐个消失」(实为 centerIndex 整页跳变 + 可见窗口卸载)。

正确结构:轮播固定在 Scroll 之外,Scroll 只滚参数面板

Column
  CarouselStage   // 固定、不滚动
  Scroll          // 只滚参数面板

4.3 R3:自定义几何动画用 createAnimator,不用 setInterval

方式

真机表现

setInterval@State

常不刷新或只跳最终帧

animateTo { centerIndex = x }

不一定插值「由 centerIndex 推导的 position」

createAnimator + onFrame

可逐帧改 centerIndex,再驱动子节点几何

createAnimatoronFrame 回调能拿到逐帧进度,把 centerIndex 从 a 连续推到 b,再由 CardNode 的 @Watch 驱动几何刷新。

4.4 R10:卡片变小不要改布局宽高,用 scale

如果直接 width = f(distance),文字会按新宽度重排,滑动时换行跳动。正确做法是固定 itemWidth/itemHeight 做排版,视觉上用 .scale() 缩放

// 固定尺寸排版 + scale 视觉缩放
const visualScale = layout.width / this.itemWidth;
Column()
  .width(this.itemWidth).height(this.itemHeight)  // 固定,文字不 reflow
  .scale({ x: visualScale, y: visualScale })

4.5 R16:侧卡深度用真 .blur(),不用 opacity

深度提示要用真模糊,而不是 opacity 变暗冒充模糊:

// ✅ 真 blur
const radius = Math.min(maxSideBlur, Math.abs(d) * sideBlur * 14);
this.geoBlur = radius;
.column.blur(radius)

// ❌ opacity 变暗冒充模糊(会被识破为「变暗」而非「深度」)

4.6 R18:AutoPlay 的 Timer 只调度,几何仍走 Animator

setInterval → autoplayNextTarget → animateCenterTo → createAnimator.onFrame → centerIndex
// ❌ setInterval 里直接改 geoLeft / centerIndex 当帧动画(与 R3 冲突)

触摸 Down → 暂停;Up / 翻页结束 → 恢复。有限列表到末页 wrap 回 0,不卡死。


五、中心卡交互分层:Flutter AbsorbPointer 的 ArkUI 版

这是「轮播里塞真按钮」的经典难题:如果全表面一个 overlay 抢掉了所有点击,中心卡的 Button 永远点不到。E017 用 HitTestMode 三层分层解决(R20):

Stack
  CardNode center  → HitTestMode.Default      → Button / 控件可点
  CardNode side    → HitTestMode.Block        → 吞子树 + onClick 聚焦
  Drag overlay     → HitTestMode.Transparent  → 跟手,且放行下方 hit-test

规则:

  • 旧全捕获 overlay(Default)会让中心 Button 永远点不到

  • 开启 enableCenterInteraction 时,overlay 的 short-tap 不要再派发 onCenterAction(避免与 Button 双发)。

  • 侧卡 Block ≈ Flutter AbsorbPointer(absorbing: true)

  • 自定义内容用 params.onAction()不要读 host this


六、无限循环:双取模 + 最短路径

无限循环是 Coverflow 的加分项,两个纯函数处理:

// 双取模:任意 virtualIndex → [0, itemCount)
export function realIndexFromVirtual(virtualIndex, itemCount): number {
  return ((Math.round(virtualIndex) % itemCount) + itemCount) % itemCount;
}

// animateTo 走最短路径(不绕远路)
export function nearestVirtualPage(targetRealIndex, currentVirtualPage, itemCount): number {
  const currentReal = realIndexFromVirtual(Math.round(currentVirtualPage), itemCount);
  let diff = targetRealIndex - currentReal;
  if (diff > itemCount / 2) diff -= itemCount;       // 往左绕更近
  else if (diff < -itemCount / 2) diff += itemCount;  // 往右绕更近
  return Math.round(currentVirtualPage) + diff;
}

nearestVirtualPageanimateTo 在无限循环里「走最短路径」——从第 5 张跳到第 1 张,往前绕 1 格而不是往后绕 9 格。


七、入口动画:entryProgress 也必须是父级 @State

7 种入口动画(none / fade / scale / expand / slide / fadeScale / stack)的关键在「per-card 的 stagger」:

// 每张卡根据「离初始中心的层距」错开入场时间
export function entryCardProgress(entryProgress, layerDistance, mode, visibleItems): number {
  // Stack 模式:均匀分段
  // 其他:每层 15% stagger
  const start = clamp(layerDistance * 0.15, 0, 0.75);
  return (t - start) / (1 - start);
}

和 R4 同理,entryProgress 必须用 createAnimator 写入 @State,再 @Prop 进 CardNode。重播入口 = remount 组件(Lab 用 carouselKey++),不是只改 enum。


八、踩坑速查(20 条规则精选)

#

症状

根因

规则

R1

按钮完全无响应

@Prop controller 深拷贝 attach 丢失

Controller 禁止 @Prop,引用传入

R2

横滑不跟手 / 卡依次消失

父竖向 Scroll 抢势

轮播移出竖向 Scroll

R3

文案有 anim、无动画

setInterval 不驱动 UI 帧

createAnimator + onFrame

R4

数值对、UI 不动

ForEach key 复用不重绑 transform

CardNode @Watchgeo* → 属性绑定

R6

Builder 空指针崩溃

跨组件 @BuilderParam 读宿主 this

数据进 cardModels,builder 只读 params

R10

文字换行跳动

用 layout 宽高直接缩容器

固定尺寸 + .scale(visualScale)

R12

Classic 卡重叠

把 3D near/swipeUnit 当中心距

Classic 锁参数 + near 当 gap + swipeUnit=near

R16

以为有模糊其实是变暗

opacity 冒充 blur

.blur(radius)

R18

AutoPlay 几何冻住

setInterval 直接改 transform

Timer 只调度,几何走 Animator

R20

中心 Button 点不到

全表面 overlay 抢事件

overlay Transparent + center Default + side Block


九、总结:平台接入方式,比数学更致命

Coverflow 是这系列里「数学最简单、平台坑最多」的一篇。距离公式、zIndex、无限循环取模,这些数学从 Flutter 搬过来几乎零改动;真正花时间的,是搞清楚 ArkUI 的 @Prop 深拷贝、ForEach key 复用、@State 属性重绑、父 Scroll 手势竞争、HitTestMode 分层这些「看不见的平台规则」。

核心结论,一条都不能少:

centerIndex 是唯一真相,一个实数驱动一切。
ForEach key 只挂卡,几何必须走子组件 @State 属性绑定。
带 attach 的 Controller 别用 @Prop(会深拷贝脱节)。
连续手势区别塞进会抢方向的父 Scroll。
自定义插值用 createAnimator.onFrame,别赌 setInterval
缩视觉用 scale,缩容器会 reflow 文字。
深度提示用真 blur,别拿 opacity 假装。

如果你正在鸿蒙上做任何「一个连续进度驱动多节点 position/rotate」的组件——不止 Coverflow,还包括轮盘、3D 堆叠、扇形导航——这 20 条规则就是你的排雷地图。数学层可以对,但平台接入方式错了,就会表现为「没动画 / 消失 / 过敏」,而这恰恰是最难排查的部分。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐