把一份长文章放到折叠屏上,最容易被注意到的是“展开后两列看着更舒服”。但对阅读工具来说,列数变化不是终点。真正影响体验的是:折叠前读到第四段,展开之后还在不在第四段?用户刚刚看到的内容,能否在布局重排后重新找到?

这里用一个明确限定的工程示例 ReadAtlas 讨论这个问题。它有八个阅读节,编号 S-01 至 S-08;演示截图取窄窗口 412vp、单列、当前锚点 S-04(索引 3)。展开到演示宽度 784vp 时,业务规则把列表改为双列。两组尺寸只是方案的测试输入,不是声称来自某台真机的测量值。

一、分栏规则容易写,难的是恢复什么

一个常见做法是记录滚动偏移量,然后折叠屏改变宽度时再把偏移量写回去。这个逻辑表面直接,在内容等高且列表始终单列时通常问题不大。但阅读卡片里有标题、摘要、插图说明,窄屏和宽屏的换行数可能完全不同。同样的纵向偏移量,重排后对应的可能已经是另一节内容。

所以这篇示例不把“像素偏移”当成阅读进度,而是记录可见内容的业务身份。本例使用 S-04 这样的稳定标识,并在内存中维护它的索引 3。索引只是当前数据快照中的定位辅助;一旦列表排序、删除或插入发生变化,就要重新由稳定 ID 查找索引。把索引永久存盘,却忘记对应数据版本,是另一种隐蔽错误。

首先约定数据结构,避免后面的回调直接依赖界面字符串。下面代码解决的是“布局变化之前,究竟要记住哪条内容”的问题。

interface ReadSection {
  id: string;
  title: string;
  summary: string;
}

const READ_SECTIONS: ReadSection[] = [
  { id: 'S-01', title: '开场', summary: '确认阅读目标与文档版本' },
  { id: 'S-02', title: '目录', summary: '了解章节组织与导航关系' },
  { id: 'S-03', title: '窗口变化', summary: '记录尺寸与分栏临界点' },
  { id: 'S-04', title: '阅读锚点', summary: '恢复到正在阅读的章节' },
  { id: 'S-05', title: '内容重排', summary: '观察双列时的布局顺序' },
  { id: 'S-06', title: '滚动反馈', summary: '处理回调与恢复互相覆盖' },
  { id: 'S-07', title: '异常边界', summary: '处理删除与插入导致的索引失效' },
  { id: 'S-08', title: '验收', summary: '逐一检查窄宽屏切换' }
];

id 要在业务生命周期中保持稳定;标题可以调整,ID 不应随意换。示例用静态数组是为了隔离布局问题,真实产品的数据源需要处理分页、列表刷新和多端同步。这里没有引用数据库或云端接口,因此也不需要假设额外的同步能力。

二、先区分“宽度变化”和“阅读变化”

ArkUI 的 List 允许用 lanes 配置列数,onScrollIndex 可以观察可见范围的索引。列表所在区域的 onAreaChange 则适合给布局模式变化提供输入。关键不是把三个回调都连起来,而是明确谁写什么状态。

在 ReadAtlas 中,页面宽度决定 laneCount;滚动事件决定 anchorIndex;恢复任务只消费一次 pendingAnchor。三者不应该在同一个回调里反复互改,否则可能形成“布局触发滚动、滚动又改锚点、锚点再触发布局”的循环。下面代码解决的是跨越阈值时只安排一次恢复。

@State private widthVp: number = 412;
@State private laneCount: number = 1;
@State private anchorId: string = 'S-04';
private anchorIndex: number = 3;
private pendingAnchor: number = -1;
private layoutTicket: number = 0;
private active: boolean = true;
private restoreTimer: number = -1;
private listScroller: Scroller = new Scroller();

private updateLayout(width: number): void {
  const next: number = width >= 700 ? 2 : 1;
  this.widthVp = width;
  if (next === this.laneCount) { return; }
  this.pendingAnchor = this.anchorIndex;
  this.laneCount = next;
  const ticket: number = ++this.layoutTicket;
  if (this.restoreTimer >= 0) { clearTimeout(this.restoreTimer); }
  this.restoreTimer = setTimeout(() => {
    if (!this.active || ticket !== this.layoutTicket) { return; }
    const target: number = this.pendingAnchor;
    if (target >= 0 && target < READ_SECTIONS.length) {
      this.listScroller.scrollToIndex(target, false);
    }
    this.pendingAnchor = -1;
    this.restoreTimer = -1;
  }, 30);
}

aboutToAppear(): void { this.active = true; }
aboutToDisappear(): void {
  this.active = false;
  this.layoutTicket++;
  if (this.restoreTimer >= 0) { clearTimeout(this.restoreTimer); }
  this.restoreTimer = -1;
}

阈值 700vp 是 ReadAtlas 自己选的产品规则,不是 HarmonyOS 的系统折叠屏断点。选择阈值的依据应是卡片最小可读宽度、字号、左右留白,而不是设备名字。width 由布局回调传入,后续也能扩展为根据窗口尺寸或断点配置选择模式。

这里故意把延时写成 30ms,是为了展示“把恢复动作安排到状态更新之后”的基本思想;固定延时并不能保证下一帧布局一定完成。真实工程应该结合布局完成条件、焦点与滚动回调做验证,在低端设备、动画过程中尤其不能把这个数值当作通用解法。layoutTicket 用来丢弃过期恢复任务,active 负责阻止页面离开后继续操作控制器。

三、一个列表只负责一种位置语义

