前言

Pura X Max 展开态最容易出现的一类问题,是内容区域被直接撑满整屏。

列表页还能通过双列、三列解决一部分空间问题,阅读页、表单页、详情页就没这么简单了。标题、正文、输入框、说明文字一旦横向拉得太宽,用户读起来会很累。尤其是详情说明、识别结果、长文本摘要这类内容,宽度越大,视线移动越长,阅读效率反而下降。

Pura X Max 外屏为 5.4 英寸,内屏为 7.7 英寸,外屏分辨率为 1848 × 1264,内屏分辨率为 2584 × 1828,系统版本为 HarmonyOS 6.1。展开态带来的横向空间很明显,但这块空间不一定都要交给正文内容。

HarmonyOS 多设备适配里,页面元素会随着窗口尺寸变化调整布局,窗口尺寸变化较大时,也需要通过断点这类响应式能力调整页面结构。我在处理这类页面时,会同时控制两件事:页面边距和内容最大宽度。外屏保持紧凑,展开态让内容居中,并限制阅读区域宽度。

问题出在内容跟着屏幕无限变宽

很多详情页一开始会写成这样:

Column() {
  // 标题
  // 正文
  // 表单
}
.width('100%')
.padding(16)

这在手机上没有问题。屏幕窄,width('100%') 加上 16vp 边距后,正文宽度仍然处在一个比较舒服的范围里。

到了 Pura X Max 展开态,同样的写法会把内容拉得很宽。正文每一行变长,表单输入框横向拉开,卡片也贴近屏幕两侧。页面看起来确实“占满了”,但可读性下降了。

这类页面和卡片工作台不同。工作台可以用多列提高信息密度,阅读页和表单页更需要控制宽度。大屏上应该给内容留出稳定的阅读范围,再把剩余空间交给留白、辅助信息、侧边操作或背景层次。

我通常会把页面拆成三层。

第一层是屏幕容器,占满窗口。

第二层是内容容器,根据窗口宽度设置边距和最大宽度。

第三层是真正的业务卡片、表单和正文。

这样处理后,页面在窄屏下仍然紧凑,展开态不会无限拉伸。

边距和最大宽度一起调整

单独调整 padding 不够。比如展开态把左右边距从 16vp 改成 32vp,内容仍然可能很宽。

单独设置最大宽度也不够。外屏下如果直接套一个固定最大宽度,页面会显得生硬,而且边缘空间不一定合适。

更稳的做法是让断点同时影响两件事。

compact 下,左右边距 16vp,内容宽度跟随窗口。

medium 下,左右边距 20vp,内容仍然尽量铺满可用区域。

expanded 下,左右边距 24vp 起步,同时把内容最大宽度限制在 760vp 左右。

判断逻辑可以保持简单:

private getLayoutMode(): string {
  const width = this.getEffectiveWidth();

  if (width >= this.expandedWidth) {
    return 'expanded';
  }

  if (width >= this.mediumWidth) {
    return 'medium';
  }

  return 'compact';
}

内容宽度单独收束到一个函数里。

private getContentMaxWidth(): number {
  if (this.isExpanded()) {
    return 760;
  }

  return this.getEffectiveWidth();
}

这里的 760vp 不是硬标准。偏阅读的页面可以控制在 680vp 到 780vp;偏表单的页面可以根据字段长度放宽到 840vp;如果右侧还要放辅助面板,主内容区可以继续收窄。

把宽屏阅读区域跑起来

下面这个页面模拟了一个材料详情页,包含标题、摘要、正文和表单区域。页面顶部提供了“铺满”和“受控”两种模式,方便直接对比宽屏下的阅读体验。选择“受控”后,展开态内容会居中,并限制最大宽度;选择“铺满”后,内容会跟随窗口横向撑开。

页面可以放到 entry/src/main/ets/pages/Index.ets 运行。Pura X Max 适配调试可以使用 DevEco Studio 6.1.0,并安装对应模拟器验证外屏和展开态表现。

interface DetailBlock {
  id: number;
  title: string;
  content: string;
}

@Entry
@Component
struct Index {
  @State private pageWidth: number = 0;
  @State private previewWidth: number = 0;
  @State private controlledWidth: boolean = true;

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

