HarmonyOS 鸿蒙 CollectibleBookshelf 原生 3D 书架实战 —— 不用 WebGL,一个 rotateY 逼出来的投影布局法
一、目标与总体思路
效果拆解:书架上 N 本书,初始只有书脊朝向你(像真实书架那样看到一排书脊);点某一本,整本书从侧面旋向正面,封面完全展开朝向你,左右邻书横向避让;再点一次(或点别的书)转回书脊。
ArkUI 声明式动画能用的 3D 原语其实只有一个:
.rotate({
x: 0, y: 1, z: 0,
angle: angle, // 绕 Y 轴旋转角
perspective: 720, // 透视相机距离
centerX: 0, centerY: '50%' // 旋转轴:左边缘(书脊铰链)
})
一个平面绕左边缘的 Y 轴转,就是「封面从书脊上翻开」的全部空间关系。问题在于:rotate 只旋转节点自身,不管旋转之后它在布局里占多宽、会不会和邻居重叠、谁遮谁。 这三件事都得自己算——这就是投影层的工作。
二、核心数学:把旋转角换算成投影宽度
BookshelfCore.ets 是整套效果的大脑,核心函数只有一个——平面在给定旋转角下的屏幕投影宽度:
export const BOOKSHELF_COVER_PERSPECTIVE: number = 720;
function bookshelfPlaneProjection(width: number, angleDeg: number): number {
const angle: number = angleDeg * Math.PI / 180;
const cosAngle: number = Math.max(0, Math.cos(angle));
const sinAngle: number = Math.sin(angle);
const perspectiveScale: number = BOOKSHELF_COVER_PERSPECTIVE /
(BOOKSHELF_COVER_PERSPECTIVE + width * sinAngle);
return width * cosAngle * perspectiveScale;
}
公式的两部分各有含义:
-
width * cos(angle):正交投影下旋转平面的宽度(转到 90° 时为零); -
perspectiveScale:透视修正——平面朝向相机的一侧(sin > 0,靠近观察者)会被放大,分母720 + width·sin保证离得近的边放得多、离得远的边放得少,与rotate.perspective: 720的渲染语义严格同源。
角度语义(v4 定稿):
export function bookshelfCoverAngle(progress: number): number {
return 90 * (1 - clamp01(progress)); // 封面 90° → 0°(垂直 → 朝前)
}
export function bookshelfSpineAngle(progress: number): number {
return -90 * clamp01(progress); // 书脊 0° → -90°(朝前 → 垂直)
}
注意 v4 的语义修正(第四轮翻车,后详):旋转的不是封面,是整本书。progress 0 时书脊朝前、封面垂直(侧面终态只有书脊宽度);progress 1 时封面朝前、书脊垂直(正面终态只有封面宽度)。两个平面的投影宽度之和就是书的当前占位:
export function bookshelfProjectedWidth(book: BookshelfBook, progress: number): number {
return Math.max(0.5,
bookshelfProjectedSpineWidth(book, progress) +
bookshelfProjectedCoverWidth(book, progress));
}
有了单书宽度,布局就是逐书累计:
export function bookshelfBuildPoses(books, progress, viewportWidth, scale, gap = 3): Array<BookshelfPose> {
// 1. 每本书按自己的 progress 算投影宽度
// 2. cursor 从居中起点逐书累加 width + gap
// 3. 输出 pose.left / pose.projectedWidth
}
而封面的 position.x 直接取书脊的投影宽度——书脊转走多少,封面就沿铰链滑出多少:
// CoverArt() 内
.position({ x: this.spineProjection(), y: 0 })
旋转由 .rotate() 渲染,位置由投影函数计算,两者读同一个 progress——这是整套方案不穿帮的根基。
三、四次翻车与四条规则
3.1 第一翻:相邻书穿插 → 布局必须用投影宽度
v1 的想法很自然:每本书容器宽度固定(书脊 + 封面),封面转出来只是视觉旋转,不动布局。真机上一转——封面直接插进邻书的身体里。
ArkUI 布局不会因为你 rotate 了节点就给它让位。规则一由此而来:
旋转角必须换算成投影宽度,再累计进布局。每本书的占位 = 两个平面当前投影之和。
这就是 bookshelfBuildPoses 每帧重算 poses 的原因——展开一本,左右书的 pose.left 全部实时移动避让。
3.2 第二翻:44vp 触控区打架 → 命中交给舞台统一解析
书脊很窄(几 vp),为了可点性把每本书的触控区扩到 44vp——于是相邻书的触控区互相重叠,点在缝上时命中看节点树顺序,行为随机。
规则二:
窄目标各自扩热区必然重叠。让舞台容器统一接住点击,按当前投影边界做「最近书」解析。
export function bookshelfNearestBook(pointerX: number, poses: Array<BookshelfPose>): number {
let nearest: number = -1;
let bestDistance: number = Number.MAX_VALUE;
for (let i: number = 0; i < poses.length; i++) {
const left: number = poses[i].left;
const right: number = left + poses[i].projectedWidth;
const distance: number =
pointerX < left ? left - pointerX : (pointerX > right ? pointerX - right : 0);
if (distance < bestDistance) {
bestDistance = distance;
nearest = i;
}
}
return nearest;
}
组件层所有书籍节点 hitTestBehavior(HitTestMode.None),点击全落在舞台上:
.onClick((event: ClickEvent): void => {
this.select(bookshelfNearestBook(event.x, this.livePoses));
})
命中依据的是动画中的实时投影边界,不是静态布局——展开到一半点邻书也能点中。
3.3 第三翻:隐式动画几何分叉 → 只动一个 progress,其余全派生
v2 用 animateTo 隐式动画过渡。真机上出现了诡异画面:角度在补间,位置也在补间,但两者速度曲线不同步——封面已经转到 60°,邻书才让开一半,穿插又出现了。
根因:隐式动画对每个属性分别补间到各自终值,属性之间的几何约束(占位宽度 = 投影之和)在过渡中不成立。
规则三(本实验最重要的一条):
每本书只有一个动画变量
progress。用 Animator 逐帧输出它,rotateY角度、透视投影、累计 X 位置、接触阴影宽度,全部从同一帧的 progress 派生。
private applyAnimationFrame(from: Array<number>, to: Array<number>, value: number): void {
this.progress = from.map((start: number, index: number): number =>
start + (to[index] - start) * value);
// Rotation and sibling positions are derived from this same frame snapshot.
this.refreshPoses();
}
select() 里 Animator 的 onFrame 只做一件事:lerp progress 数组 + 重算 poses。角度是 progress 的纯函数,位置是 progress 的纯函数,几何约束按帧成立,分叉在数学上不可能发生。
3.4 第四翻:状态语义错位 → 「整本书在转」,不是「封面在转」
v3 的语义是「打开封面」:书脊永远朝前,封面从它上面旋出来。这带来两个终态问题:侧面终态占位是书脊+封面两个宽度(书变厚了一倍);正面终态书脊还朝前占着宽(封面旁边立着一排没用的书脊平面)。
v4 把语义修正为「整本书由侧面旋向正面」:
-
书脊
0° → -90°:书脊自己转出视野; -
封面
90° → 0°:封面转进视野; -
占位宽度 = 两投影之和:侧面终态只剩书脊宽,正面终态只剩封面宽,没有多余平面。
两个配套细节:
-
书脊旋出视野靠几何投影归零,不改透明度——材质透明度变化会露出后面的木板,投影归零是「物理转走」,更真实;
-
转动过程中书脊
brightness从 1 缓降到 0.82——侧面背离光源后颜色变暗,一点光影暗示就让 3D 错觉结实很多:
private spineBrightness(): number {
return 1 - 0.18 * Math.pow(this.progress, 0.8);
}
四、遮挡:一个 zIndex 平面设计
3D 错觉的最后一块拼图是谁遮谁。展开中的封面必须被右侧邻书的书脊压住(否则封面「穿」过邻书),但收集态时封面又要被所有书脊盖住(藏起来)。
ArkUI 的 zIndex 是节点级、无 3D 语义的,动态改 zIndex 又容易在动画中跳层。最终方案是固定的三层平面设计:
Plane 1 所有封面:zIndex 10–16(layerBase 10 + 书序 stackIndex)
Plane 2 所有书脊:zIndex 100–106(layerBase 100)
Plane 3 木板前沿:zIndex 200
组件里书被渲染两遍:封面平面一遍(renderCover: true, renderSpine: false)、书脊平面一遍(反之),各自挂进对应 zIndex 的 Stack:
// Plane 1: every cover is below every spine. A cover can never paint over
// a neighboring spine, even while switching or closing mid-animation.
Stack({ alignContent: Alignment.TopStart }) { ... }
.zIndex(10)
// Plane 2: spines are a permanent foreground mask over all covers.
Stack({ alignContent: Alignment.TopStart }) { ... }
.zIndex(100)
这个设计的美妙之处是静态正确:任何封面在任何动画状态下都压不过任何书脊,切换目标、中途收起都不会穿帮。而「收起时右侧邻书自然遮住折回的封面」由书脊层内的从左到右递增 stackIndex 免费获得——右侧书的 zIndex 恒大于左侧书,折回的封面永远被右邻压住。
代价是每本书渲染两个节点(一半节点只有一个平面可见)。对十几本的书架完全无感;如果上到几百本,就该按 viewport 做虚拟化或换 XComponent 了。
五、屏幕适配:最大宽度预缩放
一本封面全开的书宽度可观,窄屏上「展开后才突然缩小」很难看。解法是预计算:枚举每本书单独展开时的书架总宽,取最大值,与视口宽比较得出全局 scale:
export function bookshelfMaxWidth(books: Array<BookshelfBook>, gap: number = 3): number {
let maximum: number = 0;
for (let open: number = -1; open < books.length; open++) {
let width: number = 0;
for (let i: number = 0; i < books.length; i++) {
const progress: number = i === open ? 1 : 0;
width += bookshelfProjectedWidth(books[i], progress);
...
}
maximum = Math.max(maximum, width);
}
return maximum;
}
// 组件内
private scaleValue(): number {
const available: number = Math.max(120, this.viewportWidth - 28);
return Math.min(1, available / bookshelfMaxWidth(this.books));
}
N 本书 N+1 种情形(含全收起),O(N²) 一次性算完。scale 直接乘进节点宽度和 perspective(perspective: 720 * scale,透视与几何同缩放),任何书在任何屏上展开都不越界、不二次缩放。
六、无资源冷启动
一个务实细节:封面资源没就绪时,实验照样可跑——封面用原生 linearGradient + 边框 + 排版文字构建,木板用三层渐变模拟。业务接入时把封面节点换成 Image 即可,投影层一行不改。先让几何正确,再贴材质,和 E019 翻书实验「先 mesh 后材质」是同一条研究纪律。
七、沉淀
Reusable Pattern:
selection
-> progress[] (唯一动画变量,Animator 逐帧输出)
-> spineAngle(0°→-90°) + coverAngle(90°→0°)
-> spineProjection + coverProjection (角度 → 投影宽度)
-> accumulated poses[] (投影 → 累计布局)
-> cover plane (z=10+) + spine plane (z=100+) + plank (z=200)
-> native rotateY + position + contact shadow
四条规则回顾:
|
# |
翻车 |
规则 |
|---|---|---|
|
1 |
相邻书穿插 |
旋转角必须换算成投影宽度再累计布局 |
|
2 |
触控区打架 |
窄目标热区交给舞台统一做最近解析 |
|
3 |
角度/位置补间不同步 |
只动一个 progress,其余全部同帧派生 |
|
4 |
终态占位穿帮 |
状态语义对准「整本书在转」,两平面投影互补 |
BookshelfCore 全部纯函数,单测覆盖投影、累计布局、最大宽度与最近点击解析——几何对不对不靠真机肉眼,靠断言。
回头看,这个实验最有价值的发现其实是个否定性结论:ArkUI 的 rotateY + perspective 原语已经足够表达这类伪 3D 交互,缺的从来不是渲染能力,而是「把旋转角当布局输入」的那层数学。 一旦投影层立起来,WebGL 就成了杀鸡的牛刀。
更多推荐




所有评论(0)