鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 09:展开态列表增加字段但不变复杂

前言

列表页是折叠屏适配里最容易被低估的页面。

因为它看起来太普通了。很多应用里都有列表:材料列表、通知列表、任务列表、会议列表、整理结果列表。手机上能用,放到 Pura X Max 外屏上也能用,到了展开态里好像也没有明显报错。真正跑起来看一眼,问题才会慢慢浮出来:页面变宽了,但信息并没有变得更好读。

我一开始也容易把这个问题想简单。展开态空间更大,那就多放几个字段。标题旁边放状态,下面放摘要,再加来源、时间、标签、负责人、优先级,右边再放按钮。这样一看,页面确实不空了,但列表开始变得像一张压缩过的小表格。字段越多,用户扫列表越慢。

Pura X Max 的外屏是 5.4 英寸,内屏是 7.7 英寸,外屏分辨率为 1848 × 1264,内屏分辨率为 2584 × 1828,系统版本为 HarmonyOS 6.1。这个尺寸变化已经足够让列表页改变信息密度,但改变信息密度不等于把字段全部堆上去。展开态应该让用户更快判断信息,而不是让用户在更多字段里重新找重点。

所以这次我处理的不是列表怎么变复杂,而是列表怎么在展开态增加字段以后仍然保持清楚。小屏只保留主字段,大屏补充辅助字段,完整详情继续交给详情页或右侧面板。

一、列表先别急着变多

1.1 大屏空着也不对

Pura X Max 展开态里,如果列表卡片还和外屏一样,只显示标题和状态,问题会很明显。

卡片被横向拉宽,页面左右空间变大,但一屏看到的有效信息没有增加。用户还是要点进详情,才能知道这条记录到底讲了什么、来自哪里、是不是需要马上处理。这样一来,展开态的优势基本没有发挥出来。

这类页面在真实项目里很常见。比如一个材料整理应用,用户打开列表时,通常不是只想看标题。他还想快速判断这条材料是不是待处理,是拍照来的还是语音转写来的,摘要里有没有关键信息,时间是不是最近生成的。

如果展开态还只展示标题和状态,页面看起来干净,但信息判断效率不高。用户拥有更大的屏幕,却仍然要靠频繁点击详情来完成判断。

1.2 字段太多也不行

另一个方向也不稳,就是把字段全部放出来。

我之前看过一些大屏列表改造,做着做着就变成了表格。标题、摘要、来源、时间、标签、负责人、优先级、操作按钮都显示出来,每条记录看起来信息很多,但用户的第一眼反而不知道该看哪里。

这类问题的本质是阅读顺序被打乱了。

列表页的核心任务是让用户快速扫。用户扫列表时,通常不是逐字阅读,而是先抓几个判断点:

  • 这条记录是什么
  • 当前状态是什么
  • 大概内容是什么
  • 它从哪里来
  • 我现在要不要处理

这些判断点有先后顺序。标题和状态是第一层,摘要和来源是第二层,负责人和优先级是第三层。完整原文、历史记录、附件、识别日志这些内容,已经不是列表页应该承载的东西。

所以展开态列表增加字段时,我会先克制一点。多放字段没问题,但字段要有层级。卡片仍然要像卡片,不能变成一行一行字段拼出来的表格。

二、先给字段分层

2.1 主字段永远显示

列表里有些字段是不能藏的。

标题要显示,因为用户需要知道这条记录是什么。状态要显示,因为用户要判断是不是待处理。主操作也要保留,因为用户可能要快速处理当前记录。

这三个字段属于主字段。无论外屏还是展开态,它们都应该存在。

在小屏里,我会把卡片压得比较短,只展示标题、状态和主操作。这样做有一个好处:外屏一屏能看到更多记录,用户扫列表时不会被摘要、标签、来源打断。

2.2 辅助字段跟着宽度出现

摘要、来源、时间、标签、负责人、优先级这些字段,我会放到辅助层。