页面层再把上述状态接到 ArkUI 上。这里的 List 不在单列与双列之间拆成两个组件实例,而是保持同一个列表容器,通过 lanes 调整交叉轴排列。它能减少页面重建带来的状态分叉,但不代表浏览位置自动精确复原。

下面代码解决的是“组件重排以后继续捕捉用户真正滚动到哪里”。注意恢复期间暂时不采用滚动回调覆盖锚点,否则程序可能刚拿到 S-04,就被中间过渡态改写成 S-01。

build() {
  Column() {
    Text('ReadAtlas 阅读台').fontSize(22)
    Text(`窗口 ${this.widthVp}vp · ${this.laneCount}列 · 锚点 ${this.anchorId}`)
    List({ scroller: this.listScroller, space: 12 }) {
      ForEach(READ_SECTIONS, (section: ReadSection) => {
        ListItem() {
          Column() {
            Text(`${section.id}  ${section.title}`).fontSize(18)
            Text(section.summary).fontSize(14)
          }.padding(16).backgroundColor('#F3F7FA').borderRadius(12)
        }
      }, (section: ReadSection) => section.id)
    }
    .lanes(this.laneCount, 12)
    .onAreaChange((_old: Area, now: Area) => {
      this.updateLayout(parseFloat(String(now.width)));
    })
    .onScrollIndex((first: number, _last: number, _center: number) => {
      if (this.pendingAnchor >= 0) { return; }
      if (first >= 0 && first < READ_SECTIONS.length) {
        this.anchorIndex = first;
        this.anchorId = READ_SECTIONS[first].id;
      }
    })
    .layoutWeight(1)
  }.padding(16)
}

onScrollIndex 记录的是可见项目位置,不是“用户确实阅读完某段”的证据。如果产品需要阅读完成率,应该另设停留时间、明确标记或用户行为,不应把一次滚动回调等同于完成阅读。双列情况下,首个可见项更不意味着左上角一定是用户关注的卡片,因此本示例只承诺“尽量恢复到相关内容所在区域”。

上面的 DevEco 配图是根据工程状态绘制的界面示意图,展示文件组织、List.lanes 与日志格式;不表示已经在 DevEco 编译成功。示意日志采用 17:40:12 [ReadAtlas] width=412vp lanes=1、17:40:13 [ReadAtlas] anchor=S-04 index=3、17:40:14 [ReadAtlas] restore=3 status=READY。这里的 READY 是本例业务状态,不能理解为系统保证阅读位置已经像素级一致。

四、窄屏结果应当怎样看

在 412vp 的手机宽度里,预期是单列、八节可纵向浏览、阅读锚点保留 S-04。配图把“索引 3”明确展示出来,目的是让测试人员能区分可读的业务身份与实现细节。真正使用时不一定要把索引暴露给终端用户,通常只需显示阅读章节标题、进度或“继续阅读”入口。

这张图片同样是基于约定数据绘制的 UI 示意,不是设备截图证据。若把界面从 412vp 改为 784vp,按当前规则会得到双列;而是否回到同一个段落的同一行,仍需要设备运行后核对。对长段落、动态图片和不同字体尺寸,恢复到同一个卡片已经是合理的第一阶段目标。

验收时我建议把观察点拆开:布局是否只在阈值两侧变化;S-04 在收起、展开后有没有丢;页面快速切换时是否存在旧计时器修改新页面;列表插入新节后是否根据 ID 重找位置;关闭页面以后是否仍有延时回调。除了看屏幕,还应在代码里给状态转换打印一致的结构化日志,并对目标索引做边界检查。

五、工程边界:不是所有列表都适合直接切列

首先是数据规模。本例只有八项,所以使用 ForEach 足够直观。面对数百条图文内容时,官方文档对 ForEach、LazyForEach、Repeat 的创建范围有明显区别。把当前演示直接复制到长列表并声称“懒加载优化完成”,结论就会失真。大型列表要单独评估组件创建数量、预加载与资源回收。

其次是插入和删除。如果服务器更新了章节顺序,anchorIndex=3 可能已经指向另一个 ID。正确做法是在布局恢复前,用 anchorId 找到当前数组中的新位置,找不到则回退到最近的有效章节,并提示阅读位置可能变化。不要因为恢复失败就反复自动滚动,让用户以为页面失控。

再次是系统尺寸与业务尺寸的区别。折叠屏、多窗口和字体放大都会改变内容区,但不一定改变设备方向。优先观察组件实际可用宽度,而不是仅根据“展开”“横屏”两个词选布局。两列能够同时出现,不等于它在所有字号下都更易读。

最后是恢复任务的生命周期。示例中存在计时器,组件离开时必须执行 active=false、layoutTicket++ 并清除 restoreTimer;再次进入页面时重置有效标记。否则旧任务可能操作一个已经失效的滚动控制器。恢复完成后还应核对 anchorId,必要时给用户一个手动“返回上次阅读位置”按钮。

六、结语:业务身份比偏移量更耐重排

ReadAtlas 给出的不是“任意折叠屏均能零偏差恢复”的承诺,而是一个更可验证的工程切分:列数由容器宽度决定,阅读锚点由稳定业务 ID 决定,滚动恢复由一次性任务执行,页面生命周期负责取消过期任务。只有把这些职责拆开,才方便继续处理复杂的图文高度、双列焦点以及滚动冲突。

资料核对:华为开发者联盟 List API 文档(含 lanes、onScrollIndex、maintainVisibleContentPosition 与 ForEach/LazyForEach 边界):https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-container-list 。文中的 lanes 新签名需要结合项目 SDK/API 版本核对;演示未进行 DevEco 编译,不能替代实际设备验收。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