  private readonly blocks: DetailBlock[] = [
    {
      id: 1,
      title: '整理结果',
      content: '识别结果显示,这是一条社区物业缴费提醒。主要信息包括缴费周期、缴费金额、截止时间、办理地点和联系方式。外屏下适合快速浏览标题和状态,展开态下适合查看完整说明。'
    },
    {
      id: 2,
      title: '处理建议',
      content: '这类提醒更适合保存为待办事项,并在截止日期前一天触发提醒。如果用户已经处理过,可以把记录标记为已完成,避免后续重复提醒。'
    },
    {
      id: 3,
      title: '适配观察',
      content: '当内容区域在展开态下无限拉宽时,正文每一行会变得很长。阅读长文本时,眼睛需要横向移动更远,页面虽然占满了屏幕,但阅读体验会变差。'
    }
  ];

  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 getPagePadding(): number {
    if (this.isExpanded()) {
      return 24;
    }

    if (this.isMedium()) {
      return 20;
    }

    return 16;
  }

  private getTitleSize(): number {
    if (this.isExpanded()) {
      return 30;
    }

    if (this.isMedium()) {
      return 26;
    }

    return 23;
  }

  private getModeText(): string {
    if (this.isExpanded()) {
      return 'expanded · 宽窗口';
    }

    if (this.isMedium()) {
      return 'medium · 中等窗口';
    }

    return 'compact · 窄窗口';
  }

  private getModeDesc(): string {
    if (!this.controlledWidth) {
      return '当前内容区域跟随窗口铺满,宽屏下正文行长会明显增加。';
    }

    if (this.isExpanded()) {
      return '当前内容区域已限制最大宽度,展开态下正文居中显示,左右保留舒适留白。';
    }

    return '当前窗口宽度有限,内容区域保持紧凑显示。';
  }

  private getContentWidth(): Length {
    if (!this.controlledWidth) {
      return '100%';
    }

    if (this.isExpanded()) {
      return this.maxReadableWidth;
    }

    return '100%';
  }

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

    return '100%';
  }

  private getBodyLineHeight(): number {
    if (this.isExpanded()) {
      return 25;
    }

    return 23;
  }

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

  private setWidthMode(controlled: boolean) {
    this.controlledWidth = controlled;
  }

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

      Row({ space: 8 }) {
        this.WidthModeButton('受控宽度', true)
        this.WidthModeButton('铺满宽度', false)
      }
      .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 WidthModeButton(text: string, controlled: boolean) {
    Text(text)
      .fontSize(12)
      .fontColor(this.controlledWidth === controlled ? '#FFFFFF' : '#7C3AED')
      .textAlign(TextAlign.Center)
      .padding({ left: 10, right: 10, top: 7, bottom: 7 })
      .backgroundColor(this.controlledWidth === controlled ? '#7C3AED' : '#F1EAFE')
      .borderRadius(999)
      .onClick(() => {
        this.setWidthMode(controlled);
      })
  }

  @Builder
  private SummaryCard() {
    Column({ space: 12 }) {
      Row() {
        Text('材料详情')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#111827')

        Blank()

        Text('待处理')
          .fontSize(12)
          .fontColor('#B25E00')
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor('#FFF4E5')
          .borderRadius(999)
      }
      .width('100%')

      Text('社区物业缴费提醒')
        .fontSize(this.isExpanded() ? 26 : 22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#111827')
        .lineHeight(this.isExpanded() ? 34 : 29)

      Text('这是一条来自拍照整理的通知类材料。页面用于观察宽屏下正文区域和表单区域的宽度变化。')
        .fontSize(15)
        .fontColor('#4B5563')
        .lineHeight(23)

      Row({ space: 8 }) {
        this.MetaPill('拍照整理')
        this.MetaPill('通知')
        this.MetaPill('09:20')
      }
      .width('100%')
    }
    .width('100%')
    .padding(this.isExpanded() ? 20 : 16)
    .backgroundColor('#FFFFFF')
    .borderRadius(22)
    .shadow({
      radius: 10,
      color: '#10000000',
      offsetX: 0,
      offsetY: 4
    })
  }

  @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 TextBlock(block: DetailBlock) {
    Column({ space: 8 }) {
      Text(block.title)
        .fontSize(17)
        .fontWeight(FontWeight.Medium)
        .fontColor('#111827')

      Text(block.content)
        .fontSize(15)
        .fontColor('#4B5563')
        .lineHeight(this.getBodyLineHeight())
    }
    .width('100%')
    .padding(this.isExpanded() ? 20 : 16)
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .border({
      width: 1,
      color: '#E5E7EB'
    })
  }

  @Builder
  private FormCard() {
    Column({ space: 14 }) {
      Text('处理表单')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#111827')

      Column({ space: 8 }) {
        Text('提醒标题')
          .fontSize(13)
          .fontColor('#6B7280')

        TextInput({ text: '社区物业缴费提醒' })
          .height(42)
          .fontSize(15)
          .backgroundColor('#F9FAFB')
          .borderRadius(14)
      }
      .width('100%')

      Column({ space: 8 }) {
        Text('处理备注')
          .fontSize(13)
          .fontColor('#6B7280')

        TextArea({ text: '缴费前一天提醒,并确认是否已经完成支付。' })
          .height(this.isExpanded() ? 88 : 76)
          .fontSize(15)
          .backgroundColor('#F9FAFB')
          .borderRadius(14)
      }
      .width('100%')

      Button('保存处理结果')
        .height(44)
        .fontSize(15)
        .fontColor('#FFFFFF')
        .width('100%')
        .backgroundColor('#2F8F83')
        .borderRadius(22)
    }
    .width('100%')
    .padding(this.isExpanded() ? 20 : 16)
    .backgroundColor('#FFFFFF')
    .borderRadius(22)
    .shadow({
      radius: 10,
      color: '#10000000',
      offsetX: 0,
      offsetY: 4
    })
  }

  @Builder
  private ContentArea() {
    Scroll() {
      Column({ space: 14 }) {
        this.SummaryCard()

        ForEach(this.blocks, (block: DetailBlock) => {
          this.TextBlock(block)
        }, (block: DetailBlock) => block.id.toString())

        this.FormCard()
      }
      .width('100%')
      .padding({ bottom: 24 })
    }
    .layoutWeight(1)
    .width('100%')
    .edgeEffect(EdgeEffect.Spring)
  }

  build() {
    Column() {
      Column({ space: 16 }) {
        this.HeaderPanel()

        Column() {
          this.ContentArea()
        }
        .width(this.getContentWidth())
        .layoutWeight(1)
      }
      .width(this.getPreviewContainerWidth())
      .height('100%')
      .padding({
        left: this.getPagePadding(),
        right: this.getPagePadding(),
        top: 18,
        bottom: 16
      })
      .alignItems(HorizontalAlign.Center)
    }
    .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;
      }
    })
  }
}