它们不是不重要,而是要看窗口宽度够不够。外屏里全放出来会挤,展开态里适当放出来很有价值。

比如摘要可以让用户不用进详情就知道内容大概;来源可以说明这条记录来自拍照、语音还是文本整理;时间可以帮助判断是否是最近内容;标签和优先级可以辅助筛选;负责人适合放在右侧小区域,告诉用户这条记录归谁处理。

这也是我写响应式列表时常用的思路:数据是一套,展示是多层。不要为了小屏和大屏拆两套数据结构,真正变化的是字段显示层级。

2.3 详情字段不要塞进列表

有些字段就不应该放在列表里。

完整原文、识别日志、附件、历史操作记录,这些信息看起来能增加内容厚度,但放到列表里会严重影响浏览效率。列表页只适合承担判断任务,真正的深度信息应该进入详情页、右侧详情面板或者弹层。

这个判断很重要。很多展开态页面出问题,不是因为字段太少,而是因为没有分清字段属于列表还是详情。

我这次把字段拆成三层:

主字段:标题、状态、主操作。

辅助字段:摘要、来源、时间、标签、负责人、优先级。

详情字段:完整原文、附件、日志、历史处理记录。

这样拆完以后,代码里的判断会简单很多。小屏只渲染主字段,展开态补充辅助字段,详情字段暂时不进入列表卡片。

三、断点只管展示层

3.1 不按机型写死

Pura X Max 适配里,我不太愿意直接写某个机型判断。原因很简单,折叠屏还有分屏、悬浮窗、横屏这些状态。即使是同一台设备,应用窗口也不一定永远是外屏或完整展开态。

更稳的方式是看当前窗口宽度。宽度达到一定值,就展示辅助字段;宽度不够,就保留主字段。

示例里我用了一个简单阈值:

arkts 复制代码
private readonly expandedWidth: number = 780;

private isExpanded(): boolean {
  return this.getEffectiveWidth() >= this.expandedWidth;
}

这里的 780vp 不是标准答案。不同项目要根据卡片内容调整。卡片内容短,可以提前进入展开态;摘要比较长,或者右侧还有操作区,就要适当调高阈值。

3.2 演示宽度只服务 Demo

代码里还有一个 previewWidth。它的作用只是方便在同一台模拟器里切换外屏和展开态。

真实项目里不需要这个字段。真实项目只需要通过页面区域变化拿到当前宽度,然后计算布局状态。

这个区别我会在代码里写注释,因为它很容易被误用。Demo 里加按钮,是为了让读者不用反复折叠设备也能看到效果;迁移回项目时,演示逻辑应该删掉。

3.3 卡片分开写更清楚

这次我没有把所有字段都写在一个卡片里,再用很多 if 控制显示。那样虽然代码量少一点,但读起来不直观。

我把卡片拆成两个:

CompactCard 处理小屏卡片。

ExpandedCard 处理展开态卡片。

最后通过 RecordCard() 统一选择。

arkts 复制代码
@Builder
private RecordCard(item: MaterialRecord) {
  if (this.isExpanded()) {
    this.ExpandedCard(item)
  } else {
    this.CompactCard(item)
  }
}

这样写的好处是结构清楚。小屏卡片怎么组织,展开态卡片怎么组织,各自独立。后续要调整某个状态,也不容易互相影响。

四、跑一遍材料列表

这个示例模拟的是材料整理场景。窄窗口下,卡片只显示状态、标题和主操作;展开态下,卡片增加摘要、来源、时间、标签、负责人和优先级,但仍然保持卡片结构。

代码可以放到 entry/src/main/ets/pages/Index.ets 中运行。里面没有依赖项目主题、图片资源或接口数据,主要用来观察不同窗口宽度下的字段显示策略。

arkts 复制代码
interface MaterialRecord {
  id: number;
  title: string;
  status: string;
  actionText: string;
  summary: string;
  source: string;
  time: string;
  tag: string;
  priority: string;
  owner: string;
}

