Harmony os—“阅读进度条 + 章节目录联动”完整模板(Web 计算进度,通过数据通道同步到 ArkTS)

Harmony os—“阅读进度条 + 章节目录联动”完整模板(Web 计算进度,通过数据通道同步到 ArkTS)

鸿蒙第四期开发者活动

这套模板适合做 长文阅读 / 协议阅读 / 小说章节 这类场景:

  • Web 负责计算滚动进度、判断当前章节
  • ArkTS 负责显示原生进度条、章节目录(点击可跳转)
  • 两边通过数据通道互通(message/postMessage)

核心点:

  • ArkTS:.onMessage() 接收 Web 上报进度
  • ArkTS:controller.postMessage() 发送“跳转章节 / 请求同步”指令给 Web
  • Web:scroll 事件节流,上报 {progress, currentChapterId, title, scrollTop}

1)ArkTS 页面:WebReadingPage.ets(原生进度条 + 章节目录 + Web)

// entry/src/main/ets/pages/WebReadingPage.ets
import { webview } from '@kit.ArkWeb';

type ProgressMsg = {
  type: 'SCROLL_PROGRESS',
  payload: {
    progress: number;
    currentChapterId?: string;
    currentChapterTitle?: string;
    scrollTop?: number;
  }
}

type ReadyMsg = { type: 'WEB_READY' }

type ToWebMsg =
  | { type: 'SCROLL_TO_CHAPTER'; payload: { id: string } }
  | { type: 'REQUEST_SYNC' };

@Entry
@Component
struct WebReadingPage {
  private controller: webview.WebviewController = new webview.WebviewController();

  @State progress: number = 0;                 // 0~100
  @State currentChapterTitle: string = '(未开始)';
  @State lastScrollTop: number = 0;

  // 目录(示例:你也可以从接口/配置读取)
  private chapters: Array<{ id: string; title: string }> = [
    { id: 'ch1', title: '第 1 章:开场' },
    { id: 'ch2', title: '第 2 章:核心概念' },
    { id: 'ch3', title: '第 3 章:实战拆解' },
    { id: 'ch4', title: '第 4 章:常见坑' },
    { id: 'ch5', title: '第 5 章:收尾总结' },
  ];

  private sendToWeb(msg: ToWebMsg) {
    try {
      this.controller.postMessage(msg);
    } catch (e) {
      // 有时候 Web 还没 ready 会失败,项目里可以做队列缓存
      console.info('postMessage failed: ' + JSON.stringify(e));
    }
  }

  build() {
    Column() {
      // 顶部信息栏(原生)
      Column({ space: 8 }) {
        Text(`阅读进度:${this.progress}%`)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)

        Text(`当前章节:${this.currentChapterTitle}`)
          .fontSize(13)
          .opacity(0.75)

        // 简易原生进度条(不用额外组件,直接用两层矩形)
        Stack() {
          // 底条
          Rect()
            .height(8)
            .width('100%')
            .radius(999)
            .opacity(0.15)

          // 进度条
          Rect()
            .height(8)
            .width(`${this.progress}%`)
            .radius(999)
        }
        .width('100%')
      }
      .padding(12)

      // 主体:左侧目录 + 右侧 Web
      Row() {
        // 目录区
        Column() {
          Text('目录')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .padding({ bottom: 8 })

          Scroll() {
            Column({ space: 8 }) {
              ForEach(this.chapters, (item) => {
                Button(item.title)
                  .width('100%')
                  .onClick(() => {
                    this.sendToWeb({ type: 'SCROLL_TO_CHAPTER', payload: { id: item.id } });
                  })
              })
            }
            .padding({ right: 8 })
          }
          .height('100%')
        }
        .width('35%')
        .padding(12)

        // Web 区
        Column() {
          Web({
            src: $rawfile('web/reading.html'),
            controller: this.controller
          })
            .javaScriptAccess(true)
            .onMessage((event) => {
              const data = event.data as ProgressMsg | ReadyMsg;
              if (!data || !('type' in data)) return;

              if (data.type === 'WEB_READY') {
                // Web 告诉 App:已就绪,可同步一次状态
                this.sendToWeb({ type: 'REQUEST_SYNC' });
                return;
              }

              if (data.type === 'SCROLL_PROGRESS') {
                const p = Math.max(0, Math.min(100, Math.round(data.payload.progress)));
                this.progress = p;

                this.lastScrollTop = data.payload.scrollTop ?? this.lastScrollTop;

                if (data.payload.currentChapterTitle) {
                  this.currentChapterTitle = data.payload.currentChapterTitle;
                }
              }
            })
            .height('100%')
            .width('100%')
        }
        .width('65%')
        .padding({ top: 12, right: 12, bottom: 12 })
      }
      .height('100%')
    }
    .width('100%')
    .height('100%')
  }
}

想让目录“高亮当前章节”?下一步我也能给你加:
只要 Web 上报 currentChapterId,ArkTS 根据 id 给按钮换样式即可。


2)Web 页面:resources/rawfile/web/reading.html(计算进度 + 上报 + 接收跳转)

