在大屏设备上做图片审核,最先想到的通常是“屏幕变宽以后多排几列”。这个判断只完成了一半。真正让人困惑的现象是:刚刚选中的第十六张照片,切换窗口宽度后还显示在列表里,却突然不再是选中态;或者页面因为反复调整列数而闪一下,操作焦点也跟着挪动。列数是布局属性,业务选择是数据属性,把两者绑在一起才是隐患。

本文设计 FoldProof 演示工程,页面是 ReviewGridPage.ets,任务 FD-207,数据池包含 24 张待审图片,当前选中 IMG-026。为了让配图与正文使用同一套参数,宽布局示意宽度固定为 720 vp,应用规则按此展示 3 列;窄布局示意为 390 vp、2 列。这里谈的是容器可用宽度变化,并不声称已经获取了某款折叠屏铰链角度,也不是 EasyGo 平行视界能力验收。

一、先确定什么不能跟着屏幕一起重置

1. 列数、数据顺序与选中项属于不同层次

Grid.columnsTemplate 决定如何排版;ForEach 的稳定 key 帮助框架识别一条数据;selectedId 则由业务自己管理。三者可以协作,但不能混成一个“当前第几格”的变量。假设第一列显示五张,调整成三列以后,索引 5 代表的屏幕位置完全不同,图片 ID 却还是同一个。用视觉位置存储选择态,天然会在换列后失效。

示例的二十四张图片使用 IMG-011 到 IMG-034 作为本地业务 ID,恰好包含 IMG-026。卡片并非真实相册资源,因此示意图只放抽象图片缩略块;若接入 PhotoAccessHelper,还要另外处理图库 URI 授权、媒体消失和缩略图回收,不应该仅凭这篇示例直接申请相册全量访问权限。

2. 宽度应该取页面真实可用区域

折叠设备、平板分屏、外接显示器和普通手机横屏,可能拥有相似的逻辑宽度。若需求只是“可用空间大时多展示”,直接按可用宽度决定列数,更容易覆盖这些场景。本文的 600 vp 分界线只是业务选择:达到 600 vp 取三列,否则两列;不假装它是系统官方规定的折叠阈值。

二、先把宽度决策写成无副作用函数

演示代码先隔离列数计算,避免在 ArkUI 的构建阶段夹杂资源申请与 I/O。对宽度非法或还没完成布局的情况给一个稳定兜底,能减少初始化阶段出现意外字符串模板。

export function columnsForWidth(widthVp: number): number {
  if (!Number.isFinite(widthVp) || widthVp <= 0) {
    return 2;
  }
  return widthVp >= 600 ? 3 : 2;
}

export interface ReviewPhoto {
  id: string;
  label: string;
}

export function buildReviewPhotos(): ReviewPhoto[] {
  const photos: ReviewPhoto[] = [];
  for (let id = 11; id <= 34; id++) {
    photos.push({ id: `IMG-${id.toString().padStart(3, '0')}`,
      label: `待审照片 ${id}` });
  }
  return photos;
}

这个函数不会知道设备型号,也不需要访问系统显示对象。输入 720 得到 3,输入 390 得到 2;它不会替你决定最合适的手机端卡片宽度,真实项目还应考虑字体放大、侧栏占位、窗口拖拽和图片标题长度。二十四张样例素材是人为构造的审核清单,不代表拍摄结果,更不能用于性能对比。

3. 三列阈值附近要先定义产品行为

宽度 599 vp 与 600 vp 相邻,却对应不同列数。普通静态切换问题不大;连续拖拽分屏边界时,系统可能多次报告宽度变化,用户会看到网格重复换列。可以在正式产品中加入“进入三列”和“回到两列”的不同阈值形成迟滞区间,也可以按卡片最小可用宽度计算列数。不过这种策略需要设计师给出可读性要求,不能仅凭屏幕物理像素套一个常量。示意工程为了保持规则可审计,先使用单个 600 vp 阈值,并如实说明快速拖拽边界尚需验证。

