鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 03:展开态列表详情联动布局
Pura X Max 展开后,列表页最明显的变化是横向空间变多了。外屏下点击一条记录再进入详情页,这个路径很自然;到了展开态,如果还沿用同样的跳转方式,用户就会在列表页和详情页之间反复切换,屏幕右侧的大块空间也没有被用起来。
前言
Pura X Max 展开后,列表页最明显的变化是横向空间变多了。外屏下点击一条记录再进入详情页,这个路径很自然;到了展开态,如果还沿用同样的跳转方式,用户就会在列表页和详情页之间反复切换,屏幕右侧的大块空间也没有被用起来。
Pura X Max 外屏为 5.4 英寸,内屏为 7.7 英寸,系统版本为 HarmonyOS 6.1。外屏和内屏的尺寸差异足够明显,列表页在展开态下可以把列表和详情放到同一个页面里。用户点击左侧列表,右侧详情直接更新,阅读路径会短很多。
HarmonyOS 多设备页面布局里,空间充足时可以采用分栏布局,把窗口划分为两栏或三栏,用来展示多类内容。响应式布局也把分栏布局作为常见方式之一,适合把导航区和内容区同屏左右展示。
我在处理列表详情页时,会先保留窄屏的普通列表体验,再给展开态增加右侧详情区。这样做不会影响外屏的操作习惯,也能让内屏真正承担更多信息。