@Entry
@Component
struct Index {
  // 当前页面真实宽度,由 onAreaChange 写入
  @State private pageWidth: number = 0;

  // 演示宽度,只用于在同一个模拟器里切换外屏和展开态效果
  @State private previewWidth: number = 0;

  // 当前选中项,用来观察不同布局下选中态是否一致
  @State private selectedId: number = 1;

  // 展开态阈值。真实项目里可以抽成统一断点配置
  private readonly expandedWidth: number = 780;

  // 模拟材料数据。字段故意多一些,方便展示主字段和辅助字段的分层
  private readonly records: MaterialRecord[] = [
    {
      id: 1,
      title: '社区物业缴费提醒',
      status: '待处理',
      actionText: '处理',
      summary: '识别到物业费缴纳截止日期、金额明细和办理地点,建议保存为待办提醒。',
      source: '拍照整理',
      time: '09:20',
      tag: '通知',
      priority: '高优先级',
      owner: '物业服务中心'
    },
    {
      id: 2,
      title: 'Pura X Max 适配会议纪要',
      status: '待确认',
      actionText: '确认',
      summary: '整理出外屏、展开态、横屏和悬停态几类页面问题,适合进入开发清单。',
      source: '语音转写',
      time: '10:45',
      tag: '会议',
      priority: '中优先级',
      owner: '产品研发组'
    },
    {
      id: 3,
      title: '客户需求变更记录',
      status: '待处理',
      actionText: '处理',
      summary: '本次变更涉及首页布局、权限配置、消息提醒和后台字段展示。',
      source: '文本整理',
      time: '13:10',
      tag: '项目',
      priority: '高优先级',
      owner: '客户成功组'
    },
    {
      id: 4,
      title: '活动报名确认单',
      status: '已保存',
      actionText: '查看',
      summary: '提取到报名人、联系方式、活动时间和签到地址,可加入行程提醒。',
      source: '相册导入',
      time: '15:25',
      tag: '表单',
      priority: '普通',
      owner: '活动运营'
    },
    {
      id: 5,
      title: '门诊复查预约提示',
      status: '已整理',
      actionText: '查看',
      summary: '提取到复查时间、科室、楼层和注意事项,后续可保存为健康提醒。',
      source: '拍照整理',
      time: '16:40',
      tag: '提醒',
      priority: '中优先级',
      owner: '个人记录'
    },
    {
      id: 6,
      title: '课程作业提交说明',
      status: '已整理',
      actionText: '查看',
      summary: '识别到提交时间、文件格式、命名规范和邮箱地址,适合保存为待办。',
      source: '图片识别',
      time: '18:05',
      tag: '学习',
      priority: '普通',
      owner: '课程助教'
    }
  ];

  // Demo 中优先使用演示宽度,真实项目里可以直接返回 pageWidth
  private getEffectiveWidth(): number {
    if (this.previewWidth > 0) {
      return this.previewWidth;
    }

    return this.pageWidth;
  }

  // 只用窗口宽度判断字段层级,不按具体机型写死逻辑
  private isExpanded(): boolean {
    return this.getEffectiveWidth() >= this.expandedWidth;
  }

  private getContentWidth(): Length {
    if (this.previewWidth > 0) {
      return this.previewWidth;
    }

    return '100%';
  }

  private getPagePadding(): number {
    return this.isExpanded() ? 24 : 16;
  }

  private getTitleSize(): number {
    return this.isExpanded() ? 28 : 23;
  }

  private getModeText(): string {
    return this.isExpanded() ? 'expanded · 辅助字段展开' : 'compact · 保留主字段';
  }

  private getModeDesc(): string {
    if (this.isExpanded()) {
      return '当前列表显示摘要、来源、时间、标签和负责人,但仍然保持卡片阅读。';
    }

    return '当前列表只显示标题、状态和主操作,适合外屏快速浏览。';
  }

