鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 10:横屏下页面从上下结构改为左右结构
我是在调一个材料预览页的时候注意到这个问题的。窗口切到 `920 × 520vp` 后,页面仍然按竖屏时的顺序往下排,上面是一块内容预览区,下面是识别结果和确认按钮。刚看第一眼,页面并没有出现明显错位,按钮也能点击,但预览区的高度已经被压得很低,原本应该优先呈现的内容只剩下一段不大的区域。
前言
我是在调一个材料预览页的时候注意到这个问题的。窗口切到 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;
}
})
}
}
更多推荐



所有评论(0)