工程:FoldHysteresis|任务编号:FH-402|方向:折叠屏多形态
技术栈:ArkUI Grid、GridItem、ForEach、@State、onAreaChange、ArkTS、Node.js

临界宽度到底算单栏还是双栏

做折叠屏相册时,我通常先让页面能够铺满,再考虑折叠形态切换。真正让人头疼的不是宽屏展示两列,而是窗口正在拖动、系统正在重新计算尺寸时,同一块容器会在阈值附近短时间收到多次面积变化事件。假设产品希望接近六百视口单位时由单栏改成双栏,直接拿 width >= 600 判断,五百九十九和六百零一交替出现,列数便来回翻转。网格一重排,用户会感觉卡片在跳,点击中的目标也可能突然换到另一行。

这篇把问题收得很小:不研究系统如何报告设备铰链角度,不试图检测是否真的打开某款折叠设备,只对应用容器反馈的宽度建立有记忆的切栏状态机。双栏进入线定为六百二十,回到单栏的退出线定为五百八十,中间四十的区间继承上一次列数。两个数字是示例产品的设计决策,不表示 HarmonyOS 官方断点。

场景采用名为 FoldHysteresis 的三卡片样例,相册对象分别是 CARD-41、CARD-42、CARD-43。CARD-42 从开始就是选中项;不管画面从一列到两列再回到一列,选中依据始终是字符串业务 ID,而不是第几个子项。这样可以把视图重排和交互状态的变化明确拆开。

先把门禁写成没有界面的函数

我先不碰任何系统 API。状态机只认三个信息:当前列数、最新有效宽度和切栏次数。当前为单栏时,宽度必须大于等于六百二十才进入双栏;当前为双栏时,必须小于等于五百八十才退回单栏。宽度无效、为零或者非有限数值时,不修改当前状态。这一条对初始化阶段有意义:如果某次布局尚未获得有效宽度,不能拿零值触发一轮错误切换。

第一段 ArkTS 放在 model/ColumnGate.ets,不依赖 ArkUI。这样日后产品修改阈值或者引入三栏时,改的是状态规则,不是整套页面事件。实现也没有定时器,不存在“等待三百毫秒再判断”导致的用户体验不确定性。

export class ColumnGate {
  private count: number = 1
  private switchCounter: number = 0
  private static readonly DOWN: number = 580
  private static readonly UP: number = 620

  accept(width: number): number {
    if (!Number.isFinite(width) || width <= 0) return this.count
    if (this.count === 1 && width >= ColumnGate.UP) {
      this.count = 2
      this.switchCounter += 1
    } else if (this.count === 2 && width <= ColumnGate.DOWN) {
      this.count = 1
      this.switchCounter += 1
    }
    return this.count
  }
  switches(): number { return this.switchCounter }
}

这里“滞回”不是额外滤镜,而是一个上下阈值不同的有限状态机。即使系统连续给出六百零五、六百零九、六百零二等尺寸,只要没有触碰另一侧阈值,就不发生列数变化。这和单纯防抖的区别在于:防抖依赖时间间隔,滞回依赖尺寸变化是否真正跨过状态边界;它也不会阻断正常的连续缩放。

把宽度事件接到 Grid,而不是替用户选择卡片

实际页面的网格可以通过 columnsTemplate 传入 '1fr' 或 '1fr 1fr'。官方 Grid 布局资料说明,模板的分数单位控制网格列宽;onAreaChange 则是在组件面积变化时获得新旧区域。代码读取 newArea.width 并转换为数字,既防止拿字符串直接做阈值比较,也为后续检查非有限值留下入口。不同 SDK 版本的声明类型及事件触发时机仍需以工程安装的 SDK 为准。

第二段展示的是页面的关键部分。真实工程另外包含完整的卡片数据和重放按钮,放在随包 demo/ets/pages/Index.ets。这里没有用数组下标保存选中态,因为窗口变化后 GridItem 的可视位置不是一个长期稳定的业务标识。

@State columns: number = 1
@State selectedId: string = 'CARD-42'
private gate: ColumnGate = new ColumnGate()

private applyWidth(width: number): void {
  if (!Number.isFinite(width) || width <= 0) return
  this.columns = this.gate.accept(width)
  console.info(`FH-402 width=${width} cols=${this.columns}`)
}

// build() 的 Grid 段
Grid() {
  ForEach(this.cards, (card: CardItem) => {
    GridItem() {
      Text(card.title)
        .onClick(() => { this.selectedId = card.id })
    }
  }, (card: CardItem) => card.id)
}
.columnsTemplate(this.columns === 1 ? '1fr' : '1fr 1fr')
.onAreaChange((oldArea: Area, newArea: Area) => {
  this.applyWidth(Number(newArea.width))
})

