鸿蒙 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; } }) } }