前言

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 展开态适合把列表页从单页面浏览改成列表详情联动。

外屏继续保持普通列表,符合窄屏操作习惯;展开态把列表和详情放到同一屏,用户点击左侧记录,右侧内容直接更新,减少来回跳转。这个结构的关键是把窗口宽度和选中状态分开处理。窗口宽度决定页面结构,选中状态决定详情内容。

实际项目里可以把这个思路放到材料整理、会议记录、客户资料、任务管理和设置分类页面中。只要列表项和详情之间存在频繁切换的需求,展开态分栏就能明显减少操作路径。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