小雨同学 2026-05-25 10:10:02 发布前言
我在调材料列表页的时候,最早关注的是展开态双栏。左边放列表,右边放详情,点一条记录以后右侧直接切换内容,这种结构在 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@Componentstruct 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; } }) }}暂无评论数据
发布
相关推荐
245
0
192
0
半夜还在打包
81
0
老刘学新框架
146
0
小雨同学
产品总监、独立开发者社群主理人、资深全栈工程师,HarmonyOS应用开发者高级认证,PMP认证,CSDN博客专家,鸿蒙极客,Trae Fellow,阿里云社区专家博主、51CTO 博客专家、OpenTiny 优秀布道师、科大讯飞荣誉讲师。
帖子
提问
粉丝
鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 10:横屏下页面从上下结构改为左右结构
2026-05-22 09:24:20 发布鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 09:展开态列表增加字段但不变复杂
2026-05-21 12:38:44 发布
京公网安备:11010502051901号