在 PageShelf 借阅工作台里,窗口从平板宽度收窄到手机宽度,书卡会自动从四列变成一列。上一期加入 Navigation 和 NavPathStack 后,点进《算法的日常》再返回,也能保留“只看待处理”。然而一次普通的拖动窗口仍可能制造新问题:书架原本停在靠下的位置,列数改变,卡片被重新排版;同样的纵向偏移不再对应同一本书。有时用户甚至误以为筛选条件丢了,实际上只是“视口的位置”与“业务记录的身份”被混成了一个概念。

这一篇不再增加新的书架业务,而是处理窗口布局、滚动偏移、筛选集合、详情路由四者相互作用。工程依旧叫 PageShelf,沿用四条 ShelfBook { id, title, section, borrowed } 静态记录、showPendingOnly、断点 360/600/840vp,以及上篇的 BookRoute.bookId 和 NavigationMode.Stack。当前只讨论组件和内存状态;窗口拖动不构成应用进程重启,也不意味着可以绕过真正的数据持久化。

多端书架窗口与阅读上下文主题封面

一、不要用一个 offset 代表整个阅读上下文

可以想象两个连续动作。先打开宽窗口,显示全部四本书,滚动到第二行;再按“只看待处理”,数据从四条缩减为两条。即便 Scroller.currentOffset().yOffset 返回原来的数值,也无法保证这个位置在新集合中还有意义。换成一列后更明显:同一偏移距离可能落在别的卡片中段。所谓恢复,至少有三个不同层次:当前筛选是什么、曾经关注哪本书、是否要继续停留在相近的视觉位置。它们的保存时机与可信程度不能相同。

筛选开关是业务状态,适合由 @State 保存。被点击的书籍身份是稳定标识,沿用 bk-01 至 bk-04 的 id。滚动偏移则属于某次布局的物理测量,只有在内容集合及窗口结构没有大变化时才适合复用。设计上要先判断上下文是否一致,再决定是否滚动;不要在每一次布局回调里机械执行 scrollTo,否则滚动操作本身改变布局,容易形成反馈循环。

NavPathStack 管理的是书架与图书详情之间的导航关系。这里不把窗口宽度或滚动距离塞进路由参数。路由携带 BookRoute.bookId 就够了;宽窄布局属于列表组件内部,筛选属于书架宿主。把几种状态放在各自有意义的位置,后续才能追踪“返回页面”和“改变窗口”分别做了什么。

二、把捕获滚动位置变成明确的操作

下面是 entry/src/main/ets/pages/Index.ets 中承接上一期的状态增量。Scroll(this.scroller) 与 Scroller 的绑定只有在滚动组件创建以后才有效,因此不在 aboutToAppear 中读取滚动位置。我们使用滚动停止事件保存偏移,并提供“回到上次位置”按钮用于可控恢复,避免频繁拖动窗口时不断触发补偿滚动。

// Index.ets:保留上一期的 ShelfBook、BookRoute 和 Navigation 主体
@State private showPendingOnly: boolean = false;
@State private savedOffsetVp: number = 0;
@State private savedFilter: boolean = false;
@State private viewportLabel: string = '窄窗口';
private scroller: Scroller = new Scroller();
private pathStack: NavPathStack = new NavPathStack();

private rememberScroll(): void {
  const offset = this.scroller.currentOffset();
  if (offset && Number.isFinite(offset.yOffset)) {
    this.savedOffsetVp = Math.max(0, offset.yOffset);
    this.savedFilter = this.showPendingOnly;
  }
}

private restoreScroll(): void {
  if (this.savedFilter !== this.showPendingOnly) { return; }
  this.scroller.scrollTo({ xOffset: 0, yOffset: this.savedOffsetVp,
    animation: false });
}

这里的 savedFilter 是“位置快照产生时的数据条件”,不是另一份可供按钮切换的筛选状态。只有它与当前 showPendingOnly 相同,才允许使用旧偏移;否则直接拒绝恢复,提示用户重新定位。使用 Number.isFinite 是防止异常数值流入滚动参数,并不能替代绑定时机检查:组件尚未建立时 currentOffset() 可能拿不到有效结果,因此 rememberScroll 只应从用户真正滚动后的回调触发。

本例保留简洁的展示策略:用户主动点“回到上次位置”后才恢复。高级产品可进一步保存 anchorBookId,通过卡片可见区域判断最靠近顶部的图书,再在数据变化后根据 ID 定位。这种“书籍身份锚点”比像素偏移更稳,但要先解决每个断点下卡片高度变化与异步渲染顺序。本篇不把一个未经测量的 index × 固定高度 当作可靠实现。

PageShelf 白色主题 DevEco 工程布局、状态字段和窗口预览

三、让 GridRow 只回答“怎么排”,不决定“看哪本”

本期仍使用 GridRow、GridCol,因为当前是四本静态书籍,无需为了验证滚动而换成完全不同的列表技术。onAreaChange 只能说明当前组件区域发生尺寸或位置变化,不能作为窗口全局生命周期回调;也不承诺先于所有子组件回调触发。下面的代码仅记录宽窄状态,不在里面重设筛选、清空导航栈或调用滚动恢复。

