一、宽度变了,书架不该像重新打开

一份书架列表在普通手机上只有一列,展开后希望一屏看到两列。看似只需要改动 List.lanes,实际会牵出一个更难察觉的交互问题:用户正在看的那本书,切换布局后不在原来的可见区域了。列表没有报错,数据也没丢,但阅读连续性被打断。这个问题不能简单地拿“保持滚动偏移量”来解决,因为一列和两列的行数、卡片高度、可见范围都可能不同。

本文用一个专门为讨论状态转换而设计的 ShelfAnchor 演示工程,把“切换宽度时继续看哪本书”作为唯一工程目标。示意任务编号为 SA-203,书架18本,业务锚点为 BK-007。紧凑窗口宽度取392vp、单列;展开示意宽度760vp、双列。图中的时间、书名和日志都是为了让论证可以对照的演示数据,不代表已经在折叠设备上实测。

与平行视界里维护详情栈不同,这里不修改页面路由,也不新增第二份书架数据。只允许列表布局发生变化;书籍集合、选中书籍和“上一次看到的位置”必须有各自的稳定身份。这条约束有点严格,但后面的复位逻辑因此能够解释得清楚。

二、位置要用业务锚点,不用像素偏移

第一种容易想到的做法,是读取当前滚动距离,在切换列数后设置相同的距离。它在卡片完全等高、列数不变时或许看起来可行;一旦从单列变成双列,几百vp对应的内容已经不同。更何况用户字体大小变化、封面加载完成、简介换行,都可能让同一条数据的布局高度不同。像素偏移是视图结果,不是书籍身份。

ShelfAnchor选择保存“当前第一条可见记录的业务ID”。演示中它是BK-007,源数据从BK-001到BK-018顺序固定;进入双列以后应把BK-007所在行重新滚入视口。并排显示的BK-008只是相邻书籍,不意味着用户选择发生改变。这样,即使单列和双列的滚动距离完全不同,也能用同一个ID定位目标。

需要区分三个概念:anchorId表示希望恢复哪一条;firstVisibleId表示恢复后当前列表报告的首条可见记录;selectedId才表示用户显式点击的书籍。三者可以相等,却不能作为同一个状态字段。否则用户只是在滑动书架,程序可能误把滑动理解为点击;复位动画的回调也会反过来污染下一次锚点。

另外,演示采用18条静态数据,是为了把问题限制在布局本身。真实项目如果还有分页、推荐插入、封面异步加载,必须先保证列表排序稳定。排序版本变化后,原锚点可能不再存在,此时应回退到一个可解释的位置,而不是用越界索引继续滚动。

三、先定义书籍ID和恢复状态

我们需要在数据入列之前做一个判断:书籍索引可以改变,但业务ID不能被卡片位置替代。下面是模型和状态的精简写法;buildBooks是演示工程自己构造数据的函数,不是HarmonyOS系统接口。

interface BookItem {
  id: string;
  title: string;
}

function buildBooks(): BookItem[] {
  const items: BookItem[] = [];
  for (let i = 1; i <= 18; i++) {
    const id = `BK-${i.toString().padStart(3, '0')}`;
    items.push({ id, title: `书籍 ${i}` });
  }
  items[6].title = '风中的旧地图';
  items[7].title = '街巷观察册';
  return items;
}

BK-007恰好位于数组索引6。代码里显式保留了这一层转换:展示用的是标题,定位用的是ID,下标只是在当前数据快照中的临时坐标。后续如果数组发生增删,findIndex必须重新计算;把6永久写死,会让所谓锚点恢复退化成一次偶然正确的跳转。

从状态管理的角度,RESTORING和RESTORED也不应混用。前者只表示已经发出了定位请求,后者需要在视图完成布局后重新核对。示例图片使用RESTORED来解释目标效果,但它是静态画出的结果,不能拿来证明设备真的恢复到了指定位置。实际验收要记录执行前后可见ID,而不是只看按钮是否点亮。

四、List 列数变化时冻结锚点写入

List的lanes控制交叉轴列数,Scroller可以执行按索引定位,onScrollIndex能够报告列表可见范围。官方文档还说明List在多列布局时,列数变化会改变行的组织方式。这几个能力组合起来,可以写出一个有限状态机,但还需要自己的业务保护条件。

@Entry
@Component
struct ShelfPage {
  private scroller: Scroller = new Scroller();
  private books: BookItem[] = buildBooks();
  @State lanes: number = 1;
  @State anchorId: string = 'BK-007';
  @State firstVisibleId: string = 'BK-007';
  @State phase: string = 'IDLE';
  private restoring: boolean = false;
  private restoreTimer: number = -1;
  private generation: number = 0;

