小雨同学 2026-05-14 11:38:08 发布前言
Pura X Max 的展开态适合做列表详情联动,但开合切换时很容易出现一个细节问题:用户刚刚选中的记录,在窗口从窄变宽、从宽变窄之后丢了。
这个问题表面上只是选中态消失,实际影响的是页面上下文。用户在外屏点了一条记录,展开内屏后希望继续看这条记录的详情;用户在展开态切换了右侧详情,折叠回外屏后也希望列表里仍然能看到刚才的选中项。如果每次布局变化都重新回到默认第一条,页面会给人一种“刚才的操作没被保留”的感觉。
Pura X Max 外屏为 5.4 英寸,内屏为 7.7 英寸,外屏分辨率为 1848 × 1264,内屏分辨率为 2584 × 1828,系统版本为 HarmonyOS 6.1。外屏和内屏之间的切换足够频繁,列表页只处理布局变化还不够,业务状态也要跟着稳住。
阔折叠设备会涉及折叠态、展开态和悬停态,开合连续性是这类设备适配中绕不开的体验要求。我在列表详情页面里会把布局状态和业务状态分开处理。布局可以随着窗口宽度变化,选中记录、操作状态、页面上下文要留在更稳定的位置。

问题出在状态跟着布局走
列表页从 compact 切到 expanded,常见写法是根据宽度渲染两套不同结构。
窄屏只渲染列表。
宽屏渲染左侧列表和右侧详情。
如果选中项只存在于某个子组件内部,布局一切换,这个子组件就可能被销毁并重新创建。结果就是选中态回到默认值,右侧详情也跟着回到第一条。
这种问题在普通手机上不明显,因为页面结构变化不频繁。Pura X Max 的开合、横竖屏、分屏窗口都会改变可用宽度,页面结构会更频繁地切换。
更稳的做法是把选中项提升到页面状态里。页面结构根据宽度变化,业务状态继续保留在当前页面组件中。
@State private selectedId: number = 2;selectedId 不属于左侧列表,也不属于右侧详情。它属于整个页面。列表负责修改它,详情负责读取它。这样一来,布局切换不会影响当前选中的业务数据。
把选中项留在页面状态里
页面里可以有两个维度的状态。
一个是布局状态,来自当前窗口宽度。
@State private pageWidth: number = 0;private isExpanded(): boolean { return this.pageWidth >= this.expandedWidth;}另一个是业务状态,来自用户操作。
@State private selectedId: number = 2;这两个状态不要混在一起。pageWidth 只决定页面是单栏还是双栏,selectedId 只决定当前选中哪条记录。
页面宽度可以通过 onAreaChange 更新。组件区域变化事件会在组件显示尺寸或位置变化时触发,适合处理窗口变化后的页面级响应。
.onAreaChange((_: Area, newValue: Area) => { const width = Number(newValue.width); if (!Number.isNaN(width) && width > 0) { this.pageWidth = width; }})选中记录通过点击列表项更新。
.onClick(() => { this.selectedId = item.id;})页面切到 expanded 后,右侧详情通过 selectedId 找到当前记录。
private getSelectedRecord(): RecordItem { const found = this.records.find((item: RecordItem) => item.id === this.selectedId); return found ? found : this.records[0];}这个结构的好处是清晰。窗口怎么变,当前记录都在。布局变化负责改变展示方式,业务状态负责维持上下文。
用一个页面还原开合切换
下面这个页面模拟了一组整理记录。窄窗口下是普通列表,点击任意记录后,卡片会出现选中态,顶部也会显示当前选中记录。窗口变宽后,页面切换成左侧列表、右侧详情,右侧会继续展示刚才选中的那条记录。再把窗口缩窄,列表里的选中态仍然保留。
页面可以放到 entry/src/main/ets/pages/Index.ets 运行。Pura X Max 适配调试可以使用 DevEco Studio 6.1.0,并安装对应模拟器检查外屏和展开态的表现。
interface RecordItem { id: number; title: string; status: string; source: string; time: string; tag: string; owner: string; summary: string; detail: string; action: string;}@Entry@Componentstruct Index { @State private pageWidth: number = 0; @State private selectedId: number = 2; @State private actionCount: number = 0; private readonly expandedWidth: number = 760; private readonly records: RecordItem[] = [ { 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: '这条会议纪要用于跟踪 Pura X Max 适配过程中的页面问题。当前重点是开合切换时保留选中记录,让用户在不同窗口状态下继续处理同一条内容。', 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: '保存提醒' } ]; private isExpanded(): boolean { return this.pageWidth >= this.expandedWidth; } private getSelectedRecord(): RecordItem { const found = this.records.find((item: RecordItem) => item.id === this.selectedId); return found ? found : this.records[0]; } private getModeText(): string { return this.isExpanded() ? 'expanded · 列表详情联动' : 'compact · 普通列表'; } 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'; } private increaseActionCount() { this.actionCount += 1; } @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 HeaderPanel() { Column({ space: 8 }) { Row() { Column({ space: 4 }) { Text('开合切换后的选中状态保持') .fontSize(this.isExpanded() ? 25 : 22) .fontWeight(FontWeight.Bold) .fontColor('#111827') Text(this.getModeText()) .fontSize(14) .fontColor('#2F8F83') } .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('当前选中:' + this.getSelectedRecord().title) .fontSize(14) .fontColor('#6B7280') .lineHeight(21) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') } @Builder private RecordCard(item: RecordItem) { Column({ space: 10 }) { Row({ space: 8 }) { this.StatusPill(item.status) if (this.selectedId === item.id) { Text('已选中') .fontSize(12) .fontColor('#2F8F83') .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor('#E6F4F1') .borderRadius(999) } Blank() Text(item.time) .fontSize(12) .fontColor('#6B7280') } .width('100%') Text(item.title) .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor('#111827') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(item.summary) .fontSize(13) .fontColor('#6B7280') .lineHeight(19) .maxLines(this.isExpanded() ? 2 : 1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row({ space: 8 }) { Text(item.source) .fontSize(12) .fontColor('#6B7280') Text('·') .fontSize(12) .fontColor('#9CA3AF') Text(item.tag) .fontSize(12) .fontColor('#6B7280') } .width('100%') } .width('100%') .padding(15) .backgroundColor(this.selectedId === item.id ? '#EEF7F5' : '#FFFFFF') .borderRadius(18) .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 ListPanel() { Column({ space: 12 }) { Scroll() { Column({ space: 12 }) { ForEach(this.records, (item: RecordItem) => { this.RecordCard(item) }, (item: RecordItem) => item.id.toString()) } .width('100%') .padding({ bottom: 20 }) } .layoutWeight(1) .width('100%') .edgeEffect(EdgeEffect.Spring) } .width('100%') .height('100%') } @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 DetailPanel(item: RecordItem) { Column({ space: 18 }) { Row() { this.StatusPill(item.status) Blank() Text(item.tag) .fontSize(13) .fontColor('#2F8F83') .padding({ left: 10, right: 10, top: 5, bottom: 5 }) .backgroundColor('#E6F4F1') .borderRadius(999) } .width('100%') Column({ space: 8 }) { Text(item.title) .fontSize(27) .fontWeight(FontWeight.Bold) .fontColor('#111827') .lineHeight(34) Text(item.summary) .fontSize(15) .fontColor('#4B5563') .lineHeight(22) } .width('100%') .alignItems(HorizontalAlign.Start) 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(15) .fontColor('#4B5563') .lineHeight(23) } .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.increaseActionCount(); }) } .width('100%') .height('100%') .padding(24) .backgroundColor('#FFFFFF') .borderRadius(24) .shadow({ radius: 12, color: '#10000000', offsetX: 0, offsetY: 4 }) } @Builder private CompactSelectedPanel(item: RecordItem) { Column({ space: 10 }) { Row() { Text('当前处理') .fontSize(15) .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 }) Text(item.summary) .fontSize(14) .fontColor('#4B5563') .lineHeight(20) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row({ space: 12 }) { Button(item.action) .fontSize(14) .fontColor('#FFFFFF') .height(38) .layoutWeight(1) .backgroundColor('#2F8F83') .borderRadius(19) .onClick(() => { this.increaseActionCount(); }) Text('已点击 ' + this.actionCount.toString() + ' 次') .fontSize(13) .fontColor('#6B7280') } .width('100%') .alignItems(VerticalAlign.Center) } .width('100%') .padding(16) .backgroundColor('#FFFFFF') .borderRadius(20) .border({ width: 1, color: '#D8EAE6' }) } build() { Column({ space: 16 }) { this.HeaderPanel() if (this.isExpanded()) { Row({ space: 18 }) { Column() { this.ListPanel() } .width(340) .height('100%') Column() { this.DetailPanel(this.getSelectedRecord()) } .layoutWeight(1) .height('100%') } .width('100%') .layoutWeight(1) } else { Column({ space: 14 }) { this.CompactSelectedPanel(this.getSelectedRecord()) Column() { this.ListPanel() } .layoutWeight(1) .width('100%') } .width('100%') .layoutWeight(1) } } .width('100%') .height('100%') .padding({ left: this.isExpanded() ? 24 : 16, right: this.isExpanded() ? 24 : 16, top: 18, bottom: 16 }) .backgroundColor('#F6F7F9') .onAreaChange((_: Area, newValue: Area) => { const width = Number(newValue.width); if (!Number.isNaN(width) && width > 0) { this.pageWidth = width; } }) }}关键实现点和运行结果
这个页面跑起来后,先在窄窗口点击任意一条记录,例如“客户需求变更记录”。卡片会变成浅绿色边框,顶部会显示当前选中的标题,上方的“当前处理”卡片也会同步更新。

把窗口切到展开态后,页面会变成左侧列表、右侧详情。左侧仍然保留刚才那条记录的选中态,右侧详情继续展示同一条记录的完整内容。这个状态可以直接截一张展开态图,重点看左侧高亮记录和右侧详情标题是否一致。

再把窗口缩回窄窗口,顶部的当前选中标题和列表里的选中态还在。
这里还额外加了 actionCount。
@State private actionCount: number = 0;它模拟用户在当前记录上做过的操作。点击右侧详情里的按钮,计数会增加;再切回外屏,计数仍然显示在“当前处理”卡片里。这个小状态能更直观地验证一点:保留的不只是选中项,也可以是当前页面里的临时操作状态。
真实项目里,状态层级可以按生命周期来分。
页面临时状态适合放在当前组件里,例如选中记录、筛选条件、展开折叠状态、当前 Tab。
跨页面状态可以放到父级页面、路由参数、AppStorage 或业务容器里,例如当前项目、登录用户、全局主题、权限信息。
需要持久化的状态应该写入数据库或设置仓库,例如用户偏好、草稿、上次打开的项目。
开合切换通常只需要保留页面上下文,不一定要把所有状态都写进数据库。把所有临时状态都持久化,会让简单交互变得过重。
总结
Pura X Max 的开合切换会频繁改变页面结构,列表页适配不能只看单列、双列或分栏。用户正在处理哪条记录、当前操作进行到哪里,也需要保留下来。
比较稳的处理方式是把布局状态和业务状态分开。窗口宽度决定页面怎么摆,选中记录决定当前显示什么。布局可以在 compact 和 expanded 之间切换,selectedId、操作计数、筛选条件这类业务状态继续留在页面状态里。
这个方法适合材料整理、会议记录、客户资料、任务管理、设置分类等页面。只要用户会在列表和详情之间反复切换,状态保持就会直接影响使用感。折叠态和展开态看起来是两种布局,用户感受到的应该是同一个任务上下文。
相关推荐
星晨未来
7
0
168
0
少女写代码
805
0
十三代宗师
206
0
小雨同学
产品总监、独立开发者社群主理人、资深全栈工程师,HarmonyOS应用开发者高级认证,PMP认证,CSDN博客专家,鸿蒙极客,Trae Fellow,阿里云社区专家博主、51CTO 博客专家、OpenTiny 优秀布道师、科大讯飞荣誉讲师。
帖子
提问
粉丝
鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 03:展开态列表详情联动布局
2026-05-13 08:44:21 发布鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 02:折叠态页面的信息密度控制
2026-05-12 08:22:06 发布
京公网安备:11010502051901号