鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 10:横屏下页面从上下结构改为左右结构

鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 10:横屏下页面从上下结构改为左右结构 前言我是在调一个材料预览页的时候注意到这个问题的。窗口切到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 演示按钮要删掉示例里有previewWidth和previewHeight它们只用于演示。真实项目里不需要让用户点击“竖屏”“横屏”。页面应该直接根据真实窗口宽高变化切换布局。示例里的写法是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; } }) } }