鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 10:横屏下页面从上下结构改为左右结构 原创
头像 小雨同学 2026-05-22 09:24:20    发布
1 浏览 0 点赞 0 收藏

前言

我是在调一个材料预览页的时候注意到这个问题的。窗口切到 920 × 520vp 后,页面仍然按竖屏时的顺序往下排,上面是一块内容预览区,下面是识别结果和确认按钮。刚看第一眼,页面并没有出现明显错位,按钮也能点击,但预览区的高度已经被压得很低,原本应该优先呈现的内容只剩下一段不大的区域。

这类页面在 Pura X Max 展开态横屏里很常见。外屏下,上下结构通常可以接受,因为屏幕本来就是窄长形态,用户从上往下看内容,再到下方处理结果。到了展开态横屏,窗口宽度增加,高度减少,如果页面还继续上下堆叠,预览区会先被压缩,识别结果和操作按钮也会继续占在下面。横向空间已经出现,但页面区域之间的关系没有跟着调整。

我这次处理的页面类型主要包括:

  • 图片预览页
  • 拍照确认页
  • OCR 识别结果页
  • 材料整理结果页
  • 详情确认页
  • 带预览区和操作区的编辑页

这些页面有一个共同点:用户需要对照两块内容。左边或上面看原始内容,另一块区域确认识别结果、编辑结果或处理动作。窗口变宽以后,如果还把这两块内容上下放,用户就要在预览区和结果区之间反复移动视线。这个问题不是样式细节,调整几处间距或者字号解决不了。

这次适配基于下面这个环境展开:

  • 设备形态:Pura X Max 阔折叠设备
  • 系统版本:HarmonyOS 6.1
  • 外屏尺寸:5.4 英寸
  • 内屏尺寸:7.7 英寸
  • 外屏分辨率:1848 × 1264
  • 内屏分辨率:2584 × 1828
  • 技术方向:窗口宽高比例判断、Row / Column 切换、预览区和操作区重排

我没有直接从设备方向入手。Pura X Max 可以完整展开,也可能处在分屏窗口里。设备处在横向状态时,应用窗口不一定有足够宽度承载左右结构。页面能不能把预览区和操作区放到一行里,最终还是要看当前窗口给了多少宽度、高度,以及右侧操作区出现后,左侧预览还能不能保住足够的展示面积。

一、旧结构在横向窗口里哪里不对

1.1 竖屏里这套写法没有问题

很多结果页最开始都是竖屏结构。比如拍照整理后的确认页,常见排布是上面放原图、文档或内容预览,中间放识别结果,下面放确认、重新识别、保存等操作按钮。这个结构在手机竖屏里能成立,主要原因是屏幕高度够,用户可以按顺序从上往下看,最后在底部完成处理。

用 ArkUI 写起来也很直接,一个 Column 就能把页面组织出来。

Column({ space: 14 }) {  this.PreviewPanel()  this.ActionPanel()}

我一开始也会这么写。外屏下这套结构没有太大问题,内容和操作都按纵向展开,用户读完内容后继续看结果,最后点按钮。它的开发成本也低,页面状态不用拆来拆去,后续维护比较省事。

麻烦出现在横向宽窗口里。窗口宽度增加,高度减少后,原来的上下结构继续存在,预览区就会被挤到一个很尴尬的高度。这个时候继续调卡片内边距、圆角、标题字号,最多只能改善一点局部观感,页面真正的问题仍然在区域关系上。

1.2 我在截图里看到的是预览区变矮

我把演示窗口切到 920 × 520vp 后,最先注意到的是预览卡片的高度不够。原本应该承载主要内容的区域,被上下结构压成了一块偏矮的卡片。下面的识别结果和操作按钮还按竖屏时的方式排列,占着底部空间。

这个状态下,用户如果只是点一下保存,问题还不算大;但如果需要对照原文和识别结果,就会变得别扭。用户要先看上面的预览,再到下面确认结果,如果发现结果和原文不一致,还得回到上面重新看。这种来回切换在竖屏里还可以接受,在横向宽窗口里就显得浪费空间。

我在这类页面里通常会先看四个点:

  • 预览区是否还能承担主内容
  • 识别结果是否需要和预览内容对照
  • 操作按钮是否继续压在底部
  • 当前窗口是否足够放下左右两块区域

只要预览和结果存在对照关系,横屏下就值得考虑左右结构。左侧保留原内容,右侧放识别结果和操作按钮,用户在同一段视线范围里完成确认,不需要在上下两块区域之间来回移动。