@Builder
private shelfBody() {
  Column({ space: 12 }) {
    Row() {
      Text('多端书架').fontSize(25).fontWeight(FontWeight.Bold)
      Blank()
      Button(this.showPendingOnly ? '显示全部' : '只看待处理')
        .onClick(() => { this.showPendingOnly = !this.showPendingOnly; })
    }
    Text(this.showPendingOnly ? '待处理 · 2本' : '全部图书 · 4本')
      .fontSize(14).fontColor('#64748B')
    Text(this.viewportLabel).fontSize(13).fontColor('#64748B')
    Scroll(this.scroller) {
      GridRow({
        columns: { xs: 4, sm: 4, md: 8, lg: 12 },
        gutter: { x: 12, y: 14 },
        breakpoints: { value: ['360vp', '600vp', '840vp'],
          reference: BreakpointsReference.WindowSize }
      }) {
        ForEach(this.visibleBooks, (book: ShelfBook) => {
          GridCol({ span: { xs: 4, sm: 4, md: 4, lg: 3 } }) {
            this.bookCard(book)
          }
        }, (book: ShelfBook) => book.id)
      }
    }
    .layoutWeight(1)
    .onScrollStop(() => this.rememberScroll())
    Button('回到上次位置').onClick(() => this.restoreScroll())
  }
  .width('100%').height('100%').padding(20)
  .onAreaChange((_old, next) => {
    const width = Number(next.width);
    this.viewportLabel = width < 600 ? '窄窗口' : '宽窗口';
  })
}

visibleBooks 和 bookCard 直接复用上一期。原始书籍仍是《分布式系统笔记》《类型与边界》《算法的日常》《设计中的留白》,待处理集合是 bk-01、bk-03。最外层 build() 继续写成 Navigation(this.pathStack) { this.shelfBody() },目的地仍由上一期的 destinationBuilder 构造;不要复制一份 Navigation 到 shelfBody 里。这样即使窗口跨过 600vp,页面的 Navigation 栈也不因列表重新布局而被创建第二次。

一个细节是过滤时的恢复策略:如果筛选前停在一个偏移较大的位置,筛选后只有两张卡片,旧偏移可能超过新内容高度。即使系统会裁剪实际滚动范围,也不该把“裁剪到了底部”误报成“恢复了同一本书”。为此本例用 savedFilter 拦截跨筛选恢复;若需要严格的语义恢复,再升级为 ID 锚点模型,并在完成布局后实际定位,不能靠当前示例的数字偏移硬推。

四、用四组场景找出状态归属错误

第一组是正常返回。进入页面,点“只看待处理”,进入 bk-03《算法的日常》,返回后应该仍显示两本待处理图书;点击“显示全部”才回到四本。若此处筛选丢失,优先检查 Index 是否在导航过程中被销毁重建,以及 showPendingOnly 是否定义到了 BookDetail 内,而不是误判为 GridRow 的断点问题。

第二组是快速改变宽度。保持书架页面,分别在 599vp 和 600vp 两侧反复调整,检查卡片排列、文本换行以及按钮是否依旧可点。预期 viewportLabel 按规则变化,但 showPendingOnly 与路由栈不变。若看到连续跳动,先排查是否在 onAreaChange 里反复写入滚动命令,而不是把抖动简单归咎于设备性能。 四本样例书籍在宽屏上可能全部位于首屏,此时不会产生有意义的滚动;验证滚动恢复应缩小视口高度,或在独立测试分支扩充数据。不能为了让断言看上去通过,就假设任何设备都必然存在可滚动距离。

第三组测试滚动位置。保持相同筛选,手动滚动、停止,再点击“回到上次位置”。在同一布局下应尝试回到保存偏移附近;再切换筛选,旧偏移不能被不加判断地强行套用。位置会受卡片高度变化和容器范围约束,因此这里验证的是“恢复决策是否正确”,不是声称每个像素永远一致。滚动控制器的 currentOffset 在未绑定时可能没有结果,这个前提必须写进测试记录。

第四组测试缺失数据。进入详情后,如果 BookRoute.bookId 找不到相应图书,上篇的空态应继续显示,并提供返回路径;窗口变宽或变窄都不能把无效的 ID 偷换为数组当前位置的另一项。若将来替换为网络数据源,还需要区分“尚未加载”和“图书确实不存在”,否则用户会把加载延迟误认为数据丢失。

筛选条件、滚动快照和图书ID之间的恢复决策流程

五、收束到一个可维护的状态模型

这一轮并不追求让所有场景都自动把屏幕移动到原来那一行,而是让每次决定有明确依据:筛选取自业务状态,详情取自稳定 ID,滚动偏移只在筛选上下文一致时复用,窗口大小事件只负责响应式展示。进一步的自动语义锚点需要专门的数据布局能力与实际设备验证,尤其是大规模、异步加载书籍时,简单 Scroll + ForEach 不适合作为长期高性能方案。

下一篇将完成 PageShelf 的工程收尾:考虑空态、异常和回归检查,并评估何时该从小规模 GridRow 过渡到按需加载的列表组件,而不是把这一次的示例直接包装成通用模板。

参考官方文档:Navigation 与 NavPathStack、Scroll 与 Scroller、GridRow、组件区域变化事件。所用控制器行为以 API 11 起的接口说明为依据;发布工程前需使用实际工程 SDK 和设备回归。

Logo

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

更多推荐