鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 09:展开态列表增加字段但不变复杂

鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 09:展开态列表增加字段但不变复杂 前言列表页是折叠屏适配里最容易被低估的页面。因为它看起来太普通了。很多应用里都有列表材料列表、通知列表、任务列表、会议列表、整理结果列表。手机上能用放到 Pura X Max 外屏上也能用到了展开态里好像也没有明显报错。真正跑起来看一眼问题才会慢慢浮出来页面变宽了但信息并没有变得更好读。我一开始也容易把这个问题想简单。展开态空间更大那就多放几个字段。标题旁边放状态下面放摘要再加来源、时间、标签、负责人、优先级右边再放按钮。这样一看页面确实不空了但列表开始变得像一张压缩过的小表格。字段越多用户扫列表越慢。Pura X Max 的外屏是 5.4 英寸内屏是 7.7 英寸外屏分辨率为 1848 × 1264内屏分辨率为 2584 × 1828系统版本为 HarmonyOS 6.1。这个尺寸变化已经足够让列表页改变信息密度但改变信息密度不等于把字段全部堆上去。展开态应该让用户更快判断信息而不是让用户在更多字段里重新找重点。所以这次我处理的不是列表怎么变复杂而是列表怎么在展开态增加字段以后仍然保持清楚。小屏只保留主字段大屏补充辅助字段完整详情继续交给详情页或右侧面板。一、列表先别急着变多1.1 大屏空着也不对Pura X Max 展开态里如果列表卡片还和外屏一样只显示标题和状态问题会很明显。卡片被横向拉宽页面左右空间变大但一屏看到的有效信息没有增加。用户还是要点进详情才能知道这条记录到底讲了什么、来自哪里、是不是需要马上处理。这样一来展开态的优势基本没有发挥出来。这类页面在真实项目里很常见。比如一个材料整理应用用户打开列表时通常不是只想看标题。他还想快速判断这条材料是不是待处理是拍照来的还是语音转写来的摘要里有没有关键信息时间是不是最近生成的。如果展开态还只展示标题和状态页面看起来干净但信息判断效率不高。用户拥有更大的屏幕却仍然要靠频繁点击详情来完成判断。1.2 字段太多也不行另一个方向也不稳就是把字段全部放出来。我之前看过一些大屏列表改造做着做着就变成了表格。标题、摘要、来源、时间、标签、负责人、优先级、操作按钮都显示出来每条记录看起来信息很多但用户的第一眼反而不知道该看哪里。这类问题的本质是阅读顺序被打乱了。列表页的核心任务是让用户快速扫。用户扫列表时通常不是逐字阅读而是先抓几个判断点这条记录是什么当前状态是什么大概内容是什么它从哪里来我现在要不要处理这些判断点有先后顺序。标题和状态是第一层摘要和来源是第二层负责人和优先级是第三层。完整原文、历史记录、附件、识别日志这些内容已经不是列表页应该承载的东西。所以展开态列表增加字段时我会先克制一点。多放字段没问题但字段要有层级。卡片仍然要像卡片不能变成一行一行字段拼出来的表格。二、先给字段分层2.1 主字段永远显示列表里有些字段是不能藏的。标题要显示因为用户需要知道这条记录是什么。状态要显示因为用户要判断是不是待处理。主操作也要保留因为用户可能要快速处理当前记录。这三个字段属于主字段。无论外屏还是展开态它们都应该存在。在小屏里我会把卡片压得比较短只展示标题、状态和主操作。这样做有一个好处外屏一屏能看到更多记录用户扫列表时不会被摘要、标签、来源打断。2.2 辅助字段跟着宽度出现摘要、来源、时间、标签、负责人、优先级这些字段我会放到辅助层。它们不是不重要而是要看窗口宽度够不够。外屏里全放出来会挤展开态里适当放出来很有价值。比如摘要可以让用户不用进详情就知道内容大概来源可以说明这条记录来自拍照、语音还是文本整理时间可以帮助判断是否是最近内容标签和优先级可以辅助筛选负责人适合放在右侧小区域告诉用户这条记录归谁处理。这也是我写响应式列表时常用的思路数据是一套展示是多层。不要为了小屏和大屏拆两套数据结构真正变化的是字段显示层级。2.3 详情字段不要塞进列表有些字段就不应该放在列表里。完整原文、识别日志、附件、历史操作记录这些信息看起来能增加内容厚度但放到列表里会严重影响浏览效率。列表页只适合承担判断任务真正的深度信息应该进入详情页、右侧详情面板或者弹层。这个判断很重要。很多展开态页面出问题不是因为字段太少而是因为没有分清字段属于列表还是详情。我这次把字段拆成三层主字段标题、状态、主操作。辅助字段摘要、来源、时间、标签、负责人、优先级。详情字段完整原文、附件、日志、历史处理记录。这样拆完以后代码里的判断会简单很多。小屏只渲染主字段展开态补充辅助字段详情字段暂时不进入列表卡片。三、断点只管展示层3.1 不按机型写死Pura X Max 适配里我不太愿意直接写某个机型判断。原因很简单折叠屏还有分屏、悬浮窗、横屏这些状态。即使是同一台设备应用窗口也不一定永远是外屏或完整展开态。更稳的方式是看当前窗口宽度。宽度达到一定值就展示辅助字段宽度不够就保留主字段。示例里我用了一个简单阈值private readonly expandedWidth: number 780; private isExpanded(): boolean { return this.getEffectiveWidth() this.expandedWidth; }这里的 780vp 不是标准答案。不同项目要根据卡片内容调整。卡片内容短可以提前进入展开态摘要比较长或者右侧还有操作区就要适当调高阈值。3.2 演示宽度只服务 Demo代码里还有一个previewWidth。它的作用只是方便在同一台模拟器里切换外屏和展开态。真实项目里不需要这个字段。真实项目只需要通过页面区域变化拿到当前宽度然后计算布局状态。这个区别我会在代码里写注释因为它很容易被误用。Demo 里加按钮是为了让读者不用反复折叠设备也能看到效果迁移回项目时演示逻辑应该删掉。3.3 卡片分开写更清楚这次我没有把所有字段都写在一个卡片里再用很多if控制显示。那样虽然代码量少一点但读起来不直观。我把卡片拆成两个CompactCard处理小屏卡片。ExpandedCard处理展开态卡片。最后通过RecordCard()统一选择。Builder private RecordCard(item: MaterialRecord) { if (this.isExpanded()) { this.ExpandedCard(item) } else { this.CompactCard(item) } }这样写的好处是结构清楚。小屏卡片怎么组织展开态卡片怎么组织各自独立。后续要调整某个状态也不容易互相影响。四、跑一遍材料列表这个示例模拟的是材料整理场景。窄窗口下卡片只显示状态、标题和主操作展开态下卡片增加摘要、来源、时间、标签、负责人和优先级但仍然保持卡片结构。代码可以放到entry/src/main/ets/pages/Index.ets中运行。里面没有依赖项目主题、图片资源或接口数据主要用来观察不同窗口宽度下的字段显示策略。interface MaterialRecord { id: number; title: string; status: string; actionText: string; summary: string; source: string; time: string; tag: string; priority: string; owner: string; } Entry Component struct Index { // 当前页面真实宽度由 onAreaChange 写入 State private pageWidth: number 0; // 演示宽度只用于在同一个模拟器里切换外屏和展开态效果 State private previewWidth: number 0; // 当前选中项用来观察不同布局下选中态是否一致 State private selectedId: number 1; // 展开态阈值。真实项目里可以抽成统一断点配置 private readonly expandedWidth: number 780; // 模拟材料数据。字段故意多一些方便展示主字段和辅助字段的分层 private readonly records: MaterialRecord[] [ { id: 1, title: 社区物业缴费提醒, status: 待处理, actionText: 处理, summary: 识别到物业费缴纳截止日期、金额明细和办理地点建议保存为待办提醒。, source: 拍照整理, time: 09:20, tag: 通知, priority: 高优先级, owner: 物业服务中心 }, { id: 2, title: Pura X Max 适配会议纪要, status: 待确认, actionText: 确认, summary: 整理出外屏、展开态、横屏和悬停态几类页面问题适合进入开发清单。, source: 语音转写, time: 10:45, tag: 会议, priority: 中优先级, owner: 产品研发组 }, { id: 3, title: 客户需求变更记录, status: 待处理, actionText: 处理, summary: 本次变更涉及首页布局、权限配置、消息提醒和后台字段展示。, source: 文本整理, time: 13:10, tag: 项目, priority: 高优先级, owner: 客户成功组 }, { id: 4, title: 活动报名确认单, status: 已保存, actionText: 查看, summary: 提取到报名人、联系方式、活动时间和签到地址可加入行程提醒。, source: 相册导入, time: 15:25, tag: 表单, priority: 普通, owner: 活动运营 }, { id: 5, title: 门诊复查预约提示, status: 已整理, actionText: 查看, summary: 提取到复查时间、科室、楼层和注意事项后续可保存为健康提醒。, source: 拍照整理, time: 16:40, tag: 提醒, priority: 中优先级, owner: 个人记录 }, { id: 6, title: 课程作业提交说明, status: 已整理, actionText: 查看, summary: 识别到提交时间、文件格式、命名规范和邮箱地址适合保存为待办。, source: 图片识别, time: 18:05, tag: 学习, priority: 普通, owner: 课程助教 } ]; // Demo 中优先使用演示宽度真实项目里可以直接返回 pageWidth private getEffectiveWidth(): number { if (this.previewWidth 0) { return this.previewWidth; } return this.pageWidth; } // 只用窗口宽度判断字段层级不按具体机型写死逻辑 private isExpanded(): boolean { return this.getEffectiveWidth() this.expandedWidth; } private getContentWidth(): Length { if (this.previewWidth 0) { return this.previewWidth; } return 100%; } private getPagePadding(): number { return this.isExpanded() ? 24 : 16; } private getTitleSize(): number { return this.isExpanded() ? 28 : 23; } private getModeText(): string { return this.isExpanded() ? expanded · 辅助字段展开 : compact · 保留主字段; } private getModeDesc(): string { if (this.isExpanded()) { return 当前列表显示摘要、来源、时间、标签和负责人但仍然保持卡片阅读。; } return 当前列表只显示标题、状态和主操作适合外屏快速浏览。; } private getSelectedRecord(): MaterialRecord { const found this.records.find((item: MaterialRecord) item.id this.selectedId); return found ? found : this.records[0]; } private setPreview(width: number) { this.previewWidth width; } private getStatusColor(status: string): string { if (status 待处理) { return #B25E00; } if (status 待确认) { return #7C3AED; } return #276749; } private getStatusBgColor(status: string): string { if (status 待处理) { return #FFF4E5; } if (status 待确认) { return #F1EAFE; } return #E7F5EE; } Builder private PreviewButton(text: string, width: number) { Text(text) .fontSize(12) .fontColor(this.previewWidth width ? #FFFFFF : #2F8F83) .textAlign(TextAlign.Center) .padding({ left: 10, right: 10, top: 7, bottom: 7 }) .backgroundColor(this.previewWidth width ? #2F8F83 : #E6F4F1) .borderRadius(999) .onClick(() { this.setPreview(width); }) } Builder private StatusPill(status: string) { Text(status) .fontSize(12) .fontColor(this.getStatusColor(status)) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(this.getStatusBgColor(status)) .borderRadius(999) } Builder private MetaPill(text: string) { Text(text) .fontSize(12) .fontColor(#4B5563) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#F3F4F6) .borderRadius(999) } Builder private HeaderPanel() { Column({ space: 10 }) { Row({ space: 10 }) { Column({ space: 4 }) { Text(展开态列表增加字段) .fontSize(this.getTitleSize()) .fontWeight(FontWeight.Bold) .fontColor(#111827) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.getModeText()) .fontSize(14) .fontColor(#2F8F83) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) Text(窗口 Math.round(this.pageWidth).toString() vp) .fontSize(12) .fontColor(#374151) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .backgroundColor(#FFFFFF) .borderRadius(999) } .width(100%) Text(演示宽度 Math.round(this.getEffectiveWidth()).toString() vp。 this.getModeDesc()) .fontSize(14) .fontColor(#6B7280) .lineHeight(21) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row({ space: 8 }) { this.PreviewButton(自动, 0) this.PreviewButton(外屏, 430) this.PreviewButton(展开态, 960) } .width(100%) } .width(100%) } Builder private CompactCard(item: MaterialRecord) { Column({ space: 12 }) { Row({ space: 8 }) { this.StatusPill(item.status) if (this.selectedId item.id) { Text(当前) .fontSize(12) .fontColor(#2F8F83) } Blank() Button(item.actionText) .fontSize(13) .fontColor(#FFFFFF) .height(32) .padding({ left: 12, right: 12 }) .backgroundColor(#2F8F83) .borderRadius(16) .onClick(() { this.selectedId item.id; }) } .width(100%) Text(item.title) .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor(#111827) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width(100%) .padding(16) .backgroundColor(this.selectedId item.id ? #EEF7F5 : #FFFFFF) .borderRadius(20) .border({ width: this.selectedId item.id ? 1.5 : 1, color: this.selectedId item.id ? #2F8F83 : #E5E7EB }) .shadow({ radius: this.selectedId item.id ? 12 : 8, color: #12000000, offsetX: 0, offsetY: 4 }) .onClick(() { this.selectedId item.id; }) } Builder private ExpandedCard(item: MaterialRecord) { Column({ space: 14 }) { Row({ space: 16 }) { Column({ space: 10 }) { Row({ space: 8 }) { this.StatusPill(item.status) this.MetaPill(item.tag) if (this.selectedId item.id) { Text(当前) .fontSize(12) .fontColor(#2F8F83) } } .width(100%) Text(item.title) .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor(#111827) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 摘要只在展开态出现避免外屏卡片被撑高 Text(item.summary) .fontSize(14) .fontColor(#4B5563) .lineHeight(21) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row({ space: 8 }) { this.MetaPill(item.source) this.MetaPill(item.time) this.MetaPill(item.priority) } .width(100%) } .layoutWeight(1) // 右侧只保留负责人和主操作不把更多详情继续塞进列表 Column({ space: 8 }) { Text(负责人) .fontSize(12) .fontColor(#9CA3AF) Text(item.owner) .fontSize(14) .fontColor(#374151) .fontWeight(FontWeight.Medium) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Button(item.actionText) .fontSize(13) .fontColor(#FFFFFF) .height(34) .width(100%) .backgroundColor(#2F8F83) .borderRadius(17) .onClick(() { this.selectedId item.id; }) } .width(132) .padding(12) .backgroundColor(#F9FAFB) .borderRadius(16) } .width(100%) } .width(100%) .padding(18) .backgroundColor(this.selectedId item.id ? #EEF7F5 : #FFFFFF) .borderRadius(22) .border({ width: this.selectedId item.id ? 1.5 : 1, color: this.selectedId item.id ? #2F8F83 : #E5E7EB }) .shadow({ radius: this.selectedId item.id ? 12 : 8, color: #12000000, offsetX: 0, offsetY: 4 }) .onClick(() { this.selectedId item.id; }) } Builder private RecordCard(item: MaterialRecord) { if (this.isExpanded()) { this.ExpandedCard(item) } else { this.CompactCard(item) } } Builder private SelectedPanel() { Column({ space: 10 }) { Row() { Text(当前选中) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(#111827) Blank() this.StatusPill(this.getSelectedRecord().status) } .width(100%) Text(this.getSelectedRecord().title) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(#111827) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (this.isExpanded()) { Text(this.getSelectedRecord().summary) .fontSize(14) .fontColor(#4B5563) .lineHeight(21) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } } .width(100%) .padding(16) .backgroundColor(#FFFFFF) .borderRadius(22) .shadow({ radius: 10, color: #10000000, offsetX: 0, offsetY: 4 }) } Builder private ListArea() { Scroll() { Column({ space: 12 }) { ForEach(this.records, (item: MaterialRecord) { this.RecordCard(item) }, (item: MaterialRecord) item.id.toString()) } .width(100%) .padding({ bottom: 24 }) } .layoutWeight(1) .width(100%) .edgeEffect(EdgeEffect.Spring) } build() { Column() { Column({ space: 16 }) { this.HeaderPanel() this.SelectedPanel() this.ListArea() } .width(this.getContentWidth()) .height(100%) .padding({ left: this.getPagePadding(), right: this.getPagePadding(), top: 18, bottom: 16 }) } .width(100%) .height(100%) .alignItems(HorizontalAlign.Center) .backgroundColor(#F6F7F9) .onAreaChange((_: Area, newValue: Area) { const width Number(newValue.width); if (!Number.isNaN(width) width 0) { this.pageWidth width; } }) } }五、跑出来后的差异外屏状态下列表卡片会明显短很多。每张卡片只有状态、标题和操作按钮用户可以快速扫过多条记录。这个状态适合手机外屏和窄窗口重点是减少打扰先让用户判断哪条记录需要处理。展开态下同样的数据会显示更多上下文。标题下面出现摘要卡片底部出现来源、时间和优先级右侧小区域显示负责人和操作按钮。信息密度确实提高了但卡片仍然保留主次关系没有被改造成表格。这段代码里真正需要迁回项目的是字段分层和RecordCard()的判断逻辑。previewWidth、PreviewButton()这些只是演示用的正式项目里应该删掉让页面直接跟随真实窗口宽度变化。还有一点需要注意展开态增加字段不是越多越好。我的经验是一张列表卡片里最多放一段摘要、三到四个元信息标签、一个主操作。如果再继续塞负责人电话、附件数量、完整原文、历史状态页面很快就会变成压缩表格。列表页只负责判断详情页才负责展开。聊天消息、时间线动态、审批流这类强顺序内容也不适合用这种字段扩展方式。它们更依赖连续阅读字段变多会打断节奏。材料列表、任务列表、通知列表、客户记录这类相对独立的卡片更适合采用这种处理。总结Pura X Max 展开态给了列表页更多空间但这些空间应该用来提高判断效率而不是把所有字段都塞进卡片。小屏下保留标题、状态和主操作展开态再补充摘要、来源、时间、标签和负责人列表会更有信息量也不会失去卡片阅读的节奏。我现在处理这类列表时会先给字段分层再决定哪些字段跟随断点展示。标题、状态、主操作属于核心字段摘要、来源、时间、标签属于辅助字段完整内容、历史记录和附件仍然留在详情层。这个顺序稳定以后外屏和展开态的列表体验都会清楚很多。