二、我没有直接读设备方向

2.1 设备方向只能提供背景

横屏适配很容易从设备方向入手。设备处在横向状态,就进入横屏布局;设备回到竖向状态,就切回竖屏布局。这个写法在单一手机页面里还能接受,放到 Pura X Max 这种窗口状态更多的设备上,我会更谨慎。

Pura X Max 不只有完整外屏和完整内屏。应用可能在展开态全屏,也可能只占分屏的一半,还可能以自由窗口形式运行。设备方向给出的只是一个背景信息,页面实际可用空间仍然要看应用窗口本身。

我在分屏尺寸里试过类似页面。设备处在横向状态,应用窗口却没有足够宽度。右侧操作区刚出现,左侧预览马上被挤得很窄。这个时候如果继续按设备方向切布局,页面看起来进入了横屏结构,实际上预览内容比原来更难看清。

所以我把判断放到了窗口宽高比例上。这个选择不是为了多写一个函数,而是为了处理完整展开、分屏、自由窗口之间的中间状态。对结果页来说,能不能左右排,得看左侧预览和右侧操作能不能同时放下。

2.2 宽度和比例都要留余量

示例里的判断是这样写的:

private isLandscapeLayout(): boolean {  const width = this.getEffectiveWidth();  const height = this.getEffectiveHeight();​  return width >= 720 && width > height * 1.12;}

这里没有只用一个宽度阈值来决定布局,而是把窗口宽度和宽高比例放在一起判断。窗口至少要有 720vp 的宽度,同时还要明显偏横向,这样右侧操作区出现以后,左侧预览区才不至于被压得太窄。

我在这个地方会偏保守一点。窗口刚刚超过某个宽度时,我不会马上切到左右结构,因为右侧操作区一旦出现,左侧预览可能只剩下一块很窄的区域。对预览页来说,主内容区域被挤掉,比继续使用上下结构更糟。

迁回真实项目时,我也会保留这种判断方式。设备形态只是背景,真正决定布局的还是当前窗口能给页面多少空间。这个判断以后还可以继续细化,比如把右侧操作区宽度、页面左右 padding、预览区最小宽度都算进去,但示例里先用宽度和比例两个条件,已经能避开大部分误切状态。

三、只改外层布局

3.1 竖屏继续上下排

竖屏下,我会继续保留 Column。竖屏的内容路径本来就是从上到下,预览区在上方,操作区在下方,用户扫完内容后继续处理识别结果。这个结构适合外屏、普通竖屏和窄窗口,没有必要为了横屏适配把所有状态都改成左右分栏。

Column({ space: 14 }) {  Column() {    this.PreviewPanel()  }  .height(360)  .width('100%')​  Column() {    this.ActionPanel()  }  .layoutWeight(1)  .width('100%')}

这里给预览区一个固定高度,操作区占剩余空间。外屏下这样排,不会把页面拆得太碎,也不会让操作区变成很窄的一列。尤其是用户单手操作时,上下结构比左右分栏更适合窄窗口。

这个地方我会保留一点重复判断。横屏适配不是把所有页面都切成左右结构,真正要处理的是宽窗口下预览区和操作区的关系。窄窗口里硬拆左右,预览和操作都会变窄,这种改法看起来像大屏适配,实际会让两个区域都不好用。

3.2 横屏再把操作区放到右侧

横屏下,结构换成 Row

Row({ space: 16 }) {  Column() {    this.PreviewPanel()  }  .layoutWeight(1)  .height('100%')​  Column() {    this.ActionPanel()  }  .width(330)  .height('100%')}

左侧预览区使用 layoutWeight(1),占主要空间。右侧操作区固定为 330vp,用来放识别结果和按钮。这个宽度不是固定标准,只是这个示例里比较合适的取值。

如果是图片预览页,右侧只有几个按钮,300vp 可能已经够用。如果右侧有字段、按钮、说明文本,可以放到 340vp 到 380vp。再往里继续塞长文本说明、完整编辑、历史记录,右侧区域就会挤占预览区,页面又会回到另一个问题上。

所以我会把右侧区域当成轻量处理区。它放识别结果、确认按钮、重新识别入口就够了。完整编辑、历史记录、长文本说明继续放到详情页或更大的面板里。左侧预览区不能被牺牲,这是这个布局能成立的前提。

3.3 业务状态不要拆开

这个改造里,业务数据不需要拆成两套。

