鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 05:用窗口宽度驱动页面断点 原创
头像 小雨同学 2026-05-15 09:11:02    发布
1 浏览 0 点赞 0 收藏


前言

Pura X Max 的适配不能只靠外屏和内屏两个判断。

外屏通常对应窄窗口,展开态通常对应宽窗口,但应用实际运行时还会遇到分屏、悬浮窗、横屏、平板和 2in1 等情况。页面真正能使用的空间,最终取决于当前应用窗口宽度。

Pura X Max 外屏为 5.4 英寸,内屏为 7.7 英寸,外屏分辨率为 1848 × 1264,内屏分辨率为 2584 × 1828,系统版本为 HarmonyOS 6.1。这个设备本身已经带来了明显的窗口变化,页面如果继续按固定机型写判断,后续很容易被分屏和多窗口场景打断。

我在做这类页面时,会先建立一套窗口断点。compact 处理窄窗口,medium 处理过渡窗口,expanded 处理宽窗口。Pura X Max 模拟器上最容易稳定验证的是 compact 和 expanded,medium 更多是给分屏、自由窗口、平板窄窗口和 2in1 场景预留。这样写更贴近真实项目,而不是把所有判断都绑定在某一台设备上。

断点应该从窗口宽度开始

很多适配问题最开始都会被写成机型判断。

看到 Pura X Max,就切换某个布局;看到平板,就切换另一套布局;看到手机,再回到手机布局。这个方案短期能跑,但它有一个很明显的问题:同一台设备的应用窗口不一定只有一种宽度。

Pura X Max 折叠态是窄窗口。

Pura X Max 展开态是宽窗口。

分屏后可能变成中间宽度。

悬浮窗可能比外屏还窄。

横屏后宽高比例又会发生变化。

所以页面适配更适合从窗口宽度出发。当前窗口宽度进入哪一档,页面就切换到对应结构。HarmonyOS 响应式布局也强调应用应根据不同屏幕尺寸和分辨率调整页面,让布局适应不同设备和窗口状态。

我这里先定义三档。

compact 用于窄窗口。页面保持单列,字号略小,主操作放到底部,减少横向拥挤。

medium 用于中等窗口。卡片可以切到双列,描述信息开始显示,操作区放到内容上方。

expanded 用于宽窗口。内容区和操作区左右分离,让大屏空间承担更多上下文。

这三个状态不一定都能通过 Pura X Max 的普通开合稳定触发。compact 和 expanded 主要通过折叠态、展开态验证;medium 保留在代码里,用来覆盖更复杂的窗口宽度。

compact、medium、expanded 的边界怎么定

断点值不要直接当成固定标准。它应该服务页面内容。

如果页面只是功能入口卡片,medium 可以来得早一点。比如 600vp 左右就能进入双列。

如果页面卡片里有摘要、按钮和状态标签,expanded 可以放得晚一点。比如 900vp 之后再把操作区移到右侧。

这次示例里采用两个阈值。

private readonly mediumWidth: number = 600;private readonly expandedWidth: number = 900;

窗口宽度低于 600vp,进入 compact。

窗口宽度达到 600vp,但低于 900vp,进入 medium。

窗口宽度达到 900vp,进入 expanded。

布局状态最好收束到一个函数里。

private getLayoutMode(): string {  const width = this.getEffectiveWidth();​  if (width >= this.expandedWidth) {    return 'expanded';  }​  if (width >= this.mediumWidth) {    return 'medium';  }​  return 'compact';}

这里我没有让每个组件自己判断宽度。卡片列数、页面边距、标题字号、操作区位置,都通过 getLayoutMode() 间接判断。这样后续要改断点,只需要改一个地方。

页面实际宽度仍然通过 onAreaChange 获取。这个事件会在组件显示区域变化时触发,适合在页面根容器上记录当前可用宽度。

.onAreaChange((_: Area, newValue: Area) => {  const width = Number(newValue.width);  if (!Number.isNaN(width) && width > 0) {    this.pageWidth = width;  }})

实际项目里,pageWidth 就是断点计算来源。为了在一个页面里更直观地观察三档变化,下面的示例加了演示宽度按钮。真实页面可以删掉这组按钮,只保留窗口宽度判断。

把断点放进页面跑一下

下面的页面可以放到 entry/src/main/ets/pages/Index.ets 运行。Pura X Max 适配调试可以使用 DevEco Studio 6.1.0,并安装 Pura X Max 模拟器验证应用在该机型上的表现。

