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

问题出在状态跟着布局走
列表页从 compact 切到 expanded,常见写法是根据宽度渲染两套不同结构。
窄屏只渲染列表。
宽屏渲染左侧列表和右侧详情。
如果选中项只存在于某个子组件内部,布局一切换,这个子组件就可能被销毁并重新创建。结果就是选中态回到默认值,右侧详情也跟着回到第一条。
这种问题在普通手机上不明显,因为页面结构变化不频繁。Pura X Max 的开合、横竖屏、分屏窗口都会改变可用宽度,页面结构会更频繁地切换。
更稳的做法是把选中项提升到页面状态里。页面结构根据宽度变化,业务状态继续保留在当前页面组件中。
arkts
@State private selectedId: number = 2;
selectedId 不属于左侧列表,也不属于右侧详情。它属于整个页面。列表负责修改它,详情负责读取它。这样一来,布局切换不会影响当前选中的业务数据。
把选中项留在页面状态里
页面里可以有两个维度的状态。
一个是布局状态,来自当前窗口宽度。
arkts
@State private pageWidth: number = 0;
private isExpanded(): boolean {
return this.pageWidth >= this.expandedWidth;
}
另一个是业务状态,来自用户操作。
arkts
@State private selectedId: number = 2;
这两个状态不要混在一起。pageWidth 只决定页面是单栏还是双栏,selectedId 只决定当前选中哪条记录。
页面宽度可以通过 onAreaChange 更新。组件区域变化事件会在组件显示尺寸或位置变化时触发,适合处理窗口变化后的页面级响应。
arkts
.onAreaChange((_: Area, newValue: Area) => {
const width = Number(newValue.width);
if (!Number.isNaN(width) && width > 0) {
this.pageWidth = width;
}
})
选中记录通过点击列表项更新。
arkts
.onClick(() => {
this.selectedId = item.id;
})
页面切到 expanded 后,右侧详情通过 selectedId 找到当前记录。
arkts
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,并安装对应模拟器检查外屏和展开态的表现。
arkts
interface RecordItem {
id: number;
title: string;
status: string;
source: string;
time: string;
tag: string;
owner: string;
summary: string;
detail: string;
action: string;
}
@Entry
@Component
struct 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。
arkts
@State private actionCount: number = 0;
它模拟用户在当前记录上做过的操作。点击右侧详情里的按钮,计数会增加;再切回外屏,计数仍然显示在"当前处理"卡片里。这个小状态能更直观地验证一点:保留的不只是选中项,也可以是当前页面里的临时操作状态。
真实项目里,状态层级可以按生命周期来分。
页面临时状态适合放在当前组件里,例如选中记录、筛选条件、展开折叠状态、当前 Tab。
跨页面状态可以放到父级页面、路由参数、AppStorage 或业务容器里,例如当前项目、登录用户、全局主题、权限信息。
需要持久化的状态应该写入数据库或设置仓库,例如用户偏好、草稿、上次打开的项目。
开合切换通常只需要保留页面上下文,不一定要把所有状态都写进数据库。把所有临时状态都持久化,会让简单交互变得过重。
总结
Pura X Max 的开合切换会频繁改变页面结构,列表页适配不能只看单列、双列或分栏。用户正在处理哪条记录、当前操作进行到哪里,也需要保留下来。
比较稳的处理方式是把布局状态和业务状态分开。窗口宽度决定页面怎么摆,选中记录决定当前显示什么。布局可以在 compact 和 expanded 之间切换,selectedId、操作计数、筛选条件这类业务状态继续留在页面状态里。
这个方法适合材料整理、会议记录、客户资料、任务管理、设置分类等页面。只要用户会在列表和详情之间反复切换,状态保持就会直接影响使用感。折叠态和展开态看起来是两种布局,用户感受到的应该是同一个任务上下文。