将宽度按 Math.round 保存到展示状态,只是为了让诊断文案更容易核对;真正做列数判断仍使用未经四舍五入的值。否则 599.6 vp 在画面里显示成 600 vp,却仍然两列,会让调试时看起来像规则失效。若产品希望日志和显示完全一致,可以另外保存原始宽度数值与格式化宽度,不要拿格式化结果参与判定。

三、把选中 ID 放在布局之外

1. 变列时只改变列模板

页面关注的是 onAreaChange 回报的容器宽度,而不是“是否折叠”的主观猜测。函数中先转成数字,再比较前后列数;如果仍然是三列,就不重复写状态,避免不必要的重新布局。下面展示 entry/src/main/ets/pages/ReviewGridPage.ets 的核心逻辑,静态图片块只是示意。

import { columnsForWidth, buildReviewPhotos, ReviewPhoto } from '../model/ReviewPhoto';

@Entry
@Component
struct ReviewGridPage {
  @State cols: number = 2;
  @State widthVp: number = 0;
  @State selectedId: string = 'IMG-026';
  private photos: ReviewPhoto[] = buildReviewPhotos();
  private readonly taskId: string = 'FD-207';

  private updateColumns(width: number): void {
    if (!Number.isFinite(width) || width <= 0) { return; }
    const next: number = columnsForWidth(width);
    this.widthVp = Math.round(width);
    if (next === this.cols) { return; }
    this.cols = next;
    console.info(`FoldProof ${this.taskId} widthVp=${this.widthVp}` +
      ` cols=${this.cols} selected=${this.selectedId}`);
  }

  build() {
    Column({ space: 12 }) {
      Text('折叠屏图片审阅').fontSize(26).fontWeight(FontWeight.Bold)
      Text(`任务 ${this.taskId} · 24 张待审`)
      Text(`已选 ${this.selectedId} · ${this.cols} 列`)
      Grid() {
        ForEach(this.photos, (photo: ReviewPhoto) => {
          GridItem() {
            Column({ space: 6 }) {
              Text('▧').fontSize(42)
              Text(photo.id).fontSize(12)
            }.width('100%').padding(12)
             .backgroundColor(photo.id === this.selectedId ? '#D9F3E9' : '#F1F3F5')
             .onClick(() => { this.selectedId = photo.id; })
          }
        }, (photo: ReviewPhoto) => photo.id)
      }
      .columnsTemplate(this.cols === 3 ? '1fr 1fr 1fr' : '1fr 1fr')
      .columnsGap(8).rowsGap(8).layoutWeight(1)
    }
    .width('100%').padding(16)
    .onAreaChange((_oldArea: Area, newArea: Area) => {
      this.updateColumns(Number(newArea.width));
    })
  }
}

ForEach 的 key 使用业务 ID,不使用元素位置,列数变化只触发排版变化。selectedId 独立于列数与索引,卡片颜色由 ID 比较产生。注意 onAreaChange 不是一个全局“折叠状态回调”;这里的宽度来自被监听组件自身,padding、父布局变化与多窗调整都会影响它。如果只关心网格宽度,应把监听放到实际网格所在的约束容器上,避免父容器和子网格的宽度口径不一致。

2. 删除数据时不要留下悬空选择

除了换列,审核清单还可能删除图片。若当前 IMG-026 被删除,页面还在显示“已选 IMG-026”,就会产生一个与实际列表不一致的业务状态。解决这类问题不能只靠 ForEach key,还得在数据变化入口明确判断选中项是否仍然存在。

import { ReviewPhoto } from '../model/ReviewPhoto';

export function keepValidSelection(items: ReviewPhoto[],
  selectedId: string): string {
  if (items.some((item: ReviewPhoto) => item.id === selectedId)) {
    return selectedId;
  }
  return items.length > 0 ? items[0].id : '';
}

