前言我在调材料列表页的时候最早关注的是展开态双栏。左边放列表右边放详情点一条记录以后右侧直接切换内容这种结构在 Pura X Max 展开态里确实能减少页面跳转。尤其是材料整理、会议纪要、客户记录这类页面用户经常需要在多条记录之间来回看列表和详情同时出现会省掉不少切换动作。但我把应用拖进分屏以后页面的状态就变了。原来能放下左侧列表和右侧详情的宽度被分屏切掉一部分以后左侧列表开始变窄右侧详情也跟着压缩。标题还能显示按钮也还能点击可每个区域都变得勉强左边不像列表右边也不像详情。Pura X Max 外屏是 5.4 英寸内屏是 7.7 英寸展开态确实适合承载更多信息同时 HarmonyOS 支持全屏、分屏、自由窗口这几种窗口形态同一个页面随时可能从完整宽窗口变成窄窗口。也就是说双栏不能当成页面的固定结构它更像是一种“宽窗口下才启用的能力”。窗口一旦缩窄页面要能退回单栏如果窗口再窄一点卡片内部还要继续收起辅助字段。这次我把页面分成三种状态来处理宽窗口左侧列表右侧详情窄窗口当前记录在上列表在下极窄窗口只保留标题、状态和主操作这三个状态让分屏以后页面还保留基本可用性宽的时候展示更多窄的时候先保证能读、能点、能继续处理当前记录。一、双栏在分屏里会先挤压内容1.1 展开态双栏没有问题我一开始做展开态页面时会很自然地想到列表详情结构。左侧列表负责切换右侧详情负责展示当前记录这个结构在宽窗口里很有价值。材料整理页就是典型场景。用户可能需要连续看几条材料点左侧记录右侧详情马上变化不用每次都进入新页面再返回列表。宽度足够时这种结构能减少页面层级也能让用户保留上下文。在代码上这种结构通常就是一个Row。Row({ space: 18 }) { this.ListPanel() this.DetailPanel(this.getSelectedItem()) }宽窗口里这样写没问题。左侧给一个相对固定的列表宽度右侧吃掉剩余空间页面能同时呈现两种信息。这个结构的问题出现在窗口缩窄以后。1.2 分屏以后两边都开始吃紧我把页面切到分屏宽度后第一眼看到的是左侧列表卡片变得很窄。标题本来可以显示两行缩窄以后只剩一小段摘要也被截断。右侧详情区域虽然还在但正文换行变多按钮和状态信息挤在一起整个页面读起来很费劲。这个时候页面并不是完全不可用真正麻烦的是两个区域都只能勉强工作。左侧列表失去了快速扫读的能力右侧详情也没有足够空间承载完整内容。继续保留双栏反而让列表和详情互相抢宽度。我在分屏里会先看这几个点左侧列表的标题还能不能读完整右侧详情是否还能承载正文主按钮有没有被挤到难点的位置用户是否还需要同时看到列表和详情当这些条件都开始变差时我不会继续保留双栏。这个页面更适合退回单栏让当前记录先占据上方区域再把列表放到下方。这样做虽然少了一点“大屏感”但用户至少还能正常处理当前材料。二、我把页面分成三档2.1 宽窗口保留双栏宽窗口下我仍然保留双栏结构。Pura X Max 展开态有足够横向空间时左侧列表和右侧详情并排出现用户能一边切换记录一边查看详情内容。这时列表宽度可以固定在一个相对稳定的范围比如 330vp。右侧详情区域使用剩余空间负责展示标题、摘要、来源、负责人、正文和主操作。private readonly wideWidth: number 820;我把820vp作为宽窗口门槛。这个值不是固定标准真实项目里可以根据列表宽度、详情内容和页面边距调整。材料列表标题比较长就要多留一些空间右侧详情字段比较少门槛可以稍微降低。2.2 窄窗口退回单栏窗口缩窄到中间状态时我不会继续保留双栏。页面会退回单栏上方先显示当前处理记录下方再显示列表。这种结构牺牲了列表详情同时展示但换来的是当前内容区域不会被左右挤压。用户在分屏里更可能是在处理一条具体记录而不是反复浏览大量详情。把当前记录放在上方可以让用户先完成眼前这件事。中间状态用narrowWidth控制private readonly narrowWidth: number 520;当窗口宽度达到 520vp但还没有达到 820vp 时页面进入 narrow。这个状态下当前处理卡片会显示标题、状态、摘要和主按钮列表继续放在下方。2.3 极窄窗口只留核心内容再往下缩窗口进入极窄状态。这个时候继续展示摘要、来源、时间、标签页面会显得很挤。极窄窗口更像一个临时处理入口不适合承担完整信息展示。我会把卡片内部再收一层只保留标题、状态和主按钮。摘要、来源、时间这些信息暂时隐藏等窗口恢复到更宽状态时再显示。private getLayoutMode(): string { const width this.getEffectiveWidth(); if (width this.wideWidth) { return wide; } if (width this.narrowWidth) { return narrow; } return tiny; }这里把页面分成 wide、narrow、tiny 三种状态。真实项目里可以把这些字符串换成枚举或者抽到统一的布局工具里。这个示例先保持简单重点是把降级规则集中到一个函数里避免 UI 分支散落在各个组件里。三、状态要留在页面层3.1 选中记录不能跟丢布局分屏降级不只是布局变化。用户正在处理哪条记录也要保留下来。我在这个示例里保留了selectedId。无论页面处于 wide、narrow 还是 tiny当前选中的记录都从同一个状态里读取。State private selectedId: number 1; private getSelectedItem(): MaterialItem { const found this.materials.find((item: MaterialItem) item.id this.selectedId); return found ? found : this.materials[0]; }这样做的好处是用户在宽窗口里选中某条记录后切到窄窗口或者极窄窗口当前处理内容仍然是同一条。布局变了正在处理的上下文没有丢。真实项目里这个点很容易被忽略。如果选中态放在某个具体布局组件里比如只放在右侧详情面板里那么面板一旦消失状态可能也跟着重置。把状态放到页面层会省掉很多切换时的补丁逻辑。3.2 操作次数也不能重置还有一个actionCount用来模拟用户点击主按钮后的操作次数。State private actionCount: number 0; private handleAction() { this.actionCount 1; }这个状态看起来很简单但它可以用来验证布局切换时状态是否保留。宽窗口里点击操作按钮后再切到窄窗口或极窄窗口操作次数仍然存在。真实项目里这类状态可能是保存进度、已读状态、待提交标记、正在处理的任务 ID。分屏适配如果只处理布局很容易遗漏这些上下文状态。我的习惯是先把页面状态放到外层再让不同布局去消费这些状态。这样 wide、narrow、tiny 三种展示方式可以变化业务状态仍然是一份。四、实际运行效果这里我提供了“宽屏”“窄屏”“极窄”三个演示按钮主要是为了在同一个模拟器里快速观察布局降级。真实项目里不需要这些按钮页面会直接跟随真实窗口宽度变化。宽屏状态下页面显示左列表、右详情。这个状态适合 Pura X Max 展开态或者任何足够宽的窗口。左侧记录负责切换右侧详情展示当前内容。窄屏状态下页面退回单栏。上方是当前处理记录下方是材料列表。这个状态更接近分屏后的中等窗口详情不再和列表并排当前记录先被放到页面顶部。极窄状态下卡片内部继续收起辅助信息。标题、状态和主按钮保留摘要、来源、时间等信息暂时隐藏。这个状态适合更窄的分屏比例、小尺寸自由窗口或者只需要快速处理当前记录的场景。五、实际放在项目中5.1 演示宽度要删掉这里我用了previewWidth和几个演示按钮方便在一台模拟器里观察三种状态。真实项目里不需要这些东西。private getEffectiveWidth(): number { if (this.previewWidth 0) { return this.previewWidth; } return this.pageWidth; }迁回真实项目时可以直接返回pageWidth。private getEffectiveWidth(): number { return this.pageWidth; }宽度仍然可以通过onAreaChange获取。这个事件会在组件区域变化时触发适合记录页面根容器当前宽度。注意这里记录的是组件区域变化不是设备型号变化。5.2 降级规则可以抽出去如果项目里有多个页面都要处理分屏降级我不建议每个页面都复制getLayoutMode()。可以把 wide、narrow、tiny 这类断点规则抽到一个工具里。比如private getLayoutMode(): string { const width this.getEffectiveWidth(); if (width this.wideWidth) { return wide; } if (width this.narrowWidth) { return narrow; } return tiny; }真实项目里可以把它改成枚举或者统一放到布局配置中。列表页、详情页、表单页可以共享同一套基础断点但每个页面内部显示什么字段仍然要根据业务决定。5.3 tiny 状态不要承载太多内容极窄窗口里我只保留标题、状态和主操作。这个取舍很直接因为窗口已经没有足够空间承载摘要、来源、负责人这些辅助字段。如果 tiny 状态还要继续放完整摘要、多个标签、来源时间和次要操作页面会变成一堆挤在一起的内容块。这里我宁愿让用户先完成主操作更多信息等窗口变宽后再显示或者进入详情页处理。这条规则可以迁移到很多页面里。悬浮窗、极窄分屏、临时窗口都不适合展示完整内容。它们更适合保留一件事当前处理对象是什么以及用户下一步能做什么。总结Pura X Max 展开态适合双栏但分屏以后双栏不一定还能成立。宽窗口下左列表右详情可以减少跳转窗口缩窄后单栏会给当前记录留下更多空间再窄一些时卡片内部也要继续收起辅助字段只保留标题、状态和主操作。我处理这类页面时会把双栏当成宽窗口能力而不是默认结构。页面宽的时候展示更多窄的时候先保证当前记录能读、能点、能继续处理。布局在变选中记录和操作状态要保留下来这一点比单纯切换Row和Column更容易被忽略。附完整代码interface MaterialItem { id: number; title: string; status: string; source: string; time: string; tag: string; owner: string; summary: string; detail: string; action: string; } Entry Component struct Index { // 页面真实宽度由 onAreaChange 写入 State private pageWidth: number 0; // 演示宽度只用于在同一个模拟器里切换 wide / narrow / tiny 三种状态 State private previewWidth: number 0; // 当前选中记录放在页面层避免布局切换后丢失上下文 State private selectedId: number 1; // 模拟操作次数用来观察布局降级后操作状态是否保留 State private actionCount: number 0; // narrow 以下进入极窄状态wide 以上才显示列表详情双栏 private readonly narrowWidth: number 520; private readonly wideWidth: number 820; private readonly materials: MaterialItem[] [ { id: 1, title: 社区物业缴费提醒, status: 待处理, source: 拍照整理, time: 09:20, tag: 通知, owner: 物业服务中心, summary: 识别到缴费截止日期、金额明细和办理地点。, detail: 这条记录来自一张社区物业缴费通知。全屏展开态下适合左侧列表、右侧详情分屏变窄后页面退回单栏会给当前记录留下更多空间。, action: 添加提醒 }, { id: 2, title: Pura X Max 适配会议纪要, status: 待确认, source: 语音转写, time: 10:45, tag: 会议, owner: 产品研发组, summary: 整理出分屏、横屏、悬停态和详情页适配任务。, detail: 会议记录类页面经常需要在列表中连续切换。宽窗口下可以使用左右双栏窗口缩窄后回到单栏当前记录会更容易阅读。, action: 确认任务 }, { id: 3, title: 客户需求变更记录, status: 待处理, source: 文本整理, time: 13:10, tag: 项目, owner: 客户成功组, summary: 本次变更涉及首页布局、权限配置和消息提醒。, detail: 需求变更记录通常需要查看完整说明和处理动作。分屏后继续保留双栏左侧列表和右侧详情都会被压缩。, action: 同步排期 }, { id: 4, title: 活动报名确认单, status: 已保存, source: 相册导入, time: 15:25, tag: 表单, owner: 活动运营, summary: 提取到报名人、联系方式、活动时间和签到地址。, detail: 报名确认类材料字段较多极窄窗口下只保留标题、状态和主操作其他信息放到更宽窗口或详情中展示。, action: 加入日程 }, { id: 5, title: 门诊复查预约提示, status: 已整理, source: 拍照整理, time: 16:40, tag: 提醒, owner: 个人记录, summary: 提取到复查时间、科室、楼层和注意事项。, detail: 提醒类记录在窄窗口中更适合快速处理不需要把所有辅助字段都展示出来。, action: 保存提醒 } ]; // Demo 中优先使用演示宽度真实项目里可以直接返回 pageWidth private getEffectiveWidth(): number { if (this.previewWidth 0) { return this.previewWidth; } return this.pageWidth; } // 把布局状态集中在一个函数里避免 UI 分支散落到每个组件内部 private getLayoutMode(): string { const width this.getEffectiveWidth(); if (width this.wideWidth) { return wide; } if (width this.narrowWidth) { return narrow; } return tiny; } private isWide(): boolean { return this.getLayoutMode() wide; } private isNarrow(): boolean { return this.getLayoutMode() narrow; } private isTiny(): boolean { return this.getLayoutMode() tiny; } private getContentWidth(): Length { if (this.previewWidth 0) { return this.previewWidth; } return 100%; } private getPagePadding(): number { if (this.isWide()) { return 24; } if (this.isNarrow()) { return 16; } return 12; } private getTitleSize(): number { if (this.isWide()) { return 28; } if (this.isNarrow()) { return 23; } return 20; } private getModeText(): string { if (this.isWide()) { return wide · 双栏布局; } if (this.isNarrow()) { return narrow · 单栏布局; } return tiny · 核心内容; } private getModeDesc(): string { if (this.isWide()) { return 宽窗口下显示左侧列表和右侧详情。; } if (this.isNarrow()) { return 窄窗口下当前记录在上列表在下。; } return 极窄窗口下只保留标题、状态和主操作。; } private getSelectedItem(): MaterialItem { const found this.materials.find((item: MaterialItem) item.id this.selectedId); return found ? found : this.materials[0]; } private setPreview(width: number) { this.previewWidth width; } private handleAction() { this.actionCount 1; } 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(宽屏, 960) this.PreviewButton(窄屏, 640) this.PreviewButton(极窄, 420) } .width(100%) } .width(100%) } Builder private MaterialCard(item: MaterialItem) { Column({ space: this.isTiny() ? 10 : 12 }) { Row({ space: 8 }) { this.StatusPill(item.status) if (!this.isTiny()) { this.MetaPill(item.tag) } Blank() if (this.selectedId item.id) { Text(当前) .fontSize(12) .fontColor(#2F8F83) } } .width(100%) Text(item.title) .fontSize(this.isTiny() ? 16 : 17) .fontWeight(FontWeight.Medium) .fontColor(#111827) .maxLines(this.isTiny() ? 1 : 2) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (!this.isTiny()) { Text(item.summary) .fontSize(13) .fontColor(#6B7280) .lineHeight(19) .maxLines(this.isWide() ? 2 : 1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row({ space: 8 }) { Text(item.source) .fontSize(12) .fontColor(#6B7280) Text(·) .fontSize(12) .fontColor(#9CA3AF) Text(item.time) .fontSize(12) .fontColor(#6B7280) } .width(100%) } if (this.isTiny()) { Button(item.action) .fontSize(13) .fontColor(#FFFFFF) .height(34) .width(100%) .backgroundColor(#2F8F83) .borderRadius(17) .onClick(() { this.selectedId item.id; this.handleAction(); }) } } .width(100%) .padding(this.isTiny() ? 12 : 15) .backgroundColor(this.selectedId item.id ? #EEF7F5 : #FFFFFF) .borderRadius(this.isTiny() ? 16 : 18) .border({ width: this.selectedId item.id ? 1.5 : 1, color: this.selectedId item.id ? #2F8F83 : #E5E7EB }) .shadow({ radius: this.selectedId item.id ? 10 : 7, color: #10000000, offsetX: 0, offsetY: 4 }) .onClick(() { this.selectedId item.id; }) } Builder private ListPanel() { Scroll() { Column({ space: 12 }) { ForEach(this.materials, (item: MaterialItem) { this.MaterialCard(item) }, (item: MaterialItem) item.id.toString()) } .width(100%) .padding({ bottom: 20 }) } .layoutWeight(1) .width(100%) .edgeEffect(EdgeEffect.Spring) } Builder private DetailPanel(item: MaterialItem) { Column({ space: 16 }) { Row() { this.StatusPill(item.status) Blank() this.MetaPill(item.tag) } .width(100%) Text(item.title) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(#111827) .lineHeight(31) Text(item.summary) .fontSize(15) .fontColor(#4B5563) .lineHeight(23) Row({ space: 10 }) { this.MetaBlock(来源, item.source) this.MetaBlock(时间, item.time) this.MetaBlock(负责人, item.owner) } .width(100%) Column({ space: 8 }) { Text(内容详情) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(#111827) Text(item.detail) .fontSize(15) .fontColor(#4B5563) .lineHeight(24) } .width(100%) .padding(16) .backgroundColor(#F9FAFB) .borderRadius(18) Column({ space: 8 }) { Text(操作状态) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(#111827) Text(当前操作已触发 this.actionCount.toString() 次。切换窗口宽度后选中项和操作状态仍然保留。) .fontSize(14) .fontColor(#6B7280) .lineHeight(22) } .width(100%) .padding(16) .backgroundColor(#F3F8F7) .borderRadius(18) Blank() Button(item.action) .fontSize(15) .fontColor(#FFFFFF) .height(44) .width(100%) .backgroundColor(#2F8F83) .borderRadius(22) .onClick(() { this.handleAction(); }) } .width(100%) .height(100%) .padding(20) .backgroundColor(#FFFFFF) .borderRadius(24) .shadow({ radius: 12, color: #12000000, offsetX: 0, offsetY: 4 }) } Builder private MetaBlock(label: string, value: string) { Column({ space: 4 }) { Text(label) .fontSize(12) .fontColor(#9CA3AF) Text(value) .fontSize(14) .fontColor(#374151) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) .padding(12) .backgroundColor(#F9FAFB) .borderRadius(14) } Builder private CompactSelectedPanel(item: MaterialItem) { Column({ space: 10 }) { Row() { Text(当前处理) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(#111827) Blank() this.StatusPill(item.status) } .width(100%) Text(item.title) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(#111827) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (this.isNarrow()) { Text(item.summary) .fontSize(14) .fontColor(#4B5563) .lineHeight(21) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } Button(item.action) .fontSize(14) .fontColor(#FFFFFF) .height(40) .width(100%) .backgroundColor(#2F8F83) .borderRadius(20) .onClick(() { this.handleAction(); }) } .width(100%) .padding(this.isTiny() ? 12 : 16) .backgroundColor(#FFFFFF) .borderRadius(20) .shadow({ radius: 10, color: #10000000, offsetX: 0, offsetY: 4 }) } Builder private MainContent() { if (this.isWide()) { Row({ space: 18 }) { Column() { this.ListPanel() } .width(330) .height(100%) Column() { this.DetailPanel(this.getSelectedItem()) } .layoutWeight(1) .height(100%) } .width(100%) .height(100%) } else { Column({ space: 14 }) { this.CompactSelectedPanel(this.getSelectedItem()) Column() { this.ListPanel() } .layoutWeight(1) .width(100%) } .width(100%) .height(100%) } } build() { Column() { Column({ space: 16 }) { this.HeaderPanel() Column() { this.MainContent() } .layoutWeight(1) .width(100%) } .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; } }) } }