这里要注意两件容易被示例图掩盖的事。其一,onAreaChange 属于布局面积变化事件,不是设备形态回调,更不能凭它判断铰链处于哪个角度。其二,应用更新列数本身可能引发一次新的布局计算;因为状态机不会对相同宽度反复切换,事件再次到达也不会无限自激。如果 Grid 所处容器还有侧边栏、留白和父组件约束,应该基于真实网格容器宽度设计阈值,而不是硬拿整机屏幕宽度。

一条五节点轨迹,先验逻辑再谈设备

我给构造数据的宽度序列为五百九十、六百一十五、六百二十三、六百零五、五百七十九。第一、二个点保持单栏,第三个点首次进入双栏,第四个点在滞回区间保持双栏,最后一个点退回单栏。预期列数序列应该是 1、1、2、2、1,切换次数为二,选中卡片始终是 CARD-42。这些输入是为了覆盖边界而设计,并非“折叠几次平均会回调多少次”的统计。

第三段 Node.js 代码只调用纯 JavaScript 状态机,并用断言检查轨迹。随包的 demo/replay-widths.mjs 还包括无效宽度检查、JSON 报告写入及退出行为。执行命令是 node demo/replay-widths.mjs,不需要联网,也没有额外 npm 依赖;它验证的是业务算法,不是 ArkTS 在某台设备上的编译兼容性。

const widths = [590, 615, 623, 605, 579]
let cols = 1
let switches = 0
const outputs = widths.map((width) => {
  const before = cols
  if (cols === 1 && width >= 620) cols = 2
  else if (cols === 2 && width <= 580) cols = 1
  if (cols !== before) switches += 1
  return cols
})
console.log(`FH-402 columns=${outputs.join(',')} switch=${switches}`)
// FH-402 columns=1,1,2,2,1 switch=2

我在本文生成时实际运行了随包 Node.js 脚本,得到 COLS=1 SWITCH=2 SELECT=CARD-42,脚本同时拒绝了一次 NaN 输入,状态保持不变。记录见 demo/reports/result.json。这个结果只证明离线轨迹满足自定义规则;图中的 HiLog 行用于说明开发时应记录哪些字段。

如果以后接入真实页面,我会补四类检查:缓慢拖动和快速拖动各一次,极窄容器到较宽容器的边界往返,窗口被系统临时缩到零时的保护,以及用户在过渡中持续点击同一张卡片的选中态检查。最容易遗漏的不是中间状态,而是从双栏回到单栏时旧焦点是否仍落在可见卡片上。无障碍焦点和软键盘布局另属系统交互测试,不因业务 ID 正确就自动通过。

生命周期、异常和后续扩展

这个样例没有工作线程和图片解码对象,ColumnGate 只是轻量对象,页面销毁后由组件生命周期管理。将来若增加按时间节流的定时器,必须在组件消失时取消计时;若接入外部数据订阅,也要解除监听,避免隐藏页面继续刷新列数。对于不合理宽度,当前策略是保留最后一次有效列数并记录告警,而不是拿异常值猜测设备形态。

当产品确实需要官方断点响应时,可以考虑 GridRow/GridCol 和 breakpoints 机制;但本篇刻意选择 Grid + 应用级滞回,是因为需求不是普通的静态宽度分栏,而是阈值附近的重复切换。两种手段服务的对象不同,不能用这段状态机声称比系统响应式布局更准确,更不能把六百二十/五百八十称为框架推荐数值。工程评审时应由设计稿、字体缩放、最小可点区域及实际容器边距决定阈值。

最后留下一个清楚的判断标准:输入和列数分开记录,视图重排和选中态分开维护,离线断言和设备验证分开报告。这样即使换一套折叠形态,也能定位到底是区域回调有问题、门禁算法有问题,还是 GridItem 自身的布局有问题。

官方资料与版本边界

  • 华为《使用 Grid 构建网格布局》,说明 rowsTemplate、columnsTemplate 的网格设计与参数约束:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-layout-development-create-grid
  • 华为 ArkUI FAQ:onAreaChange 响应组件面积变化,示例展示新旧区域对象:https://developer.huawei.com/consumer/en/doc/harmonyos-faqs-V14/faqs-arkui-9-V14
  • 华为《响应式栅格布局》,用于比较内建 GridRow/GridCol 断点设计:https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-layout-development-grid-layout
Logo

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

更多推荐