鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 04:开合切换后的选中状态保持

前言

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、操作计数、筛选条件这类业务状态继续留在页面状态里。

这个方法适合材料整理、会议记录、客户资料、任务管理、设置分类等页面。只要用户会在列表和详情之间反复切换,状态保持就会直接影响使用感。折叠态和展开态看起来是两种布局,用户感受到的应该是同一个任务上下文。

相关推荐
阿钱真强道1 小时前
22 鸿蒙LiteOS 互斥锁(Mutex)实战教程:多任务共享资源保护
harmonyos·鸿蒙·互斥·rk·liteos·瑞芯微·rk2206
大师兄66681 小时前
HarmonyOS 卡片 UI 三种玩法:普通卡片、动效卡片、Canvas 卡片
harmonyos·arkts·formkit·动效卡片·canvas卡片
特立独行的猫a6 小时前
鸿蒙 PC 命令行工具迁移实战 · 直播PPT
android·华为·harmonyos·vcpkg·三方库移植·鸿蒙pc
想你依然心痛6 小时前
HarmonyOS 6(API 23)实战:基于悬浮导航、沉浸光感与Face AR & Body AR的“灵犀智投“——PC端沉浸式AR量化交易分析工作台
华为·ar·harmonyos·悬浮导航·沉浸光感
特立独行的猫a7 小时前
鸿蒙 PC 三方库移植实战 · 直播课件(详细教案)
华为·harmonyos·移植·鸿蒙pc·opendesk
xmdy58668 小时前
Flutter+开源鸿蒙实战|企业级工具APP Day2 全局网络封装与 Dio 拦截器实战(鸿蒙兼容版)
flutter·开源·harmonyos
xmdy58668 小时前
Flutter+开源鸿蒙实战:企业级工具类APP开发教程(含第三方库适配)
flutter·开源·harmonyos
richard_yuu9 小时前
鸿蒙Stage模型实战|心晴驿站分层架构与隐私安全设计
安全·架构·harmonyos
Swift社区9 小时前
Flutter / React / ArkUI:在鸿蒙 PC 上怎么选?
flutter·react.js·harmonyos