页面顶部会显示实际窗口宽度、当前用于断点计算的宽度和布局状态。默认使用真实窗口宽度。为了方便观察 medium,页面顶部提供了自动、compact、medium、expanded 四个按钮。选择自动时,页面完全跟随窗口宽度;选择其他按钮时,只是把内容容器限制到对应演示宽度,方便在 Pura X Max 模拟器里对比三档布局。

interface DashboardCard {  id: number;  title: string;  value: string;  desc: string;  status: string;}​@Entry@Componentstruct Index {  @State private pageWidth: number = 0;  @State private previewWidth: number = 0;  @State private selectedId: number = 1;​  private readonly mediumWidth: number = 600;  private readonly expandedWidth: number = 900;​  private readonly cards: DashboardCard[] = [    {      id: 1,      title: '待处理材料',      value: '12',      desc: '包含拍照整理、相册导入和文本记录',      status: '需要处理'    },    {      id: 2,      title: '今日提醒',      value: '5',      desc: '从通知、会议和预约信息中提取',      status: '即将开始'    },    {      id: 3,      title: '已整理记录',      value: '48',      desc: '本周已经完成结构化整理的内容',      status: '稳定'    },    {      id: 4,      title: '待确认摘要',      value: '7',      desc: '需要人工确认识别结果和下一步动作',      status: '待确认'    },    {      id: 5,      title: '图片识别',      value: '23',      desc: '来自原图预览和 OCR 处理流程',      status: '处理中'    },    {      id: 6,      title: '会议记录',      value: '9',      desc: '包含会议纪要、行动项和关联项目',      status: '已同步'    }  ];​  private getEffectiveWidth(): number {    if (this.previewWidth > 0) {      return this.previewWidth;    }​    return this.pageWidth;  }​  private getLayoutMode(): string {    const width = this.getEffectiveWidth();​    if (width >= this.expandedWidth) {      return 'expanded';    }​    if (width >= this.mediumWidth) {      return 'medium';    }​    return 'compact';  }​  private isCompact(): boolean {    return this.getLayoutMode() === 'compact';  }​  private isMedium(): boolean {    return this.getLayoutMode() === 'medium';  }​  private isExpanded(): boolean {    return this.getLayoutMode() === 'expanded';  }​  private getModeText(): string {    if (this.isExpanded()) {      return 'expanded · 宽窗口';    }​    if (this.isMedium()) {      return 'medium · 中等窗口';    }​    return 'compact · 窄窗口';  }​  private getModeDesc(): string {    if (this.isExpanded()) {      return '宽窗口下,内容区和操作区左右分离,适合展示更多上下文。';    }​    if (this.isMedium()) {      return '中等窗口下,卡片进入双列,操作区放到内容上方。';    }​    return '窄窗口下,页面保持单列,主操作靠近底部。';  }​  private getColumnsTemplate(): string {    if (this.isCompact()) {      return '1fr';    }​    return '1fr 1fr';  }​  private getPagePadding(): number {    if (this.isExpanded()) {      return 24;    }​    if (this.isMedium()) {      return 20;    }​    return 16;  }​  private getTitleSize(): number {    if (this.isExpanded()) {      return 28;    }​    if (this.isMedium()) {      return 25;    }​    return 22;  }​  private getCardTitleSize(): number {    if (this.isCompact()) {      return 16;    }​    return 17;  }​  private getCardValueSize(): number {    if (this.isExpanded()) {      return 34;    }​    if (this.isMedium()) {      return 30;    }​    return 28;  }​  private getContentWidth(): Length {    if (this.previewWidth > 0) {      return this.previewWidth;    }​    return '100%';  }​  private getSelectedCard(): DashboardCard {    const found = this.cards.find((item: DashboardCard) => item.id === this.selectedId);    return found ? found : this.cards[0];  }​  private getStatusColor(status: string): string {    if (status === '需要处理' || status === '待确认') {      return '#B25E00';    }​    if (status === '处理中' || status === '即将开始') {      return '#7C3AED';    }​    return '#276749';  }​  private getStatusBgColor(status: string): string {    if (status === '需要处理' || status === '待确认') {      return '#FFF4E5';    }​    if (status === '处理中' || status === '即将开始') {      return '#F1EAFE';    }​    return '#E7F5EE';  }​  private setPreview(width: number) {    this.previewWidth = width;  }​  @Builder  private HeaderPanel() {    Column({ space: 10 }) {      Row() {        Column({ space: 4 }) {          Text('窗口宽度驱动页面断点')            .fontSize(this.getTitleSize())            .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('断点宽度:' + Math.round(this.getEffectiveWidth()).toString() + 'vp。' + this.getModeDesc())        .fontSize(14)        .fontColor('#6B7280')        .lineHeight(21)​      Row({ space: 8 }) {        this.PreviewButton('自动', 0)        this.PreviewButton('compact', 420)        this.PreviewButton('medium', 720)        this.PreviewButton('expanded', 960)      }      .width('100%')    }    .width('100%')  }​  @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 DataCard(item: DashboardCard) {    Column({ space: this.isCompact() ? 10 : 12 }) {      Row() {        this.StatusPill(item.status)​        Blank()​        if (this.selectedId === item.id) {          Text('当前')            .fontSize(12)            .fontColor('#2F8F83')        }      }      .width('100%')​      Text(item.title)        .fontSize(this.getCardTitleSize())        .fontWeight(FontWeight.Medium)        .fontColor('#111827')        .maxLines(1)        .textOverflow({ overflow: TextOverflow.Ellipsis })​      Text(item.value)        .fontSize(this.getCardValueSize())        .fontWeight(FontWeight.Bold)        .fontColor('#111827')​      if (!this.isCompact()) {        Text(item.desc)          .fontSize(13)          .fontColor('#6B7280')          .lineHeight(19)          .maxLines(this.isExpanded() ? 2 : 1)          .textOverflow({ overflow: TextOverflow.Ellipsis })      }    }    .width('100%')    .padding(this.isExpanded() ? 18 : 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 CardGrid() {    Scroll() {      Grid() {        ForEach(this.cards, (item: DashboardCard) => {          GridItem() {            this.DataCard(item)          }        }, (item: DashboardCard) => item.id.toString())      }      .columnsTemplate(this.getColumnsTemplate())      .columnsGap(12)      .rowsGap(12)      .width('100%')      .padding({ bottom: 16 })    }    .layoutWeight(1)    .width('100%')    .edgeEffect(EdgeEffect.Spring)  }​  @Builder  private OperationPanel() {    Column({ space: 14 }) {      Column({ space: 6 }) {        Text('当前操作')          .fontSize(16)          .fontWeight(FontWeight.Medium)          .fontColor('#111827')​        Text(this.getSelectedCard().title)          .fontSize(15)          .fontColor('#4B5563')          .lineHeight(22)          .maxLines(2)          .textOverflow({ overflow: TextOverflow.Ellipsis })      }      .width('100%')      .alignItems(HorizontalAlign.Start)​      Row({ space: 8 }) {        Button('处理')          .fontSize(14)          .fontColor('#FFFFFF')          .height(40)          .layoutWeight(1)          .backgroundColor('#2F8F83')          .borderRadius(20)​        Button('详情')          .fontSize(14)          .fontColor('#2F8F83')          .height(40)          .layoutWeight(1)          .backgroundColor('#E6F4F1')          .borderRadius(20)      }      .width('100%')​      if (this.isExpanded()) {        Column({ space: 8 }) {          Text('断点策略')            .fontSize(15)            .fontWeight(FontWeight.Medium)            .fontColor('#111827')​          Text('expanded 状态下,操作区固定在右侧,卡片区域继续保持双列。这样可以减少宽屏下的视线跳转。')            .fontSize(14)            .fontColor('#6B7280')            .lineHeight(22)        }        .width('100%')        .padding(14)        .backgroundColor('#F9FAFB')        .borderRadius(16)      }    }    .width('100%')    .padding(16)    .backgroundColor('#FFFFFF')    .borderRadius(22)    .shadow({      radius: 10,      color: '#10000000',      offsetX: 0,      offsetY: 4    })  }​  @Builder  private CompactBottomAction() {    Column() {      Row({ space: 10 }) {        Button('处理 ' + this.getSelectedCard().title)          .fontSize(14)          .fontColor('#FFFFFF')          .height(44)          .layoutWeight(1)          .backgroundColor('#2F8F83')          .borderRadius(22)      }      .width('100%')      .padding({        left: this.getPagePadding(),        right: this.getPagePadding(),        top: 10,        bottom: 12      })      .backgroundColor('#F6F7F9')    }    .width('100%')  }​  build() {    Stack({ alignContent: Alignment.Bottom }) {      Column() {        Column({ space: 16 }) {          this.HeaderPanel()​          if (this.isExpanded()) {            Row({ space: 18 }) {              Column() {                this.CardGrid()              }              .layoutWeight(1)              .height('100%')​              Column() {                this.OperationPanel()              }              .width(280)              .height('100%')            }            .width('100%')            .layoutWeight(1)          } else {            Column({ space: 14 }) {              if (this.isMedium()) {                this.OperationPanel()              }​              this.CardGrid()            }            .width('100%')            .layoutWeight(1)          }        }        .width(this.getContentWidth())        .height('100%')        .padding({          left: this.getPagePadding(),          right: this.getPagePadding(),          top: 18,          bottom: this.isCompact() ? 74 : 16        })      }      .width('100%')      .height('100%')      .alignItems(HorizontalAlign.Center)​      if (this.isCompact()) {        this.CompactBottomAction()      }    }    .width('100%')    .height('100%')    .backgroundColor('#F6F7F9')    .onAreaChange((_: Area, newValue: Area) => {      const width = Number(newValue.width);      if (!Number.isNaN(width) && width > 0) {        this.pageWidth = width;      }    })  }}

关键实现点和运行结果

页面运行后,默认处于自动状态。此时断点完全由真实窗口宽度决定。Pura X Max 模拟器上,折叠态通常能看到 compact,展开态通常能看到 expanded。

为了观察三档效果,示例顶部增加了 compact、medium、expanded 三个演示按钮。点击 compact 后,内容容器会限制为 420vp,页面变成单列卡片,描述信息收起,主操作固定在底部。这个状态适合外屏、窄窗口或小尺寸悬浮窗。

点击 medium 后,内容容器限制为 720vp。页面卡片变成两列,描述信息开始出现,操作区移动到内容上方。这个状态在 Pura X Max 普通开合里不一定稳定出现,但在分屏、自由窗口、平板窄窗口里很常见,所以代码里保留它是有意义的。

点击 expanded 后,内容容器限制为 960vp。卡片区继续保持双列,操作区固定到右侧。这个状态接近展开态或更宽窗口,内容区和操作区分开之后,页面不需要把所有按钮都堆到列表下方。

核心判断都集中在 getLayoutMode()

private getLayoutMode(): string {  const width = this.getEffectiveWidth();​  if (width >= this.expandedWidth) {    return 'expanded';  }​  if (width >= this.mediumWidth) {    return 'medium';  }​  return 'compact';}

真实项目里可以把 previewWidth 相关逻辑删掉,只保留 pageWidth。示例里加它,是为了让三种状态能在同一台模拟器上被直观看到。

private getEffectiveWidth(): number {  if (this.previewWidth > 0) {    return this.previewWidth;  }​  return this.pageWidth;}

迁移到项目中时,可以进一步把断点配置抽到统一文件里。

const BreakpointMedium = 600;const BreakpointExpanded = 900;

首页、列表页、详情页和设置页可以共用同一套断点,但每个页面在不同断点下做不同变化。首页可能调整卡片列数,详情页可能变成左右分栏,设置页可能从单列切换成左侧分类、右侧配置项。

断点不宜切得太碎。普通业务页面用 compact、medium、expanded 三档已经够用。状态越多,页面分支越多,后续维护成本也会变高。

总结

Pura X Max 的页面适配,适合从窗口宽度断点开始。

compact 负责窄窗口,medium 负责中间窗口,expanded 负责宽窗口。Pura X Max 模拟器最容易稳定验证 compact 和 expanded,medium 更适合作为兼容分屏、自由窗口、平板窄窗口和 2in1 的预留状态。

这样写的好处是页面不会被设备名称绑死。设备形态可以变化,窗口模式可以变化,业务页面也可以继续扩展。只要断点函数稳定,卡片列数、字号、边距和操作区位置都能围绕同一套规则调整。对折叠屏适配来说,窗口宽度比机型判断更适合作为布局入口。


©本站发布的所有内容,包括但不限于文字、图片、音频、视频、图表、标志、标识、广告、商标、商号、域名、软件、程序等,除特别标明外,均来源于网络或用户投稿,版权归原作者或原出处所有。我们致力于保护原作者版权,若涉及版权问题,请及时联系我们进行处理。
分类
HarmonyOS
头像

小雨同学

产品总监、独立开发者社群主理人、资深全栈工程师,HarmonyOS应用开发者高级认证,PMP认证,CSDN博客专家,鸿蒙极客,Trae Fellow,阿里云社区专家博主、51CTO 博客专家、OpenTiny 优秀布道师、科大讯飞荣誉讲师。

4

帖子

0

提问

24

粉丝

关注
热门推荐
地址:北京市朝阳区北三环东路三元桥曙光西里甲1号第三置业A座1508室 商务内容合作QQ:2291221 电话:13391790444或(010)62178877
版权所有:电脑商情信息服务集团 北京赢邦策略咨询有限责任公司
声明:本媒体部分图片、文章来源于网络,版权归原作者所有,我司致力于保护作者版权,如有侵权,请与我司联系删除

京ICP备:2022009079号-2

京公网安备:11010502051901号

ICP证:京B2-20230255