Skip to content
🌊海洋蓝
🌸樱花粉
🍃森林绿
🔮幻夜紫
🌙暗夜黑

HarmonyOS PageFlip — 三种阅读器翻页效果的实现解析

项目地址:https://gitcode.com/HarmonyOS_Samples/PageFlip 技术栈:HarmonyOS 5.0.5 / ArkTS / ArkGraphics 2D

项目简介

PageFlip 是华为 HarmonyOS 官方示例项目,展示了如何在阅读器场景中实现三种经典的翻页效果:上下翻页左右覆盖翻页仿真翻页。这个项目不仅是一个效果演示,更是一份完整的 HarmonyOS 动画与图形编程实践指南。

对于正在开发阅读类、漫画类或文档浏览类应用的开发者来说,PageFlip 提供了可以直接复用的核心逻辑。三种翻页模式覆盖了绝大多数阅读场景的用户习惯:上下滑动适合长文本连续阅读,左右覆盖适合章节切换,仿真翻页则还原了实体书的翻阅体验。

项目基于 HarmonyOS 5.0.5 Release,使用 ArkTS 语言开发,核心仿真翻页效果通过 ArkGraphics 2D 的 @ohos.graphics.drawing 接口实现。


功能概览

翻页模式交互方式技术实现
仿真翻页手指滑动 / 点击左右侧ArkGraphics 2D + NodeContainer 自定义绘制
左右覆盖翻页左右滑动 / 点击翻页animateTo 显式动画 + 组件位移
上下翻页上下滑动List 组件 + LazyForEach 懒加载

进入应用后默认展示仿真翻页效果。点击屏幕中部区域弹出底部选项栏,可在三种模式间切换。

真机运行效果

仿真翻页模式(默认)

仿真翻页模式

底部设置菜单

底部设置菜单

三种翻页模式可通过底部菜单快速切换,菜单采用 bindSheet 实现,从屏幕底部弹出,背景透明,不影响阅读内容的连续性。


工程结构

text
entry/src/main/ets/
├── common/
│   └── Constants.ets                  // 公共常量与枚举定义
├── entryability/
│   └── EntryAbility.ets               // 应用入口 Ability
├── pages/
│   └── Index.ets                      // 首页:三种翻页模式的容器
├── view/
│   ├── BottomView.ets                 // 底部翻页类型选择弹窗
│   ├── CoverFlipPage.ets              // 左右覆盖翻页实现
│   ├── EmulationFlipPage.ets          // 仿真翻页实现(核心)
│   ├── ReaderPage.ets                 // 单页阅读内容组件
│   └── UpDownFlipPage.ets             // 上下翻页实现
└── viewmodel/
    ├── BasicDataSource.ets            // List 数据源管理
    └── PageNodeController.ets         // 节点控制器与绘制逻辑

工程采用标准的 HarmonyOS 模块化结构。pages/Index.ets 作为根容器,通过状态变量 buttonClickedIndex 切换三种翻页组件的显示。view/ 目录下的三个 *FlipPage 分别独立实现各自的翻页逻辑,互不耦合。


核心实现解析

一、上下翻页:List 组件的极简实现

上下翻页是最基础的翻页模式,实现也最为简洁。核心思路是利用 ArkUI 的 List 组件原生支持的垂直滑动能力:

typescript
// entry/src/main/ets/view/UpDownFlipPage.ets
List({ initialIndex: this.currentPageNum - 1, scroller: this.scroller }) {
  LazyForEach(this.data, (item: string) => {
    ListItem() {
      Text($r(item))
        .fontSize($r('app.integer.flip_page_text_font_size'))
        .lineHeight($r('app.integer.flip_page_text_line_height'))
        .padding({ left: 26, right: 20 })
        .fontColor($r('app.color.text_font_color'))
    }
  }, (item: string, index: number) => index + JSON.stringify(item))
}
.height($r('app.string.page_flip_full_size'))
.scrollBar(BarState.Off)
.cachedCount(Constants.PAGE_FLIP_CACHE_COUNT)
.onScrollIndex((firstIndex: number) => {
  this.currentPageNum = firstIndex + 1;
})

关键点:

  • LazyForEach 实现懒加载,只渲染可视区域内的页面,内存占用低
  • cachedCount 预加载相邻页面,保证滑动流畅
  • onScrollIndex 回调同步当前页码到父组件
  • 数据源 BasicDataSource 实现了 IDataSource 接口,支持数据变更监听

这种实现方式的优势在于零自定义绘制,完全依赖 ArkUI 框架的原生能力,代码量少、稳定性高。

真机效果 - 上下翻页模式:

上下翻页模式

上下翻页模式采用标准的列表滚动交互,用户可以通过上下滑动浏览内容,体验类似于常见的长文本阅读应用。LazyForEach 确保只有当前可视区域的页面被渲染,内存占用极低。


二、左右覆盖翻页:显式动画驱动

覆盖翻页模拟了卡片堆叠的切换效果:当前页面向左或向右滑出,露出下方的下一页。实现上通过 animateTo 显式动画控制组件的 translate 偏移:

typescript
// entry/src/main/ets/view/CoverFlipPage.ets
private pageAnimateTo(isClick: boolean, isLeft?: boolean) {
  this.getUIContext().animateTo({
    duration: Constants.PAGE_FLIP_TO_AST_DURATION,
    curve: Curve.EaseOut,
    onFinish: () => {
      // 动画结束后更新页码
      if (this.offsetX > Constants.PAGE_FLIP_RIGHT_FLIP_OFFSETX &&
        this.currentPageNum !== Constants.PAGE_FLIP_PAGE_START) {
        this.currentPageNum -= 1;
      } else if (this.offsetX < Constants.PAGE_FLIP_LEFT_FLIP_OFFSETX &&
        this.currentPageNum !== Constants.PAGE_FLIP_PAGE_END) {
        this.currentPageNum += 1;
      }
      this.offsetX = Constants.PAGE_FLIP_ZERO;
      this.simulatePageContent();
      this.isAnimating = false;
    }
  }, () => {
    if (isClick) {
      this.offsetX = isLeft ? this.screenW : -this.screenW;
    } else {
      // 根据滑动方向判断翻页或回弹
      if (!this.isPageForward && !this.isGestureForward) {
        this.offsetX = -this.screenW;
      } else if (this.isPageForward && this.isGestureForward) {
        this.offsetX = this.screenW;
      } else {
        this.offsetX = Constants.PAGE_FLIP_ZERO;
      }
    }
  });
}

关键点:

  • 使用 Stack 堆叠三个 ReaderPage 组件:左页、中页(当前页)、右页
  • 中页通过 translate({ x: this.offsetX }) 实现水平位移
  • PanGesture 监听滑动手势,实时更新 offsetX
  • 手势结束时调用 pageAnimateTo,使用 Curve.EaseOut 实现自然的减速效果
  • 页面边缘添加 shadow 属性,增强层叠视觉层次

覆盖翻页的核心难点在于手势方向的判断:需要比较手势起始位置与结束位置,结合当前页码判断是否越界,并在越界时给出提示。

真机效果 - 覆盖翻页模式:

覆盖翻页模式

覆盖翻页模式下,当前页面会像卡片一样向左或向右滑出,露出下方的下一页内容。页面边缘的阴影效果增强了层叠的视觉层次感。


三、仿真翻页:ArkGraphics 2D 的自定义绘制(核心难点)

仿真翻页是整个项目最复杂的部分。它需要在手指滑动时实时计算书页的卷曲形状,并通过 2D 绘制 API 渲染出逼真的翻页效果。

3.1 架构设计

text
┌─────────────────────────────────────────┐
│              Stack 容器                  │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐   │
│  │ 右页    │ │ 中页    │ │ 左页    │   │
│  │(下一页) │ │(当前页) │ │(上一页) │   │
│  └─────────┘ └─────────┘ └─────────┘   │
│  ┌─────────────────────────────────┐    │
│  │     NodeContainer               │    │
│  │  (自定义绘制层:翻页动画)        │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

中页和左页通过 translate 定位,而翻页过程中的动态卷曲效果则通过 NodeContainer + 自定义 RenderNode 绘制在上层。

3.2 触摸点与几何计算

仿真翻页的核心是根据手指位置计算书页卷曲的几何形状。代码中定义了多个关键点:

typescript
// entry/src/main/ets/viewmodel/PageNodeController.ets
class MyPoint {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

// 关键点定义
let pointA: MyPoint = new MyPoint(-1, -1);  // 手指触摸点
let pointF: MyPoint = new MyPoint(0, 0);    // 页面固定角(右上角或右下角)
let pointG: MyPoint = new MyPoint(0, 0);    // AF 中点
let pointE: MyPoint = new MyPoint(0, 0);    // 贝塞尔曲线控制点
// ... 更多辅助点

当手指在屏幕上滑动时,系统根据触摸点 A 和固定角 F 的位置,通过几何公式计算出书页卷曲边缘的所有控制点:

typescript
function calcPointsXY(): void {
  pointG.x = (pointA.x + pointF.x) / 2;
  pointG.y = (pointA.y + pointF.y) / 2;

  pointE.x = pointG.x - (pointF.y - pointG.y) * (pointF.y - pointG.y) / (pointF.x - pointG.x);
  pointE.y = pointF.y;

  pointH.x = pointF.x;
  pointH.y = pointG.y - (pointF.x - pointG.x) * (pointF.x - pointG.x) / (pointF.y - pointG.y);

  // 计算交点 B、K
  pointB = getIntersectionPoint(pointA, pointE, pointC, pointJ);
  pointK = getIntersectionPoint(pointA, pointH, pointC, pointJ);
}

这些计算涉及中点公式贝塞尔曲线控制点计算线段交点求解,本质上是在模拟纸张弯曲时的物理形态。

3.3 绘制流程

自定义绘制在 RectRenderNode.draw() 中完成,分为四个步骤:

typescript
// entry/src/main/ets/viewmodel/PageNodeController.ets
draw(context: DrawContext): void {
  const canvas = context.canvas;

  init();                          // 1. 初始化数据
  drawPathBShadow(canvas);         // 2. 绘制下一页的阴影
  drawPathC(canvas);               // 3. 绘制翻起页的背面
  getPathA();                      // 4. 计算当前页裁剪区域
  drawPathAContent(canvas);        // 5. 绘制当前页内容
}

步骤详解:

  1. 初始化数据:根据手指位置计算所有几何点坐标
  2. 绘制下一页阴影:使用线性渐变模拟翻页时下方页面的阴影效果
  3. 绘制翻起页背面:通过矩阵变换实现页面的镜像翻转,模拟纸张背面的内容
  4. 绘制当前页内容:使用 clipPath 裁剪出当前可见区域,再绘制页面截图

3.4 截图与像素操作

仿真翻页需要获取页面内容的像素数据,代码中使用了 getComponentSnapshot API:

typescript
// entry/src/main/ets/view/EmulationFlipPage.ets
this.pagePixelMap = this.getUIContext().getComponentSnapshot().getSync(this.snapPageId);

截图后的 PixelMap 被存入 AppStorage,供 PageNodeController 在绘制时读取:

typescript
// 在 drawPathC 中绘制背面内容
let pagePixelMap: image.PixelMap = AppStorage.get('pagePixelMap') as image.PixelMap;
let verts: Array<number> = [0, 0, viewWidth, 0, 0, viewHeight, viewWidth, viewHeight];
canvas.drawPixelMapMesh(pagePixelMap, 1, 1, verts, 0, null, 0);

3.5 自动翻页动画

当用户松开手指后,翻页动画需要自动完成。代码通过 setInterval 实现逐帧更新:

typescript
private setTimer(xDiff: number, yDiff: number, drawNode: () => void) {
  this.timeID = setInterval((xDiff: number, yDiff: number, drawNode: () => void) => {
    let x = AppStorage.get('positionX') as number + xDiff;
    let y = AppStorage.get('positionY') as number + yDiff;

    // 终止条件判断
    if (x >= (AppStorage.get('windowWidth') as number) - 1 || y >= (AppStorage.get('windowHeight') as number) || y <= 0) {
      this.finishLastGesture();
    } else {
      AppStorage.setOrCreate('positionX', x);
      AppStorage.setOrCreate('positionY', y);
      drawNode();
    }
  }, Constants.TIMER_DURATION, xDiff, yDiff, drawNode);
}

每 8.3ms 更新一次触摸点位置,触发重新绘制,形成流畅的翻页动画。

真机效果 - 仿真翻页滑动过程:

滑动翻页效果

上图展示了从右向左滑动翻页后的结果页面。仿真翻页通过实时计算书页卷曲的几何形状,配合阴影渐变和背面内容镜像,还原了实体书翻阅的真实体验。


运行效果

项目提供了完整的中文和英文演示截图。以下是真机运行截图对比:

仿真翻页覆盖翻页上下翻页
仿真翻页覆盖翻页上下翻页
手指滑动时书页实时卷曲当前页面向左/右滑出上下滑动浏览
支持点击左右侧快速翻页支持手势和点击翻页类似长文本阅读
点击中部弹出设置选项动画流畅自然内存占用最低

交互演示: 点击屏幕中部区域可唤出底部设置菜单,在三种翻页模式间即时切换。

底部设置菜单


技术要点总结

1. 三种翻页方案的选择策略

方案实现复杂度性能适用场景
List 上下翻页长文本、连续阅读
animateTo 覆盖翻页章节切换、卡片浏览
ArkGraphics 2D 仿真翻页电子书、漫画、追求真实感

2. 状态管理

项目使用 AppStorage 作为全局状态存储,在仿真翻页中传递触摸点坐标、绘制状态等跨组件数据:

typescript
AppStorage.setOrCreate('positionX', this.positionX);
AppStorage.setOrCreate('positionY', this.positionY);
AppStorage.setOrCreate('drawState', DrawState.DS_MOVING);

3. 性能优化

  • LazyForEach 懒加载避免一次性渲染全部页面
  • cachedCount 控制预加载数量
  • NodeContainerclearNodes 及时释放绘制资源
  • pagePixelMap.release() 避免内存泄漏

总结

PageFlip 项目展示了 HarmonyOS 在阅读场景下的三种翻页实现方案,从简单的 List 组件到复杂的 2D 自定义绘制,覆盖了不同复杂度需求的开发场景。

学习建议:

  1. 初学者:从 UpDownFlipPage 入手,理解 List + LazyForEach 的基础用法
  2. 进阶开发者:研究 CoverFlipPageanimateTo 动画和手势处理逻辑
  3. 高级开发者:深入 EmulationFlipPagePageNodeController,掌握 ArkGraphics 2D 的自定义绘制、几何计算和像素操作

这个项目不仅是一个示例,更是一份HarmonyOS 动画与图形编程的实战教材。无论是开发阅读器、漫画 App 还是任何需要页面切换效果的应用,都能从中找到可直接复用的思路和代码。


环境要求

  • HarmonyOS 5.0.5 Release 及以上
  • DevEco Studio 5.0.5 Release 及以上
  • 支持设备:华为手机、平板

Released under the MIT License.