  build() {
    Column() {
      Text(`可见锚点 ${this.anchorId} · ${this.phase}`)
      List({ scroller: this.scroller }) {
        ForEach(this.books, (book: BookItem) => {
          ListItem() { Text(`${book.id}  ${book.title}`) }
        }, (book: BookItem) => book.id)
      }
      .lanes(this.lanes)
      .onScrollIndex((first: number) => {
        if (this.restoring || first < 0 || first >= this.books.length) return;
        this.firstVisibleId = this.books[first].id;
        this.anchorId = this.firstVisibleId;
      })
      .onAreaChange((_old, next) => {
        this.handleWidth(parseFloat(next.width.toString()));
      })
    }
  }

这里ForEach的key来自业务ID而不是数组下标;18条示例数据用全量ForEach足够,不能将它直接推断为大型相册也适合一次性构建。更大的书架应另评估LazyForEach或Repeat,那是加载效率问题,与本篇的锚点问题不是一回事。

尤其要注意restoring这个守卫。当程序主动调用scrollToIndex时,List本身仍可能发出滚动相关事件。如果每次onScrollIndex都立即覆盖anchorId,目标在恢复过程中就会被滚动反馈改掉。开发者看日志会误以为索引计算出错,实际是“状态写入者”没有区分用户滚动与程序滚动。

图02是开发界面示意:左侧工程目录、中间ArkTS关键逻辑、右侧模拟器、底部HiLog。日志中的SA-203、BK-007、760vp和RESTORED都是预设的演示字符串,不是编译或设备实测记录。这样处理比在文章里虚构一次成功的折叠动作更适合讲清机制。

五、布局完成后再执行恢复,过期任务要取消

真正容易漏掉的是时机。改变lanes会触发重新布局,紧接着调用Scroller不一定意味着新行列已经稳定。本例通过尺寸变化事件触发恢复请求,并用一次延后任务作为示意;正式应用仍要结合目标SDK的布局完成时机与实际数据加载阶段做验证,不能依赖固定毫秒数声称百分之百成功。

  private handleWidth(width: number): void {
    if (!Number.isFinite(width) || width <= 0) return;
    const nextLanes: number = width >= 600 ? 2 : 1;
    if (nextLanes === this.lanes) return;
    this.lanes = nextLanes;
    this.scheduleRestore();
  }

  private scheduleRestore(): void {
    const ticket: number = ++this.generation;
    this.restoring = true;
    this.phase = 'RESTORING';
    if (this.restoreTimer >= 0) clearTimeout(this.restoreTimer);
    this.restoreTimer = setTimeout(() => {
      if (ticket !== this.generation) return;
      const index = this.books.findIndex(b => b.id === this.anchorId);
      if (index < 0) {
        this.phase = 'ANCHOR_MISSING';
      } else {
        this.scroller.scrollToIndex(index, false);
        this.phase = 'RESTORED'; // 请求已发出,仍需验收实际可见项
      }
      this.restoring = false;
      this.restoreTimer = -1;
    }, 0);
  }

  aboutToDisappear(): void {
    ++this.generation;
    if (this.restoreTimer >= 0) clearTimeout(this.restoreTimer);
  }
}

这里的RESTORED是演示状态名,严格说应理解为“恢复命令已发出”。真实工程最好再用可见项回调核对目标是否真的回到视口范围,必要时将状态细分成REQUESTED和VERIFIED。如果布局刚稳定时还有封面尺寸变化,就可能需要第二次核验,但绝不应该无条件无限重试。

generation用来让旧恢复任务失效:假设用户迅速从展开切回折叠,前一次异步回调稍后触发,旧目标就有可能覆盖新的布局。计数票据不负责提升渲染速度,只负责确保过期回调不再修改最新状态。aboutToDisappear则清理还没执行的计时器,避免页面退出后继续使用无效的滚动控制器。

六、三组边界,决定这套方案是否可信

第一组是数据边界。若BK-007已被删除,findIndex返回-1时不能继续调用定位。演示把状态标为ANCHOR_MISSING;真实产品可以回退到相邻仍存在的业务记录、当前章节开头或列表首项,但需要和产品明确约定。不可把“回到顶部”默默包装成“位置恢复成功”。

第二组是布局边界。392vp与760vp是为讲述单列、双列差异而设的样例容器宽度,不等同于所有折叠设备的屏幕尺寸。列数阈值600vp是应用策略,既不是系统强制断点,也不能代替窗口可用区域、字体缩放和安全区计算。只要窗口的实际内容区域变化,就应重新检验设计阈值是否合适。

第三组是事件边界。主动定位会产生回调,用户手指操作也会产生回调。让restoring在窗口变化过程中冻结锚点写入,只能解决交叠的一部分;如果用户在复位尚未完成时主动滑动,实际产品应该尊重新的用户意图,撤销这次恢复,而不是强行把列表拉回旧位置。需要明确“用户触摸优先”还是“恢复过程优先”,不要两套机制相互争夺滚动位置。

静态示意页面记录的统一契约是:任务SA-203,18本书,展开后760vp和2列,锚点BK-007,界面状态RESTORED;HiLog示意依次展示锚点保留、列数切换、恢复请求。图03用于检查文字标签的意义,并不对应一次真实设备操作。

七、收束到一个可验证的工程结论

我更愿意把这类问题叫作“视图结构改变时的身份连续性”。系统负责重排,应用负责记住用户正在处理的对象,两者不能互相替代。List.lanes解决一列和两列的布局表达,Scroller.scrollToIndex提供跳转能力,onScrollIndex提供可见索引线索;业务ID、恢复票据以及异常回退才把这些接口连接成可维护的方案。

后续真正上设备验证,至少要覆盖从单列到双列、再从双列回单列、数据中途插入、锚点被删除、列表尚未初始化和快速连切六个路径。每条路径记录操作前锚点ID、布局宽度、列数、滚动请求索引、最终可见项以及页面退出后的异步任务数量。只有这些证据具备,才适合声称恢复稳定。本文没有生成真实折叠屏测试数据,也没有把示意图当成测试截图。

官方参考:华为开发者文档《List》(2026-09-09,含lanes、onScrollIndex及列表滚动控制说明):https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-container-list 。

Logo

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

更多推荐