一、目标与总体思路

效果拆解:书架上 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°:封面转进视野;

  • 占位宽度 = 两投影之和:侧面终态只剩书脊宽,正面终态只剩封面宽,没有多余平面。

两个配套细节:

  1. 书脊旋出视野靠几何投影归零,不改透明度——材质透明度变化会露出后面的木板,投影归零是「物理转走」,更真实;

  2. 转动过程中书脊 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 直接乘进节点宽度和 perspectiveperspective: 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 就成了杀鸡的牛刀。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