HarmonyOS 6 | Pura X Max 鸿蒙原生适配 08:大屏下操作按钮位置重排
前言我在适配 Pura X Max 的详情页和编辑页时发现一个很容易被忽略的问题页面变宽以后底部按钮反而变得不好用了。在普通手机页面里底部固定按钮几乎是默认选择。用户从上往下看内容最后在底部点击保存、提交、确认这个路径很顺。外屏尺寸有限底部按钮也比较容易触达。可到了 Pura X Max 展开态同样的设计就开始别扭了。内容在页面上半部分表单在中间区域按钮却还停在屏幕底部。用户看完内容以后要把视线拉到底部再把手指移动过去操作路径明显变长。Pura X Max 外屏为 5.4 英寸内屏为 7.7 英寸外屏分辨率为 1848 × 1264内屏分辨率为 2584 × 1828系统版本为 HarmonyOS 6.1。这个设备的展开态不是普通手机页面的简单放大很多页面结构都需要重新看一遍尤其是详情、编辑、审核、确认这类带主操作的页面。我现在更倾向于把按钮位置当成页面结构的一部分来处理。外屏下主操作继续放在底部展开态下主操作移动到右侧让按钮靠近内容、表单和当前状态。这个改动看起来只是按钮换了位置实际影响的是用户在大屏上的阅读路径和操作距离。一、按钮离内容太远小屏页面为什么喜欢底部按钮本质上是因为用户的阅读路径和操作区域都集中在一个窄窗口里。页面宽度有限时内容从上往下走。底部固定按钮可以一直露出用户不用滚到底也不用到页面里找按钮。很多表单页、详情处理页、订单确认页都会这么做。这个方案在外屏里仍然成立尤其是保存、提交、确认这类单一主操作放在底部很稳定。但展开态的空间关系变了。内容区域通常在左侧或中上部表单输入区可能在页面中间底部固定按钮却横跨整个屏幕。页面越宽这个按钮越像一条很重的底部栏。用户阅读内容和执行操作之间会被拉开。这个问题在编辑页里更明显。用户正在处理备注、标题、识别结果保存按钮还停在最底部。填完以后还要把视线移开确认按钮位置再进行点击。这个过程不算复杂但在大屏上会显得拖沓。我后来处理这类页面时会先问一个问题这个按钮到底应该服务谁如果按钮服务的是当前内容那它应该靠近内容。如果按钮服务的是当前表单那它应该靠近表单。如果按钮是整个页面的最终确认在小屏下放底部没问题到了展开态放在右侧操作区会更稳定。鸿蒙现在的窗口状态也比单一手机时代复杂。全屏、分屏、自由窗口都会改变应用可用区域。页面不能只按设备名称判断还要看当前窗口是否真的适合右侧操作区。窗口模式变化会带来窗口尺寸变化页面布局也要跟着调整。二、外屏贴底外屏下我还是会保留底部固定按钮。原因很直接。外屏宽度有限内容和操作基本都在一条纵向路径上。用户读完内容以后底部主按钮离手指更近。这个时候如果强行把按钮放到页面右侧反而会破坏原来的单手操作习惯。在实现上外屏可以继续使用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 Component struct 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 的空间因为底部固定按钮如果不预留位置滚动到底部时很容易挡住备注区域。展开态下页面会变成左内容、右操作。保存、标记完成、稍后处理都移动到右侧。此时底部不再出现固定操作条内容区域也不需要额外留出那么大的底部空间。右侧操作栏里保留状态和保存次数用户在阅读内容时可以一直看到当前记录的操作状态。这段代码里我没有只用expandedWidth判断大屏。canUseSideAction()会先算出当前剩余宽度确认内容区和右侧操作栏真的能放下再进入右侧操作栏模式。这样写能避免一个常见问题窗口刚超过断点但右侧栏一出现内容区马上被挤窄。回到真实项目时可以删掉previewWidth、PreviewButton()和顶部演示按钮。真实页面只需要保留pageWidth、canUseSideAction()和对应的布局分支。右侧操作栏里的按钮也要继续收敛主操作保留一个次操作保留一到两个低频操作放进更多入口会更合适。总结Pura X Max 展开态下主操作按钮不一定还要留在底部。外屏里底部按钮方便触达展开态里右侧操作栏更接近内容和表单用户不需要在阅读区和屏幕底部之间反复移动。这个改造的关键不是把按钮从一个地方搬到另一个地方而是先判断当前窗口是否真的适合右侧操作栏。宽度足够时再分栏宽度不足时继续使用底部操作条页面在外屏、展开态和分屏边界下都会更稳。