关键实现点和运行结果

页面运行后,先选择展开态,再分别点击铺满宽度和受控宽度。铺满宽度下,正文卡片和表单会横向拉满整个内容区域,输入框也会变得很长。受控宽度下,内容区域会居中,宽度限制在 760vp,左右留白明显增加,阅读线条更短。

外屏状态下,页面仍然保持紧凑。左右边距是 16vp,内容宽度跟随窗口,不会因为最大宽度设置造成额外留白。中宽状态下,页面边距变成 20vp,内容仍然尽量利用当前空间。展开态下,页面边距变成 24vp,内容最大宽度开始生效。

这里最重要的是 getContentWidth()

private getContentWidth(): Length {
  if (!this.controlledWidth) {
    return '100%';
  }

  if (this.isExpanded()) {
    return this.maxReadableWidth;
  }

  return '100%';
}

它把宽屏下的阅读区域限制住。展开态不是让正文撑满屏幕,而是让正文保持在更适合阅读的宽度里。

页面边距由 getPagePadding() 控制。

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

  if (this.isMedium()) {
    return 20;
  }

  return 16;
}

最大宽度和边距配合起来,页面在三种状态下会有不同表现:窄屏优先利用空间,中宽增加呼吸感,宽屏控制阅读宽度。

真实项目里,演示按钮可以删掉,只保留 pageWidth 和断点判断。详情页、表单页、文章页、识别结果页都适合用这种方式处理。尤其是 OCR 结果、会议纪要、合同条款、长说明文本,如果不限制宽度,展开态很容易变成“看起来很大,读起来很累”。

这个方案也有边界。统计卡片、图片瀑布流、看板入口这类页面,不一定需要限制最大内容宽度,它们更适合用多列布局提高信息密度。最大内容宽度更适合文本、表单和详情阅读场景。

总结

Pura X Max 展开态的页面适配,不能只关注内容有没有填满屏幕。

阅读页、详情页和表单页更需要控制内容宽度。外屏保持紧凑,展开态增加边距并限制最大内容宽度,页面会更稳定,也更适合长文本阅读和表单填写。

实际开发中,可以把页面分成屏幕容器、内容容器和业务内容三层。屏幕容器负责占满窗口,内容容器负责边距和最大宽度,业务内容只关心自身结构。这样写出来的页面,在外屏、展开态、分屏和 2in1 上都更容易保持一致的阅读体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