预览还是同一个预览,识别结果还是同一组字段,确认按钮也还是原来的确认按钮。变化只发生在外层容器方向上。

我会尽量把变化控制在 UI 层。窗口比例变了,容器从 Column 换成 Row;业务数据、确认次数、识别结果都留在同一个页面状态里。真实项目里,这一点能省很多后续维护成本。为了一个横屏状态拆出两套数据处理逻辑,后面埋点、权限、错误提示、状态回填都会跟着变复杂。

页面布局可以切换,业务状态最好不要跟着拆散。这个判断在折叠屏适配里很常见,尤其是列表详情、预览确认、编辑保存这类页面,布局变了,用户正在处理的那条记录仍然应该保持不变。

四、跑一下两个状态

横屏布局这类问题,截图比文字更容易说明。我一般会先截一张竖屏状态,再截一张横屏状态,然后把两张图放在一起看。这样能直观看到预览区从上方移动到左侧,识别结果从下方移动到右侧,整个页面关系发生了变化。

竖屏状态下,页面按上下结构显示。上方是内容预览,下方是识别结果和操作按钮。这个状态适合外屏、窄窗口和普通竖屏场景。

横屏状态下,中间演示区域变成横向宽窗口。页面会从 Column 切换为 Row。左侧显示内容预览,右侧显示识别结果和确认按钮。

五、迁回项目时怎么处理

5.1 演示按钮要删掉

示例里有 previewWidthpreviewHeight,它们只用于演示。

真实项目里不需要让用户点击“竖屏”“横屏”。页面应该直接根据真实窗口宽高变化切换布局。

示例里的写法是:

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

迁回项目时可以简化成:

private getEffectiveWidth(): number {  return this.pageWidth;}

高度同理。

5.2 左右结构要挑页面

这个方案我会优先放在预览页、结果页、详情确认页里。这些页面天然有两个区域,一个是主内容,一个是辅助结果或操作。横屏时把它们左右并排,用户可以同时看到上下文和处理结果。

普通设置页、短列表页、单字段表单页就不一定要这样做。它们在横屏下可能只需要控制最大宽度、边距或信息密度。如果页面没有“对照关系”,强行左右分栏会显得多余。

这里我会再强调一下自己的取舍。横屏左右结构只适合有对照关系的页面。预览和结果、列表和详情、表单和说明,这些结构放在横向窗口里才有意义。没有这种关系的页面,继续控制内容宽度和边距,通常会比硬拆分栏更合适。

5.3 右侧区域只放处理内容

右侧操作区宽度也要控制。

示例里用了 330vp

.width(330)

这个宽度适合放识别结果、少量字段和操作按钮。如果继续往里放长文本说明、完整编辑、历史记录,左侧预览会先被挤掉。

真实项目里,我一般会把右侧区域控制成轻量处理区。它可以放识别结果、主按钮、次按钮、少量说明。完整编辑、长文本、复杂表单还是进入独立页面或更大的面板。

我这里再重复一次自己的取舍。横屏切左右结构,前提是左侧预览不能被牺牲。如果右侧内容继续变多,我会先拆右侧内容,而不是继续压左侧预览区。

总结

Pura X Max 横屏适配,不能只看设备有没有旋转。预览页、结果页这类页面,要看主内容和操作区能不能在当前窗口里形成对照关系。竖屏下继续上下排列,横屏下切成左右结构,用户可以一边看原内容,一边确认识别结果。

我处理这类页面时,会把窗口宽高比例作为入口。宽度和比例都够,再切左右结构;空间不够,继续上下结构。这个判断比单纯读取设备方向更适合分屏、自由窗口和折叠屏展开态这些场景。

附:完整代码