问题出在展开态仍然跳转
普通手机上的列表页,一般是这样的路径:
点击列表项。
进入详情页。
返回列表。
再点击另一项。
这个路径适合窄屏,因为一屏很难同时放下列表和详情。用户每次只关注一个页面,层级也比较清楚。
Pura X Max 展开态就不一样了。内屏宽度变大以后,继续让列表占满整屏,会出现几个问题。
列表卡片被横向拉宽,但信息密度没有明显提升。
查看不同记录时,需要频繁进入详情和返回列表。
用户已经拥有更大的屏幕,页面仍然按窄屏流程工作。
这种页面更适合做成主从结构。左侧列表负责选择对象,右侧详情负责展示内容。用户不需要离开当前页面,就能快速比较不同记录。
这里不需要一开始就引入复杂路由。先用 Row 把页面分成左右两块,左侧放列表,右侧放当前选中项的详情。Row 本身就是沿水平方向布局的容器,适合搭建这种左右结构。
用 Row 改成列表详情
页面仍然根据窗口宽度判断布局状态。
窄屏进入 compact,只展示列表。这个状态适合外屏、普通手机宽度和分屏后的窄窗口。
宽屏进入 expanded,页面切成左右两栏。左侧列表负责切换选中项,右侧详情展示选中记录的完整内容。
判断逻辑仍然保持简单:
private readonly expandedWidth: number = 760;
private isExpanded(): boolean {
return this.pageWidth >= this.expandedWidth;
}
这里把阈值设置成 760vp,是为了给右侧详情区留出足够空间。列表详情联动比双列卡片更吃宽度,如果阈值过低,左右两栏都会显得挤。
展开态布局可以这样理解:
Row() {
// 左侧列表
// 右侧详情
}
窄屏布局则保持单页面列表:
Column() {
// 普通列表
}
这个结构的关键点在于:布局可以变化,但选中数据不变。当前选中的记录由 selectedId 保存,左侧列表点击后更新它,右侧详情根据它渲染内容。
把列表和详情放进同一个页面
下面这个页面模拟了一组材料记录。窄窗口下只显示普通列表;展开态下,左侧显示列表,右侧显示当前选中记录详情。点击左侧不同记录,右侧详情会立即变化。
页面放在 entry/src/main/ets/pages/Index.ets 即可运行。Pura X Max 适配调试可以使用 DevEco Studio 6.1.0,并安装 Pura X Max 模拟器验证不同窗口形态下的表现。
interface MaterialItem {
id: number;
title: string;
status: string;
source: string;
time: string;
tag: string;
owner: string;
summary: string;
detail: string;
todo: string;
}
@Entry
@Component
struct Index {
@State private pageWidth: number = 0;
@State private selectedId: number = 1;
private readonly expandedWidth: number = 760;
private readonly materials: MaterialItem[] = [
{
id: 1,
title: '社区物业缴费提醒',
status: '待处理',
source: '拍照整理',
time: '09:20',
tag: '通知',
owner: '物业服务中心',
summary: '识别到缴费截止日期、费用明细和办理地点。',
detail: '这条记录来自一张物业缴费通知。内容里包含缴费周期、应缴金额、截止日期和办理地点。折叠态下只需要知道它是一条待处理提醒,展开态下可以直接看到更多上下文,减少进入详情页的次数。',
todo: '添加缴费提醒,并确认是否需要同步到日程。'
},
{
id: 2,
title: 'Pura X Max 适配会议纪要',
status: '待确认',
source: '语音转写',
time: '10:45',
tag: '会议',
owner: '产品研发组',
summary: '整理出外屏、展开态、横屏和悬停态几类页面问题。',
detail: '会议讨论了多个页面在 Pura X Max 上的展示问题,其中列表页、详情页、设置页和图片预览页都需要重新检查窗口变化后的布局表现。展开态更适合使用列表详情结构,减少页面跳转。',
todo: '确认适配清单,并把列表详情联动加入开发任务。'
},
{
id: 3,
title: '活动报名确认单',
status: '已保存',
source: '相册导入',
time: '11:30',
tag: '表单',
owner: '活动运营',
summary: '提取到报名人、联系方式、活动时间和签到地址。',
detail: '这条记录适合在列表右侧直接查看摘要和关键字段。用户通常只是确认活动时间和地点,不一定需要进入完整详情页。',
todo: '保留记录,并在活动前一天提醒。'
},
{
id: 4,
title: '客户需求变更记录',
status: '待处理',
source: '文本整理',
time: '13:10',
tag: '项目',
owner: '客户成功组',
summary: '本次变更涉及首页布局、权限配置和通知策略。',
detail: '需求变更类记录往往需要反复对照多个条目。展开态下把列表和详情放在同一屏,可以减少返回列表的频率,也方便连续检查不同变更项。',
todo: '同步项目负责人,并拆分到研发排期。'
},
{
id: 5,
title: '课程作业提交说明',
status: '已整理',
source: '拍照整理',
time: '15:25',
tag: '学习',
owner: '课程助教',
summary: '识别到提交时间、文件格式、命名规范和邮箱地址。',
detail: '学习类通知一般字段较多。展开态详情区可以把提交要求完整展示出来,左侧列表继续保留其他记录,切换查看会更快。',
todo: '创建作业待办,并保留提交格式说明。'
}
];
private isExpanded(): boolean {
return this.pageWidth >= this.expandedWidth;
}
private getSelectedItem(): MaterialItem {
const found = this.materials.find((item: MaterialItem) => item.id === this.selectedId);
return found ? found : this.materials[0];
}
private getPagePadding(): number {
return this.isExpanded() ? 24 : 16;
}
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 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 ListCard(item: MaterialItem) {
Column({ space: 10 }) {
Row({ space: 8 }) {
this.StatusPill(item.status)
if (this.isExpanded()) {
Text(item.tag)
.fontSize(12)
.fontColor('#2F8F83')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('#E6F4F1')
.borderRadius(999)
}
Blank()
if (this.selectedId === item.id) {
Text('当前')
.fontSize(12)
.fontColor('#2F8F83')
}
}
.width('100%')
Text(item.title)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.fontColor('#111827')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.isExpanded()) {
Text(item.summary)
.fontSize(13)
.fontColor('#6B7280')
.lineHeight(19)
.maxLines(2)
.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%')
}
.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 }) {
Row() {
Column({ space: 4 }) {
Text(this.isExpanded() ? '材料列表' : '整理记录')
.fontSize(this.isExpanded() ? 22 : 24)
.fontWeight(FontWeight.Bold)
.fontColor('#111827')
Text(this.isExpanded() ? '点击左侧记录,右侧详情会同步更新' : '外屏保持普通列表浏览')
.fontSize(14)
.fontColor('#6B7280')
}
.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%')
Scroll() {
Column({ space: 12 }) {
ForEach(this.materials, (item: MaterialItem) => {
this.ListCard(item)
}, (item: MaterialItem) => item.id.toString())
}
.width('100%')
.padding({ bottom: 20 })
}
.layoutWeight(1)
.width('100%')
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
}
@Builder
private DetailPanel(item: MaterialItem) {
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(item.todo)
.fontSize(15)
.fontColor('#4B5563')
.lineHeight(23)
}
.width('100%')
.padding(16)
.backgroundColor('#F3F8F7')
.borderRadius(18)
Blank()
Row({ space: 12 }) {
Button('标记完成')
.fontSize(15)
.fontColor('#FFFFFF')
.height(42)
.layoutWeight(1)
.backgroundColor('#2F8F83')
.borderRadius(21)
Button('进入详情')
.fontSize(15)
.fontColor('#2F8F83')
.height(42)
.layoutWeight(1)
.backgroundColor('#E6F4F1')
.borderRadius(21)
}
.width('100%')
}
.width('100%')
.height('100%')
.padding(24)
.backgroundColor('#FFFFFF')
.borderRadius(24)
.shadow({
radius: 12,
color: '#10000000',
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)
}
build() {
Column() {
if (this.isExpanded()) {
Row({ space: 18 }) {
Column() {
this.ListPanel()
}
.width(340)
.height('100%')
Column() {
this.DetailPanel(this.getSelectedItem())
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
.padding(24)
} else {
Column() {
this.ListPanel()
}
.width('100%')
.height('100%')
.padding({
left: this.getPagePadding(),
right: this.getPagePadding(),
top: 18
})
}
}
.width('100%')
.height('100%')
.backgroundColor('#F6F7F9')
.onAreaChange((_: Area, newValue: Area) => {
const width = Number(newValue.width);
if (!Number.isNaN(width) && width > 0) {
this.pageWidth = width;
}
})
}
}
关键实现点和适配边界
这个页面最重要的状态只有两个。
一个是窗口宽度。
@State private pageWidth: number = 0;
另一个是当前选中记录。
@State private selectedId: number = 1;
宽度决定当前采用列表模式还是列表详情模式,选中记录决定右侧展示什么内容。
private isExpanded(): boolean {
return this.pageWidth >= this.expandedWidth;
}
展开态通过 Row 切出左右两栏。
Row({ space: 18 }) {
Column() {
this.ListPanel()
}
.width(340)
Column() {
this.DetailPanel(this.getSelectedItem())
}
.layoutWeight(1)
}
左侧宽度我设置成 340vp。这个数值不是固定标准,只是为了让列表项在展开态下保持稳定宽度。真实项目里可以根据业务列表内容调整,比如记录标题普遍较长,可以给到 360vp;如果只是短标题列表,320vp 就够用。
右侧详情使用 layoutWeight(1) 占满剩余空间。这样窗口继续变宽时,详情区获得更多空间,左侧列表不会被拉得过宽。
窄屏下只渲染 ListPanel()。
Column() {
this.ListPanel()
}
真实项目里,窄屏点击列表项通常会进入详情页。为了让页面能在一个 Index.ets 里直接看到效果,示例里保留了点击选中状态,没有额外做路由跳转。回到项目时,可以把 compact 下的点击事件换成 Navigation 路由,把 expanded 下的点击事件保留为更新 selectedId。
这个方案适合材料列表、会议列表、客户列表、任务列表、消息列表等场景。它不适合所有列表。像聊天消息、时间线动态、审批流记录这类强顺序内容,更适合保持单列连续阅读,不宜强行拆成左右两栏。
验证要看什么
外屏或窄窗口里,页面应该是普通列表。顶部显示当前窗口宽度,列表从上到下排列,卡片宽度不会被压缩。这个状态下要重点看卡片标题是否能读完,状态标签是否明显,滚动是否自然。

展开态里,左侧应该是固定宽度列表,右侧是详情内容。点击左侧不同记录,右侧标题、摘要、来源、负责人、内容整理和建议动作都会切换。这个状态下要重点看三个位置。
左侧列表是否过宽。
右侧详情是否有足够阅读空间。
点击切换时,选中态和详情内容是否一致。
如果右侧详情只是重复左侧标题,分栏的价值就不明显。展开态的详情区应该承载更多上下文,比如摘要、完整整理内容、建议动作、操作按钮和关联信息。

总结
Pura X Max 展开态适合把列表页从单页面浏览改成列表详情联动。
外屏继续保持普通列表,符合窄屏操作习惯;展开态把列表和详情放到同一屏,用户点击左侧记录,右侧内容直接更新,减少来回跳转。这个结构的关键是把窗口宽度和选中状态分开处理。窗口宽度决定页面结构,选中状态决定详情内容。
实际项目里可以把这个思路放到材料整理、会议记录、客户资料、任务管理和设置分类页面中。只要列表项和详情之间存在频繁切换的需求,展开态分栏就能明显减少操作路径。
更多推荐




所有评论(0)