  private getSelectedRecord(): MaterialRecord {
    const found = this.records.find((item: MaterialRecord) => item.id === this.selectedId);
    return found ? found : this.records[0];
  }

  private setPreview(width: number) {
    this.previewWidth = width;
  }

  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('外屏', 430)
        this.PreviewButton('展开态', 960)
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  private CompactCard(item: MaterialRecord) {
    Column({ space: 12 }) {
      Row({ space: 8 }) {
        this.StatusPill(item.status)

        if (this.selectedId === item.id) {
          Text('当前')
            .fontSize(12)
            .fontColor('#2F8F83')
        }

        Blank()

        Button(item.actionText)
          .fontSize(13)
          .fontColor('#FFFFFF')
          .height(32)
          .padding({ left: 12, right: 12 })
          .backgroundColor('#2F8F83')
          .borderRadius(16)
          .onClick(() => {
            this.selectedId = item.id;
          })
      }
      .width('100%')

      Text(item.title)
        .fontSize(17)
        .fontWeight(FontWeight.Medium)
        .fontColor('#111827')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('100%')
    .padding(16)
    .backgroundColor(this.selectedId === item.id ? '#EEF7F5' : '#FFFFFF')
    .borderRadius(20)
    .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 ExpandedCard(item: MaterialRecord) {
    Column({ space: 14 }) {
      Row({ space: 16 }) {
        Column({ space: 10 }) {
          Row({ space: 8 }) {
            this.StatusPill(item.status)
            this.MetaPill(item.tag)

            if (this.selectedId === item.id) {
              Text('当前')
                .fontSize(12)
                .fontColor('#2F8F83')
            }
          }
          .width('100%')

          Text(item.title)
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .fontColor('#111827')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          // 摘要只在展开态出现,避免外屏卡片被撑高
          Text(item.summary)
            .fontSize(14)
            .fontColor('#4B5563')
            .lineHeight(21)
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          Row({ space: 8 }) {
            this.MetaPill(item.source)
            this.MetaPill(item.time)
            this.MetaPill(item.priority)
          }
          .width('100%')
        }
        .layoutWeight(1)

        // 右侧只保留负责人和主操作,不把更多详情继续塞进列表
        Column({ space: 8 }) {
          Text('负责人')
            .fontSize(12)
            .fontColor('#9CA3AF')

          Text(item.owner)
            .fontSize(14)
            .fontColor('#374151')
            .fontWeight(FontWeight.Medium)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          Button(item.actionText)
            .fontSize(13)
            .fontColor('#FFFFFF')
            .height(34)
            .width('100%')
            .backgroundColor('#2F8F83')
            .borderRadius(17)
            .onClick(() => {
              this.selectedId = item.id;
            })
        }
        .width(132)
        .padding(12)
        .backgroundColor('#F9FAFB')
        .borderRadius(16)
      }
      .width('100%')
    }
    .width('100%')
    .padding(18)
    .backgroundColor(this.selectedId === item.id ? '#EEF7F5' : '#FFFFFF')
    .borderRadius(22)
    .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 RecordCard(item: MaterialRecord) {
    if (this.isExpanded()) {
      this.ExpandedCard(item)
    } else {
      this.CompactCard(item)
    }
  }

  @Builder
  private SelectedPanel() {
    Column({ space: 10 }) {
      Row() {
        Text('当前选中')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#111827')

        Blank()

        this.StatusPill(this.getSelectedRecord().status)
      }
      .width('100%')

      Text(this.getSelectedRecord().title)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#111827')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      if (this.isExpanded()) {
        Text(this.getSelectedRecord().summary)
          .fontSize(14)
          .fontColor('#4B5563')
          .lineHeight(21)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(22)
    .shadow({
      radius: 10,
      color: '#10000000',
      offsetX: 0,
      offsetY: 4
    })
  }

  @Builder
  private ListArea() {
    Scroll() {
      Column({ space: 12 }) {
        ForEach(this.records, (item: MaterialRecord) => {
          this.RecordCard(item)
        }, (item: MaterialRecord) => item.id.toString())
      }
      .width('100%')
      .padding({ bottom: 24 })
    }
    .layoutWeight(1)
    .width('100%')
    .edgeEffect(EdgeEffect.Spring)
  }

  build() {
    Column() {
      Column({ space: 16 }) {
        this.HeaderPanel()
        this.SelectedPanel()
        this.ListArea()
      }
      .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;
      }
    })
  }
}

五、跑出来后的差异

外屏状态下,列表卡片会明显短很多。每张卡片只有状态、标题和操作按钮,用户可以快速扫过多条记录。这个状态适合手机外屏和窄窗口,重点是减少打扰,先让用户判断哪条记录需要处理。

展开态下,同样的数据会显示更多上下文。标题下面出现摘要,卡片底部出现来源、时间和优先级,右侧小区域显示负责人和操作按钮。信息密度确实提高了,但卡片仍然保留主次关系,没有被改造成表格。

这段代码里,真正需要迁回项目的是字段分层和 RecordCard() 的判断逻辑。previewWidthPreviewButton() 这些只是演示用的,正式项目里应该删掉,让页面直接跟随真实窗口宽度变化。

还有一点需要注意:展开态增加字段不是越多越好。我的经验是,一张列表卡片里最多放一段摘要、三到四个元信息标签、一个主操作。如果再继续塞负责人电话、附件数量、完整原文、历史状态,页面很快就会变成压缩表格。列表页只负责判断,详情页才负责展开。

聊天消息、时间线动态、审批流这类强顺序内容,也不适合用这种字段扩展方式。它们更依赖连续阅读,字段变多会打断节奏。材料列表、任务列表、通知列表、客户记录这类相对独立的卡片,更适合采用这种处理。

总结

Pura X Max 展开态给了列表页更多空间,但这些空间应该用来提高判断效率,而不是把所有字段都塞进卡片。小屏下保留标题、状态和主操作,展开态再补充摘要、来源、时间、标签和负责人,列表会更有信息量,也不会失去卡片阅读的节奏。

我现在处理这类列表时,会先给字段分层,再决定哪些字段跟随断点展示。标题、状态、主操作属于核心字段;摘要、来源、时间、标签属于辅助字段;完整内容、历史记录和附件仍然留在详情层。这个顺序稳定以后,外屏和展开态的列表体验都会清楚很多。

相关推荐
richard_yuu1 小时前
鸿蒙治愈游戏模块实战|四大轻量解压游戏、ArkTS动画交互与低功耗落地
游戏·交互·harmonyos
阿钱真强道5 小时前
24 鸿蒙LiteOS GPIO中断实战:从原理到上升沿/下降沿详解
harmonyos·中断·rk·liteos·开源鸿蒙·瑞芯微·rk2206
小崽崽16 小时前
华为云云主机 + DeepSeek|快速实现华为云DeepSeek大模型搭建“腾讯云代码助手”客户端集成DeepSeek模型
华为·华为云·腾讯云
cd_949217217 小时前
鸿蒙系统下抖音存储空间不足怎么办?缓存清理教程
缓存·华为·harmonyos
轻口味10 小时前
HarmonyOS 6.1 全栈实战录 - 14 渲染树透镜:FrameNode 渲染状态感知与高性能 UI 调优实战
ui·华为·harmonyos
HwJack2010 小时前
HarmonyOS NEXT 游戏APP开发中如何正确拦截退出手势
游戏·华为·harmonyos
HwJack2010 小时前
HarmonyOS APP开发中ArkTS/JS 类型错误全景拆解
javascript·华为·harmonyos
lqj_本人11 小时前
鸿蒙PC:鸿蒙版本 Electron 框架环境搭建并且实现 XH 笔记应用
笔记·electron·harmonyos
不爱吃糖的程序媛11 小时前
特色软件 | 补齐 鸿蒙 PC 开发短板,Harmonybrew 的环境适配方案
华为·harmonyos