interface ResultItem {  id: number;  label: string;  value: string;}​@Entry@Componentstruct Index {  // 页面真实宽度,由 onAreaChange 写入  @State private pageWidth: number = 0;​  // 页面真实高度,由 onAreaChange 写入  @State private pageHeight: number = 0;​  // 演示宽度,只用于在同一个模拟器里观察竖屏和横屏差异  @State private previewWidth: number = 0;​  // 演示高度,只用于配合 previewWidth 模拟不同宽高比例  @State private previewHeight: number = 0;​  // 模拟确认次数,用来观察操作区状态是否保留  @State private confirmCount: number = 0;​  private readonly resultItems: ResultItem[] = [    {      id: 1,      label: '材料类型',      value: '社区物业缴费提醒'    },    {      id: 2,      label: '截止日期',      value: '2026 年 5 月 28 日'    },    {      id: 3,      label: '处理建议',      value: '添加缴费提醒,并在截止日前一天通知'    },    {      id: 4,      label: '来源方式',      value: '拍照整理'    }  ];​  // Demo 中优先使用演示宽度,真实项目里可以直接返回 pageWidth  private getEffectiveWidth(): number {    if (this.previewWidth > 0) {      return this.previewWidth;    }​    return this.pageWidth;  }​  // Demo 中优先使用演示高度,真实项目里可以直接返回 pageHeight  private getEffectiveHeight(): number {    if (this.previewHeight > 0) {      return this.previewHeight;    }​    return this.pageHeight;  }​  // 用窗口宽高比例判断布局方向,处理分屏和自由窗口里的中间尺寸  private isLandscapeLayout(): boolean {    const width = this.getEffectiveWidth();    const height = this.getEffectiveHeight();​    return width >= 720 && width > height * 1.12;  }​  private getContentWidth(): Length {    if (this.previewWidth > 0) {      return this.previewWidth;    }​    return '100%';  }​  private getContentHeight(): Length {    if (this.previewHeight > 0) {      return this.previewHeight;    }​    return '100%';  }​  private getPagePadding(): number {    return this.isLandscapeLayout() ? 20 : 16;  }​  private getTitleSize(): number {    return this.isLandscapeLayout() ? 26 : 23;  }​  private getModeText(): string {    return this.isLandscapeLayout() ? 'landscape · 左右结构' : 'portrait · 上下结构';  }​  private getModeDesc(): string {    if (this.isLandscapeLayout()) {      return '当前窗口采用横向布局,预览区放左侧,操作区放右侧。';    }​    return '当前窗口采用纵向布局,预览区在上方,操作区在下方。';  }​  private setPreview(width: number, height: number) {    this.previewWidth = width;    this.previewHeight = height;  }​  private confirm() {    this.confirmCount += 1;  }​  @Builder  private PreviewButton(text: string, width: number, height: number) {    Text(text)      .fontSize(12)      .fontColor(this.previewWidth === width && this.previewHeight === height ? '#FFFFFF' : '#2F8F83')      .textAlign(TextAlign.Center)      .padding({ left: 10, right: 10, top: 7, bottom: 7 })      .backgroundColor(this.previewWidth === width && this.previewHeight === height ? '#2F8F83' : '#E6F4F1')      .borderRadius(999)      .onClick(() => {        this.setPreview(width, height);      })  }​  @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() + ' × ' + Math.round(this.pageHeight).toString())          .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() + ' × ' + Math.round(this.getEffectiveHeight()).toString() + 'vp。' + this.getModeDesc())        .fontSize(14)        .fontColor('#6B7280')        .lineHeight(21)        .maxLines(2)        .textOverflow({ overflow: TextOverflow.Ellipsis })​      Row({ space: 8 }) {        this.PreviewButton('自动', 0, 0)        this.PreviewButton('竖屏', 430, 760)        this.PreviewButton('横屏', 920, 520)      }      .width('100%')    }    .width('100%')  }​  @Builder  private StatusPill(text: string) {    Text(text)      .fontSize(12)      .fontColor('#B25E00')      .padding({ left: 8, right: 8, top: 4, bottom: 4 })      .backgroundColor('#FFF4E5')      .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 PreviewPanel() {    Column({ space: 12 }) {      Row() {        Text('内容预览')          .fontSize(18)          .fontWeight(FontWeight.Bold)          .fontColor('#111827')​        Blank()​        this.StatusPill('待确认')      }      .width('100%')​      Column({ space: 12 }) {        Text('物业缴费提醒')          .fontSize(this.isLandscapeLayout() ? 24 : 22)          .fontWeight(FontWeight.Bold)          .fontColor('#111827')​        Text('尊敬的业主:本期物业服务费缴纳截止日期为 2026 年 5 月 28 日。请在截止日期前完成缴费,避免影响后续服务办理。')          .fontSize(15)          .fontColor('#4B5563')          .lineHeight(24)​        Column({ space: 8 }) {          this.PreviewLine('缴费周期', '2026 年 4 月 - 2026 年 6 月')          this.PreviewLine('应缴金额', '¥ 680.00')          this.PreviewLine('办理地点', '社区物业服务中心一楼')        }        .width('100%')        .padding(14)        .backgroundColor('#F9FAFB')        .borderRadius(16)      }      .width('100%')      .layoutWeight(1)      .padding(this.isLandscapeLayout() ? 18 : 16)      .backgroundColor('#FFFFFF')      .borderRadius(20)      .border({        width: 1,        color: '#E5E7EB'      })    }    .width('100%')    .height('100%')    .padding(this.isLandscapeLayout() ? 18 : 16)    .backgroundColor('#FFFFFF')    .borderRadius(24)    .shadow({      radius: 12,      color: '#12000000',      offsetX: 0,      offsetY: 4    })  }​  @Builder  private PreviewLine(label: string, value: string) {    Row() {      Text(label)        .fontSize(13)        .fontColor('#6B7280')​      Blank()​      Text(value)        .fontSize(13)        .fontColor('#111827')        .fontWeight(FontWeight.Medium)        .maxLines(1)        .textOverflow({ overflow: TextOverflow.Ellipsis })    }    .width('100%')  }​  @Builder  private ResultRow(item: ResultItem) {    Column({ space: 4 }) {      Text(item.label)        .fontSize(12)        .fontColor('#9CA3AF')​      Text(item.value)        .fontSize(14)        .fontColor('#374151')        .lineHeight(20)        .maxLines(2)        .textOverflow({ overflow: TextOverflow.Ellipsis })    }    .width('100%')    .padding(12)    .backgroundColor('#F9FAFB')    .borderRadius(14)  }​  @Builder  private ActionPanel() {    Column({ space: 14 }) {      Row() {        Text('识别结果')          .fontSize(18)          .fontWeight(FontWeight.Bold)          .fontColor('#111827')​        Blank()​        this.MetaPill('拍照整理')      }      .width('100%')​      Text('横屏时,右侧区域用于展示识别结果和操作按钮。用户可以一边看左侧原内容,一边确认右侧整理结果。')        .fontSize(14)        .fontColor('#6B7280')        .lineHeight(22)​      Column({ space: 10 }) {        ForEach(this.resultItems, (item: ResultItem) => {          this.ResultRow(item)        }, (item: ResultItem) => item.id.toString())      }      .width('100%')​      Column({ space: 8 }) {        Text('确认次数:' + this.confirmCount.toString())          .fontSize(13)          .fontColor('#6B7280')​        Button('确认并保存')          .fontSize(15)          .fontColor('#FFFFFF')          .height(44)          .width('100%')          .backgroundColor('#2F8F83')          .borderRadius(22)          .onClick(() => {            this.confirm();          })​        Button('重新识别')          .fontSize(15)          .fontColor('#2F8F83')          .height(44)          .width('100%')          .backgroundColor('#E6F4F1')          .borderRadius(22)      }      .width('100%')​      Blank()    }    .width('100%')    .height('100%')    .padding(this.isLandscapeLayout() ? 18 : 16)    .backgroundColor('#FFFFFF')    .borderRadius(24)    .shadow({      radius: 12,      color: '#12000000',      offsetX: 0,      offsetY: 4    })  }​  @Builder  private MainContent() {    if (this.isLandscapeLayout()) {      Row({ space: 16 }) {        Column() {          this.PreviewPanel()        }        .layoutWeight(1)        .height('100%')​        Column() {          this.ActionPanel()        }        .width(330)        .height('100%')      }      .width('100%')      .height('100%')    } else {      Column({ space: 14 }) {        Column() {          this.PreviewPanel()        }        .height(360)        .width('100%')​        Column() {          this.ActionPanel()        }        .layoutWeight(1)        .width('100%')      }      .width('100%')      .height('100%')    }  }​  build() {    Column() {      Column({ space: 16 }) {        this.HeaderPanel()​        Column() {          this.MainContent()        }        .width('100%')        .layoutWeight(1)      }      .width(this.getContentWidth())      .height(this.getContentHeight())      .padding({        left: this.getPagePadding(),        right: this.getPagePadding(),        top: 18,        bottom: 16      })    }    .width('100%')    .height('100%')    .alignItems(HorizontalAlign.Center)    .justifyContent(FlexAlign.Center)    .backgroundColor('#F6F7F9')    .onAreaChange((_: Area, newValue: Area) => {      const width = Number(newValue.width);      const height = Number(newValue.height);​      if (!Number.isNaN(width) && width > 0) {        this.pageWidth = width;      }​      if (!Number.isNaN(height) && height > 0) {        this.pageHeight = height;      }    })  }}


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

小雨同学

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

10

帖子

0

提问

25

粉丝

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

京ICP备:2022009079号-2

京公网安备:11010502051901号

ICP证:京B2-20230255