<!-- entry/src/main/resources/rawfile/web/reading.html -->
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Reading</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      padding: 16px;
      line-height: 1.8;
    }
    h2 {
      margin: 28px 0 10px;
      padding-top: 10px;
      border-top: 1px solid #eee;
    }
    .hint {
      padding: 10px 12px;
      background: #f6f6f6;
      border-radius: 10px;
    }
    .spacer { height: 800px; } /* 模拟长内容 */
  </style>
</head>
<body>
  <div class="hint">
    这是一个示例长文页面:滚动时会把“阅读进度 + 当前章节”通过数据通道发给 ArkTS。
  </div>

  <h2 id="ch1" data-title="第 1 章:开场">第 1 章:开场</h2>
  <p>这里是正文……(你可以替换成真实文章内容)</p>
  <div class="spacer"></div>

  <h2 id="ch2" data-title="第 2 章:核心概念">第 2 章:核心概念</h2>
  <p>这里是正文……</p>
  <div class="spacer"></div>

  <h2 id="ch3" data-title="第 3 章:实战拆解">第 3 章:实战拆解</h2>
  <p>这里是正文……</p>
  <div class="spacer"></div>

  <h2 id="ch4" data-title="第 4 章:常见坑">第 4 章:常见坑</h2>
  <p>这里是正文……</p>
  <div class="spacer"></div>

  <h2 id="ch5" data-title="第 5 章:收尾总结">第 5 章:收尾总结</h2>
  <p>这里是正文……</p>
  <div class="spacer"></div>

  <script>
    // ====== 工具:节流(滚动事件必须节制,不然消息太多)======
    function throttle(fn, wait) {
      let last = 0;
      let timer = null;
      return function (...args) {
        const now = Date.now();
        if (now - last >= wait) {
          last = now;
          fn.apply(this, args);
        } else {
          clearTimeout(timer);
          timer = setTimeout(() => {
            last = Date.now();
            fn.apply(this, args);
          }, wait - (now - last));
        }
      };
    }

    // ====== 计算阅读进度:scrollTop / (scrollHeight - clientHeight) ======
    function calcProgress() {
      const doc = document.documentElement;
      const scrollTop = doc.scrollTop || document.body.scrollTop || 0;
      const max = (doc.scrollHeight - doc.clientHeight) || 1;
      const progress = (scrollTop / max) * 100;
      return { progress, scrollTop };
    }

    // ====== 计算当前章节:找“顶部最近的 h2” ======
    function calcCurrentChapter() {
      const headings = Array.from(document.querySelectorAll('h2[id]'));
      const y = 80; // 顶部偏移,留一点余量更贴近阅读感受
      let current = headings[0];

      for (const h of headings) {
        const rect = h.getBoundingClientRect();
        if (rect.top <= y) current = h;
        else break;
      }

      return {
        id: current?.id || '',
        title: current?.dataset?.title || current?.textContent || ''
      };
    }

    // ====== 向 App 上报(数据通道)======
    function postProgress() {
      const { progress, scrollTop } = calcProgress();
      const ch = calcCurrentChapter();

      window.postMessage({
        type: 'SCROLL_PROGRESS',
        payload: {
          progress,
          scrollTop,
          currentChapterId: ch.id,
          currentChapterTitle: ch.title
        }
      });
    }

    const postProgressThrottled = throttle(postProgress, 120);

    window.addEventListener('scroll', () => {
      postProgressThrottled();
    }, { passive: true });

    // ====== 接收 App 指令:跳转到章节 ======
    window.addEventListener('message', (event) => {
      const msg = event.data;
      if (!msg || !msg.type) return;

      if (msg.type === 'SCROLL_TO_CHAPTER') {
        const id = msg.payload?.id;
        const el = id && document.getElementById(id);
        if (el) {
          el.scrollIntoView({ behavior: 'smooth', block: 'start' });

          // 跳完也上报一次,让原生进度立刻同步
          setTimeout(postProgress, 50);
        }
      }

      if (msg.type === 'REQUEST_SYNC') {
        // App 请求同步一次当前状态
        postProgress();
      }
    });

    // Web ready 通知:让 App 知道可以 postMessage 了
    window.postMessage({ type: 'WEB_READY' });

    // 初次上报一次(避免原生进度条一直是 0)
    setTimeout(postProgress, 50);
  </script>
</body>
</html>

3)怎么接到你之前的“工程模板”里?

你之前的模板已经有 Web + controller + 数据通道思路了。现在只要做两件事:

  1. reading.html 放到:
    entry/src/main/resources/rawfile/web/reading.html
  2. 新增一个页面 WebReadingPage.ets,然后在 Index 里加一个入口按钮跳转即可。

4)我建议你下一步加的“更像真实产品”的两件事

如果你要把它做成“能上线的体验”,我建议加:

A. 目录高亮当前章节(很爽)

  • Web 已经在上报 currentChapterId
  • ArkTS 用这个 id 给对应 Button 换背景/字体即可

B. 记忆阅读位置(回到上次阅读点)

  • Web 上报 scrollTop
  • ArkTS 持久化(Preferences)
  • 进入页面后 App 发消息给 Web:SCROLL_TO_Y
posted @ 2025-12-18 16:28  骑老爷爷过马路  阅读(1)  评论(0)    收藏  举报