HarmonyOS 鸿蒙 Coverflow 3D 轮播实战 —— 「数值对、UI 不动」的渲染绑定血泪史
一、前言: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 的 |
|
这是「设备观感优先于源码同构」的具体体现:数学结构可以抄,但常数要在真机上重新标定。
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
|
方式 |
真机表现 |
|---|---|
|
|
常不刷新或只跳最终帧 |
|
|
不一定插值「由 centerIndex 推导的 position」 |
|
|
可逐帧改 |
createAnimator 的 onFrame 回调能拿到逐帧进度,把 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≈ FlutterAbsorbPointer(absorbing: true)。 -
自定义内容用
params.onAction(),不要读 hostthis。
六、无限循环:双取模 + 最短路径
无限循环是 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;
}
nearestVirtualPage 让 animateTo 在无限循环里「走最短路径」——从第 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 |
按钮完全无响应 |
|
Controller 禁止 @Prop,引用传入 |
|
R2 |
横滑不跟手 / 卡依次消失 |
父竖向 Scroll 抢势 |
轮播移出竖向 Scroll |
|
R3 |
文案有 anim、无动画 |
|
|
|
R4 |
数值对、UI 不动 |
ForEach key 复用不重绑 transform |
CardNode |
|
R6 |
Builder 空指针崩溃 |
跨组件 |
数据进 |
|
R10 |
文字换行跳动 |
用 layout 宽高直接缩容器 |
固定尺寸 + |
|
R12 |
Classic 卡重叠 |
把 3D near/swipeUnit 当中心距 |
Classic 锁参数 + near 当 gap + swipeUnit=near |
|
R16 |
以为有模糊其实是变暗 |
opacity 冒充 blur |
真 |
|
R18 |
AutoPlay 几何冻住 |
setInterval 直接改 transform |
Timer 只调度,几何走 Animator |
|
R20 |
中心 Button 点不到 |
全表面 overlay 抢事件 |
overlay |
九、总结:平台接入方式,比数学更致命
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 条规则就是你的排雷地图。数学层可以对,但平台接入方式错了,就会表现为「没动画 / 消失 / 过敏」,而这恰恰是最难排查的部分。
更多推荐




所有评论(0)