前言

我是在调一个材料预览页的时候注意到这个问题的。窗口切到 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
@Component
struct 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;
      }
    })
  }
}
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