一个工单在宽屏上打开时,左侧是列表,右侧是详情;将窗口收窄后,同一工单应该如何呈现?如果只用“手机显示单列、平板显示双列”回答,真实项目往往还会遇到第二个问题:折叠屏展开时正在编辑的工单没有变,界面却可能重新建立了详情导航,结果出现重复详情页、返回路径混乱,甚至把选中高亮跳到了另一条记录。这不是屏幕不够宽,而是窗口形态变化被误当成业务状态变化。

本系列前四篇逐步完成了工单模型、断点回调、筛选与导航一致性,以及 WorkOrderSource 和 LazyForEach 的长列表适配。现在只调整“如何呈现”,不修改 WorkOrder { id, title, status, description }、workOrderSelectedId 或 WorkOrderSource 的数据所有权。对于折叠屏、自由窗口、分屏和横竖屏,只要应用实际可用的窗口宽度发生变化,就允许布局策略重新计算;不能把设备型号当成布局判据,也不能假设“折叠”永远等于“宽屏”。

响应式工单台折叠与多窗口适配技术封面

从设备名称转向窗口宽度

折叠屏折起可以很宽,也可以被另一个应用挤压得只剩一列;平板开多窗口后,同样可能只得到窄内容区。把 deviceType === 'tablet' 写成双栏判断,用户稍微调整窗口,详情就会被压成一条竖线。更合理的条件是 GridRow 的断点:sm 使用完整宽度列表和可导航详情,md、lg 视为可容纳主从双栏的区域。具体阈值必须结合字号、列表最小宽度、触控目标、系统缩放与实际设计验收调整,不能仅靠某个物理设备尺寸拍板。

这里刻意不使用未查证的“铰链角度状态回调”或所谓系统级平行视界专用 API。GridRow 和 GridCol 是 ArkUI 的公开组件,onBreakpointChange 报告栅格布局区间变化;页面依据它决定左右栏结构。至于屏幕铰链区域避让、窗口生命周期及应用多实例等更深层系统行为,属于后续需按目标机型和窗口模式单独验证的条件,不能由一个栅格回调推导出“已经识别了折叠姿态”。

窄窗口、宽窗口和折叠屏展开时的主从布局策略对比

让断点变化只影响呈现

一种常见的错误写法是在断点回调里调用 applyFilter(),再把选择设置为 visibleOrders[0].id。窗口变化并没有改变筛选条件与数据内容,这样做却悄悄丢掉了当前用户的目标工单。另一个隐蔽问题是只根据宽度判断“需要打开详情”,然后自动向 NavPathStack 压入新目的地:用户从宽屏缩回窄屏,反复几次就可能产生多个详情页面,返回键要按多次才能回列表。

将布局切换看成一个纯粹的策略决策更容易排查。selectedId 依然负责业务身份;compact 仅表示当前呈现方式;导航栈只记录窄屏里用户主动打开详情的动作。进入宽屏时清理冗余的窄屏详情路径、直接在右侧展示当前工单;从宽屏回到窄屏时默认展示列表,而不是凭空生成用户从未点击的页面。返回上级后,业务选择仍然保持,便于用户继续查找同一工单。

// entry/src/main/ets/model/LayoutPolicy.ets
import { WorkOrder } from './WorkOrderSource';

export enum LayoutMode { Compact = 'compact', Split = 'split' }

export class LayoutPolicy {
  static modeFromBreakpoint(value: string): LayoutMode {
    return value === 'sm' || value === 'xs' ? LayoutMode.Compact : LayoutMode.Split;
  }

  static validSelection(visible: WorkOrder[], selectedId: number): number {
    if (visible.some((item: WorkOrder): boolean => item.id === selectedId)) {
      return selectedId;
    }
    return visible.length === 0 ? -1 : visible[0].id;
  }

  static visibleIndex(visible: WorkOrder[], anchorId: number): number {
    return visible.findIndex((item: WorkOrder): boolean => item.id === anchorId);
  }
}

LayoutPolicy 是项目自己定义的纯业务类,不是系统 API。上面的 WorkOrder 直接导入上一阶段的模型定义,避免复制出第二份结构。它的三个函数分别回答“当前展示单栏还是双栏”“筛选后当前选择是否合法”“历史滚动锚点能否在新列表中定位”。三个问题不应互相代替。尤其 validSelection 只在业务数据或过滤条件变化后调用;断点变化时,选择保持原值。所有代码使用上一阶段的 WorkOrder 类型,实际集成时应直接从既有模型文件导入,不需要在项目中再建立第二份不同含义的数据模型。

把导航的两个入口约束清楚

下面示例展示对既有 Index.ets 的增量修改。listPane()、detailPane()、WorkOrderSource、applyFilter() 直接延续上一篇,这里集中写状态变化与导航部分,而不是复制整个长列表实现。@StorageLink 仍然使用之前确定的应用内键值;它负责进程内共享状态,不提供跨卸载或跨进程的持久化保证。页面根据 compact 决定是否使用路由展示详情,并且把 selectedId 变更严格限定在用户选择或过滤数据变化时。

// Index.ets 内的增量结构;其余 Builder 和数据源沿用连载04
@StorageLink('workOrderSelectedId') selectedId: number = 1;
@State private compact: boolean = true;
private pathStack: NavPathStack = new NavPathStack();

private choose(item: WorkOrder): void {
  this.selectedId = item.id;
  if (this.compact) {
    // 仅用户主动点选窄屏列表时创建详情目的地
    this.pathStack.pushPath({ name: 'WorkOrderDetail' });
  }
}

