HarmonyOS 6 | Pura X Max 鸿蒙原生适配 08:大屏下操作按钮位置重排 原创
头像 小雨同学 2026-05-19 14:10:13    发布
1 浏览 0 点赞 0 收藏

前言

我在适配 Pura X Max 的详情页和编辑页时,发现一个很容易被忽略的问题:页面变宽以后,底部按钮反而变得不好用了。

在普通手机页面里,底部固定按钮几乎是默认选择。用户从上往下看内容,最后在底部点击保存、提交、确认,这个路径很顺。外屏尺寸有限,底部按钮也比较容易触达。可到了 Pura X Max 展开态,同样的设计就开始别扭了。内容在页面上半部分,表单在中间区域,按钮却还停在屏幕底部。用户看完内容以后,要把视线拉到底部,再把手指移动过去,操作路径明显变长。

Pura X Max 外屏为 5.4 英寸,内屏为 7.7 英寸,外屏分辨率为 1848 × 1264,内屏分辨率为 2584 × 1828,系统版本为 HarmonyOS 6.1。这个设备的展开态不是普通手机页面的简单放大,很多页面结构都需要重新看一遍,尤其是详情、编辑、审核、确认这类带主操作的页面。

我现在更倾向于把按钮位置当成页面结构的一部分来处理。外屏下,主操作继续放在底部;展开态下,主操作移动到右侧,让按钮靠近内容、表单和当前状态。这个改动看起来只是按钮换了位置,实际影响的是用户在大屏上的阅读路径和操作距离。

img

一、按钮离内容太远

小屏页面为什么喜欢底部按钮,本质上是因为用户的阅读路径和操作区域都集中在一个窄窗口里。

页面宽度有限时,内容从上往下走。底部固定按钮可以一直露出,用户不用滚到底,也不用到页面里找按钮。很多表单页、详情处理页、订单确认页都会这么做。这个方案在外屏里仍然成立,尤其是保存、提交、确认这类单一主操作,放在底部很稳定。

但展开态的空间关系变了。

内容区域通常在左侧或中上部,表单输入区可能在页面中间,底部固定按钮却横跨整个屏幕。页面越宽,这个按钮越像一条很重的底部栏。用户阅读内容和执行操作之间会被拉开。

这个问题在编辑页里更明显。用户正在处理备注、标题、识别结果,保存按钮还停在最底部。填完以后还要把视线移开,确认按钮位置,再进行点击。这个过程不算复杂,但在大屏上会显得拖沓。

我后来处理这类页面时,会先问一个问题:这个按钮到底应该服务谁?

如果按钮服务的是当前内容,那它应该靠近内容。如果按钮服务的是当前表单,那它应该靠近表单。如果按钮是整个页面的最终确认,在小屏下放底部没问题;到了展开态,放在右侧操作区会更稳定。

鸿蒙现在的窗口状态也比单一手机时代复杂。全屏、分屏、自由窗口都会改变应用可用区域。页面不能只按设备名称判断,还要看当前窗口是否真的适合右侧操作区。窗口模式变化会带来窗口尺寸变化,页面布局也要跟着调整。

二、外屏贴底

外屏下,我还是会保留底部固定按钮。

原因很直接。外屏宽度有限,内容和操作基本都在一条纵向路径上。用户读完内容以后,底部主按钮离手指更近。这个时候如果强行把按钮放到页面右侧,反而会破坏原来的单手操作习惯。

在实现上,外屏可以继续使用 Stack。内容区域在下方预留空间,底部操作条覆盖在页面底部。这里最容易漏的是内容底部留白。如果不留出来,用户滚动到底部时,最后一段内容或输入框会被按钮挡住。

小屏结构大概是这样:

Stack({ alignContent: Alignment.Bottom }) {  // 页面内容区域​  // 底部固定操作条}

底部固定按钮不是问题本身。问题出在没有区分窗口状态。外屏下它是合理的,展开态里继续使用它才会让操作距离变长。

所以我的处理思路是:小屏保留底部操作条,大屏切到右侧操作栏。布局状态变了,业务动作不变。保存仍然是保存,稍后处理仍然是稍后处理,只是它们在不同窗口下出现在更合适的位置。

三、大屏靠右

展开态下,我会把页面拆成两个区域。

左侧是内容区。它负责展示标题、摘要、识别内容、备注输入等主要内容。

右侧是操作区。它负责展示当前状态、主按钮、次要按钮,以及少量和操作相关的信息,比如保存次数、当前记录状态、是否已处理。

这里有一个取舍:右侧操作栏不能太宽。它不是新的详情页,也不是设置面板,只是承接当前页面的操作。一般 280vp 到 320vp 已经够用。如果右侧栏太宽,会反过来挤压内容区,展开态就失去了意义。

我也不会只用一个固定阈值判断是否进入右侧操作栏。之前我遇到过类似问题:窗口宽度看起来已经到达展开态,但右侧栏、间距、外层 padding 加起来以后,内容区实际已经很窄。这个时候强行分栏,页面会被挤得很难看。

更稳的写法是先算能不能放下。

private readonly wideThreshold: number = 860;private readonly actionPanelWidth: number = 300;private readonly minContentWidth: number = 430;private readonly twoColumnGap: number = 16;​private canUseSideAction(): boolean {  const availableWidth = this.getEffectiveWidth() - this.getPagePadding() * 2;  const requiredWidth = this.minContentWidth + this.twoColumnGap + this.actionPanelWidth;  return this.getEffectiveWidth() >= this.wideThreshold && availableWidth >= requiredWidth;}

这个判断比单纯写 width >= 860 更可靠。它不会因为窗口刚刚超过阈值,就马上把页面拆成左右结构。只有当前可用宽度真的能放下内容区、间距和右侧操作栏时,页面才进入大屏布局。

这类细节在折叠屏适配里很重要。很多布局问题不是因为组件不会写,而是因为进入大屏布局的条件太粗。Pura X Max 的展开态确实更宽,但分屏、悬浮窗、模拟器窗口都会让实际可用宽度发生变化。写死阈值很容易在边界状态下翻车。

四、把页面跑起来

我把这个场景压成一个可以直接运行的 Index.ets 页面。示例模拟的是一条材料编辑记录:上方是材料摘要,中间是识别内容和处理备注,小屏下按钮固定在底部,大屏下按钮移动到右侧操作栏。

代码里保留了“自动”“外屏”“展开态”三个按钮,是为了在同一个模拟器里快速观察两种布局。放回真实项目时,可以删掉 previewWidth 和演示按钮,只保留真实窗口宽度判断。

interface DetailInfo {  title: string;  status: string;  source: string;  time: string;  tag: string;  summary: string;  content: string;}​@Entry@Componentstruct Index {  // 当前页面真实宽度,由 onAreaChange 写入  @State private pageWidth: number = 0;​  // 演示宽度,只用于在同一个模拟器里切换外屏和展开态效果  @State private previewWidth: number = 0;​  // 模拟保存次数,方便观察按钮位置变化后状态是否仍然保留  @State private saveCount: number = 0;​  // 模拟用户正在编辑的备注内容  @State private noteText: string = '缴费前一天提醒,并确认是否已经完成支付。';​  // 进入右侧操作栏前,先判断可用空间是否真的放得下  private readonly wideThreshold: number = 860;  private readonly actionPanelWidth: number = 300;  private readonly minContentWidth: number = 430;  private readonly twoColumnGap: number = 16;​  private readonly detail: DetailInfo = {    title: '社区物业缴费提醒',    status: '待处理',    source: '拍照整理',    time: '09:20',    tag: '通知',    summary: '识别到物业费缴纳截止日期、金额明细和办理地点,建议保存为待办提醒。',    content: '这条记录来自一张物业缴费通知。页面包含标题、摘要、识别内容和处理备注。外屏下,主按钮固定在底部,方便快速保存;展开态下,操作按钮移动到右侧,让用户在阅读内容和处理动作之间保持更短的距离。'  };​  // 演示宽度优先级高于真实宽度,真实项目里可以删除 previewWidth 相关逻辑  private getEffectiveWidth(): number {    if (this.previewWidth > 0) {      return this.previewWidth;    }​    return this.pageWidth;  }​  private getPagePadding(): number {    return this.getEffectiveWidth() >= this.wideThreshold ? 24 : 16;  }​  // 不只判断阈值,还要确认内容区、间距和操作栏是否真的能放下  private canUseSideAction(): boolean {    const availableWidth = this.getEffectiveWidth() - this.getPagePadding() * 2;    const requiredWidth = this.minContentWidth + this.twoColumnGap + this.actionPanelWidth;    return this.getEffectiveWidth() >= this.wideThreshold && availableWidth >= requiredWidth;  }​  private isExpanded(): boolean {    return this.canUseSideAction();  }​  private getContentWidth(): Length {    if (this.previewWidth > 0) {      return this.previewWidth;    }​    return '100%';  }​  private getTitleSize(): number {    return this.isExpanded() ? 28 : 23;  }​  private getModeText(): string {    return this.isExpanded() ? 'expanded · 右侧操作栏' : 'compact · 底部固定按钮';  }​  private getModeDesc(): string {    if (this.isExpanded()) {      return '当前宽度足够,主操作移动到右侧,内容区和操作区保持在同一视线范围内。';    }​    return '当前宽度更适合底部固定按钮,主操作靠近外屏下方,便于单手触达。';  }​  private setPreview(width: number) {    this.previewWidth = width;  }​  private save() {    this.saveCount += 1;  }​  @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(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 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() + '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)        .maxLines(2)        .textOverflow({ overflow: TextOverflow.Ellipsis })​      Row({ space: 8 }) {        this.PreviewButton('自动', 0)        this.PreviewButton('外屏', 430)        this.PreviewButton('展开态', 980)      }      .width('100%')    }    .width('100%')  }​  @Builder  private SummaryCard() {    Column({ space: 12 }) {      Row() {        this.StatusPill(this.detail.status)​        Blank()​        this.MetaPill(this.detail.tag)      }      .width('100%')​      Text(this.detail.title)        .fontSize(this.isExpanded() ? 26 : 22)        .fontWeight(FontWeight.Bold)        .fontColor('#111827')        .lineHeight(this.isExpanded() ? 34 : 29)        .maxLines(2)        .textOverflow({ overflow: TextOverflow.Ellipsis })​      Text(this.detail.summary)        .fontSize(15)        .fontColor('#4B5563')        .lineHeight(23)​      Row({ space: 8 }) {        this.MetaPill(this.detail.source)        this.MetaPill(this.detail.time)      }      .width('100%')    }    .width('100%')    .padding(this.isExpanded() ? 20 : 16)    .backgroundColor('#FFFFFF')    .borderRadius(22)    .shadow({      radius: 10,      color: '#10000000',      offsetX: 0,      offsetY: 4    })  }​  @Builder  private ContentCard() {    Column({ space: 10 }) {      Text('识别内容')        .fontSize(18)        .fontWeight(FontWeight.Bold)        .fontColor('#111827')​      Text(this.detail.content)        .fontSize(15)        .fontColor('#4B5563')        .lineHeight(24)    }    .width('100%')    .padding(this.isExpanded() ? 20 : 16)    .backgroundColor('#FFFFFF')    .borderRadius(22)    .border({      width: 1,      color: '#E5E7EB'    })  }​  @Builder  private EditCard() {    Column({ space: 14 }) {      Text('处理备注')        .fontSize(18)        .fontWeight(FontWeight.Bold)        .fontColor('#111827')​      TextArea({ text: this.noteText })        .height(this.isExpanded() ? 116 : 92)        .fontSize(15)        .backgroundColor('#F9FAFB')        .borderRadius(14)        .padding({ left: 12, right: 12, top: 10, bottom: 10 })        .onChange((value: string) => {          this.noteText = value;        })​      if (this.isExpanded()) {        Text('展开态下,主操作交给右侧操作栏,表单区域只保留编辑内容。')          .fontSize(14)          .fontColor('#6B7280')          .lineHeight(21)      }    }    .width('100%')    .padding(this.isExpanded() ? 20 : 16)    .backgroundColor('#FFFFFF')    .borderRadius(22)    .shadow({      radius: 10,      color: '#10000000',      offsetX: 0,      offsetY: 4    })  }​  @Builder  private ContentPanel() {    Scroll() {      Column({ space: 14 }) {        this.SummaryCard()        this.ContentCard()        this.EditCard()      }      .width('100%')      // 小屏底部有固定按钮,需要额外留出空间,避免内容被遮住      .padding({ bottom: this.isExpanded() ? 20 : 88 })    }    .layoutWeight(1)    .width('100%')    .edgeEffect(EdgeEffect.Spring)  }​  @Builder  private ActionPanel() {    Column({ space: 16 }) {      Column({ space: 6 }) {        Text('操作区')          .fontSize(18)          .fontWeight(FontWeight.Bold)          .fontColor('#111827')​        Text('当前记录:' + this.detail.title)          .fontSize(14)          .fontColor('#4B5563')          .lineHeight(21)          .maxLines(2)          .textOverflow({ overflow: TextOverflow.Ellipsis })      }      .width('100%')      .alignItems(HorizontalAlign.Start)​      Column({ space: 8 }) {        Row() {          Text('状态')            .fontSize(13)            .fontColor('#6B7280')​          Blank()​          this.StatusPill(this.detail.status)        }        .width('100%')​        Row() {          Text('保存次数')            .fontSize(13)            .fontColor('#6B7280')​          Blank()​          Text(this.saveCount.toString() + ' 次')            .fontSize(13)            .fontColor('#111827')        }        .width('100%')      }      .width('100%')      .padding(14)      .backgroundColor('#F9FAFB')      .borderRadius(16)​      Button('保存处理结果')        .fontSize(15)        .fontColor('#FFFFFF')        .height(44)        .width('100%')        .backgroundColor('#2F8F83')        .borderRadius(22)        .onClick(() => {          this.save();        })​      Button('标记为已完成')        .fontSize(15)        .fontColor('#2F8F83')        .height(44)        .width('100%')        .backgroundColor('#E6F4F1')        .borderRadius(22)​      Button('稍后处理')        .fontSize(15)        .fontColor('#4B5563')        .height(44)        .width('100%')        .backgroundColor('#F3F4F6')        .borderRadius(22)​      Text('右侧操作栏只放主操作、少量次操作和当前状态。更多操作不适合继续堆在这里。')        .fontSize(14)        .fontColor('#6B7280')        .lineHeight(22)​      Blank()    }    .width('100%')    .height('100%')    .padding(18)    .backgroundColor('#FFFFFF')    .borderRadius(24)    .shadow({      radius: 12,      color: '#12000000',      offsetX: 0,      offsetY: 4    })  }​  @Builder  private BottomActionBar() {    Column() {      Row({ space: 10 }) {        Button('保存处理结果')          .fontSize(15)          .fontColor('#FFFFFF')          .height(44)          .layoutWeight(1)          .backgroundColor('#2F8F83')          .borderRadius(22)          .onClick(() => {            this.save();          })​        Button('稍后')          .fontSize(15)          .fontColor('#2F8F83')          .height(44)          .width(92)          .backgroundColor('#E6F4F1')          .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: this.twoColumnGap }) {              Column() {                this.ContentPanel()              }              .layoutWeight(1)              .height('100%')​              Column() {                this.ActionPanel()              }              .width(this.actionPanelWidth)              .height('100%')            }            .width('100%')            .layoutWeight(1)          } else {            this.ContentPanel()          }        }        .width(this.getContentWidth())        .height('100%')        .padding({          left: this.getPagePadding(),          right: this.getPagePadding(),          top: 18,          bottom: this.isExpanded() ? 16 : 0        })      }      .width('100%')      .height('100%')      .alignItems(HorizontalAlign.Center)​      if (!this.isExpanded()) {        this.BottomActionBar()      }    }    .width('100%')    .height('100%')    .backgroundColor('#F6F7F9')    .onAreaChange((_: Area, newValue: Area) => {      const width = Number(newValue.width);      if (!Number.isNaN(width) && width > 0) {        this.pageWidth = width;      }    })  }}

四、跑出来后的变化

外屏状态下,页面仍然是典型的纵向详情页。内容从上到下排列,底部一直保留“保存处理结果”和“稍后”两个操作。这里我特意给内容区域底部留了 88vp 的空间,因为底部固定按钮如果不预留位置,滚动到底部时很容易挡住备注区域。

img

展开态下,页面会变成左内容、右操作。保存、标记完成、稍后处理都移动到右侧。此时底部不再出现固定操作条,内容区域也不需要额外留出那么大的底部空间。右侧操作栏里保留状态和保存次数,用户在阅读内容时,可以一直看到当前记录的操作状态。

img

这段代码里,我没有只用 expandedWidth 判断大屏。canUseSideAction() 会先算出当前剩余宽度,确认内容区和右侧操作栏真的能放下,再进入右侧操作栏模式。这样写能避免一个常见问题:窗口刚超过断点,但右侧栏一出现,内容区马上被挤窄。

回到真实项目时,可以删掉 previewWidthPreviewButton() 和顶部演示按钮。真实页面只需要保留 pageWidthcanUseSideAction() 和对应的布局分支。右侧操作栏里的按钮也要继续收敛,主操作保留一个,次操作保留一到两个,低频操作放进更多入口会更合适。

总结

Pura X Max 展开态下,主操作按钮不一定还要留在底部。外屏里底部按钮方便触达,展开态里右侧操作栏更接近内容和表单,用户不需要在阅读区和屏幕底部之间反复移动。

这个改造的关键不是把按钮从一个地方搬到另一个地方,而是先判断当前窗口是否真的适合右侧操作栏。宽度足够时再分栏,宽度不足时继续使用底部操作条,页面在外屏、展开态和分屏边界下都会更稳。


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

暂无评论数据

加载中...

发布

头像

小雨同学

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

8

帖子

0

提问

24

粉丝

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

京ICP备:2022009079号-2

京公网安备:11010502051901号

ICP证:京B2-20230255