// 下列属于纯函数规则演算,不是折叠屏设备实测。
const all: ReviewPhoto[] = [{ id: 'IMG-026', label: '样例' },
  { id: 'IMG-027', label: '样例' }];
const before: string = keepValidSelection(all, 'IMG-026'); // IMG-026
const after: string = keepValidSelection(all.slice(1), 'IMG-026'); // IMG-027

这段函数维护的是“选择必须指向当前可见数据”的一致性约束。实际项目还应决定删除当前项时选前一张、后一张还是清空选择;这里选第一张仅为了行为确定。数据异步刷新时,必须按请求代次决定是否接受新清单,否则旧请求回来以后,仍有可能把已删除的项目重新放进列表。

3. 选择态保留不等于滚动位置保留

在窗口变化后保住 IMG-026 的选中颜色,只证明数据身份没有丢失,并不能保证该图片还位于当前可视区域。网格从两列变成三列会改变每行所容纳的项目数,滚动偏移自然不再等价。如果业务要求“换列后仍看到刚才那一张”,需要额外保存可见图片的 ID,再结合滚动控制能力定位,而不能硬存旧的绝对偏移值。这是另一个独立工程任务,本篇不把它混进换列与选择态逻辑。

同样,焦点保持也不是 ID 稳定后自动获得的能力。键盘或读屏用户在换列后可能发现焦点顺序变化,实际验收应观察聚焦控件、朗读顺序和按钮可触达性。本文配图中的绿色选中块仅表达视觉选择,不声称已通过无障碍验收;正式产品还需要对选中状态设置合适的语义文本。

四、把检查点设计成能被复核的字段

1. 预设演示结果与日志口径

本轮配图选用 720 vp、3 列、24 张待审、IMG-026 选中 作为共同状态,任务号始终为 FD-207。在模拟的 HiLog 面板中给出 FoldProof FD-207 widthVp=720 cols=3 selected=IMG-026,并在选中态说明中显示 selection kept=IMG-026。这些日志是为了说明需要记录哪些字段而编排的示意行,不是运行后截取的日志。

把检查分为宽度变化前、变化后和删除数据后三个步骤会更可靠。宽度变化前先记当前 ID;变化后只检查列数与 ID 是否独立;删除数据后再检查选中 ID 是否有兜底。不要将“排版看起来没乱”直接替换为“选择态正确”,两件事必须分别验证。

2. 真机验收应该看什么

真实项目必须用目标手机折叠、展开、分屏、旋转和系统字体放大等场景检查 UI。建议把容器宽度、列模板、卡片实际尺寸、选中 ID、屏幕阅读焦点及滚动位置分别记录;若有快速拖拽,还要关注阈值附近来回切换产生的视觉抖动。本文没有提供 FPS、耗时、内存或支持机型结论,也没有声称运行过某款设备。

五、这套方案能解决和不能解决的边界

它解决的是容器宽度决定列数、业务 ID 决定选择态这两个职责的隔离。三列并不等于系统进入了平行视界,Grid 也不会替应用处理多实例草稿、外部显示器焦点或者后台恢复。复杂的折叠屏产品还可能需要针对窗口宽高比、输入方式与多任务栏设计更细的布局策略。若图片成本高,应再加入缩略图缓存与懒加载;不要因为列数变多就立即同步解码更多原图。

这次更重要的结论不是“720 vp 应该三列”,而是“列数属于可重算布局,选中 ID 属于必须保存的业务事实”。把二者分离,即使将来从两列扩展为四列,依然可以从一份稳定的数据契约演进,而不会每次调整外观都顺带重写业务状态。

资料核对: 华为官方 ArkUI 自定义键盘指南中的 Grid、onAreaChange 用例、ArkUI 组件性能优化实践。图像、宽度、日志为设计演示,未进行 DevEco 编译、折叠屏实测或实际审核。

Logo

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

更多推荐