private onLayoutBreakpoint(value: string): void {
  const nextCompact: boolean = LayoutPolicy.modeFromBreakpoint(value) === LayoutMode.Compact;
  if (nextCompact === this.compact) return;
  this.compact = nextCompact;
  // 进入双栏时由右侧区域呈现详情,不再保留窄屏的目的地
  if (!nextCompact) this.pathStack.clear();
  // 不调用 applyFilter,也不改 selectedId
}

// GridRow(...){ GridCol(...){ this.listPane() } ... }
// .onBreakpointChange((value: string) => this.onLayoutBreakpoint(value))
// Navigation(...).navDestination(this.destination).mode(NavigationMode.Stack)

窄屏列表条目连续点击的处理还需结合实际 Navigation 跳转策略:如果详情页当前已经是栈顶,再次选择通常应更新当前 selectedId,而不是无条件重复压栈;这里的 choose() 是从列表点击进入详情的入口。在完整工程里,可以在页面层封装一个 openDetailFromList(),统一判断当前目的地,避免将按钮重复点击行为混入断点策略。即使实现了单栈控制,也不应把 NavPathStack.clear() 解释为“业务数据被重置”,它清理的是窄屏页面路径。

断点切换、导航栈与业务 selectedId 的状态流转

滚动锚点不是选中 ID 的别名

上一篇为了列表性能引入了 LazyForEach,并用稳定业务 ID 作 Key。这样做保障了数据项身份,但并不自动保存具体滚动位置。想象宽屏显示第八百条工单,用户把窗口拖成窄屏,列表重建后跳回开头;选中详情还正确,体验却很割裂。相反,若只保存旧的 index=800,在筛选或者重新排序后它可能指向其他工单。正确的锚点是“最后明确可见的业务 ID + 对应筛选条件”,并且恢复动作必须在数据源刷新与布局尺寸稳定之后执行。

可以给页面增加 anchorId 与 filterFingerprint 两个业务字段。这里不在断点回调中立即调用 Scroller.scrollToIndex,因为此时新的 List 可能还没有完成布局;而是在恢复入口读取 source.snapshot(),调用 LayoutPolicy.visibleIndex() 定位,再由页面恰当的生命周期或用户“返回原位置”操作触发滚动。若锚点已被删除或当前筛选条件改变,则放弃旧位置,让列表从新结果合理起点呈现。设计重点是先保证位置语义准确,再决定恢复发生的生命周期时机。

// 视图恢复前执行的数据层检查,不依赖窗口管理权限
interface ListAnchor { id: number; filterKey: string; }

function resolveAnchor(source: WorkOrderSource, anchor: ListAnchor,
  currentFilterKey: string): number {
  if (anchor.filterKey !== currentFilterKey) return -1;
  return LayoutPolicy.visibleIndex(source.snapshot(), anchor.id);
}

这个函数不会调用任何设备专属功能,测试时可以用“筛选结果变动后锚点失效”“纯窗口变化后锚点仍有效”两种输入直接验证。对于真正需要跨 Ability 销毁恢复位置的项目,应把锚点写入受管理的本地存储,并在读取后再次核验它属于当前过滤条件。应用级共享状态、磁盘持久化、窗口布局缓存是三种不同的生命周期,不宜拿一个字段统包。

用事件矩阵验证策略而不是只看一张截图

建议分别记录当前断点、selectedId、目的地栈深度、列表源的可见条数及滚动锚点。测试从窄屏点开工单开始:进入详情后拉宽窗口,预期导航目的地清空,而业务 ID 仍为原值;再缩窄窗口,预期返回列表而不是自动再压入旧详情。之后保持宽度不变,筛掉当前选择,才允许由 applyFilter() 按前一篇的契约校正选择。还要让窗口宽度在阈值附近来回变化,确认只是呈现切换,不伴随重复数据查询或选择漂移。

断点边界、筛选变化与滚动恢复的回归检查图

多窗口情况下,一次查询请求可能在布局切换前发出、切换后才返回。这里应继续让数据源层负责结果更新,布局回调不重发同一请求,否则很难区分“窗口变化”与“重复业务请求”。若应用存在不同 Ability 窗口且各自拥有独立页面栈,则需要明确它们是否共享业务选择;本文仅讨论一个页面所在窗口的栅格响应,不能由 @StorageLink 推断多进程、多窗口实例间的全部一致性。

真正的验收不是证明某台设备“看起来双栏很漂亮”,而是把窄屏、宽屏、折叠展开、分屏收窄、旋转、字体放大、搜索清空、删除选中项等条件做成组合矩阵。验证前记录固定设备、系统、SDK及窗口模式;观察结果应来自实际调试,不把预期行为编成测试报告。本阶段给出的是可实施的组件与业务状态划分,最终效果仍要在目标产品的窗口策略和设备上复核。

本系列至此保住了一条设计底线:工单身份属于业务层,可见列表属于数据源层,详情路由属于导航层,栅格断点属于呈现层。它们各自维护自己的状态,才有机会在窗口突然变化时做到“界面变了,用户正在做的事没变”。第六篇将围绕这一架构整理适配回归矩阵、边界条件和工程收尾,让零散的局部优化变成可维护的发布方案。

参考资料:华为开发者:GridRow 栅格容器、GridCol 栅格子组件、LazyForEach 数据懒加载、窗口生命周期。

Logo

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

更多推荐