背景
一次开发,多端部署旨在编写一套代码,一次开发上架,即可以将应用多端按需部署。随着HarmonyOS生态不断拓展,终端设备形态日益多样化,应用的页面布局如何在一套代码中适配不同屏幕尺寸、屏幕方向的设备类型,成为一大挑战。为了解决这一问题,系统侧提供了响应式布局供开发者学习与使用。响应式布局是一种设计模式,核心思想是页面能够根据不同窗口或屏幕尺寸自动调整布局,以提供更加舒适的界面和更好的用户体验。响应式布局页面的效果图如下:
简单来讲,响应式布局是指页面内的元素可以根据窗口尺寸自动变化。响应式布局中最常使用的特征是窗口宽度,因此系统侧将窗口宽度划分为不同的范围(称为断点)。当窗口宽度从一个断点变化到另一个断点时,改变页面布局(如将页面内容从单列排布调整为双列排布甚至三列排布等)以获得更好的显示效果。
断点的设计原理
为了提升用户的全场景体验,需要考虑多设备体验的连续性。应用在页面布局设计上推荐遵循如下原则:
- 原则一:两个宽度相近的窗口,页面布局应相同。
- 原则二:高度相对宽度较小的窗口,呈现横向窗口或类方型窗口时,页面布局进行差异化设计。
因此,HarmonyOS设计了横向和纵向断点分别代表窗口的不同特征,作为判断页面布局和交互体验的条件:
- 横向断点以窗口宽度值区分,代表窗口宽度。
- 纵向断点以窗口高宽比区分,代表窗口相对高度,表示横向、方型或纵向窗口。
下文 横向断点的使用 章节将介绍如何使用横向断点实现原则一, 纵向断点的使用 章节将介绍如何结合横向和纵向断点实现原则二。
断点以应用窗口宽和高为切入点,将应用窗口在宽度以及窗口的宽高比这两个维度上分成了几个不同的区间(即不同的断点),在不同的区间下,UX设计师对页面进行响应式布局设计,开发者可根据HarmonyOS一多相关能力实现不同的页面布局效果。
断点的定义
横向断点以应用窗口宽度为判断条件,推荐按照不同的阈值分成5个区间:
断点名称 | 窗口宽度(vp) |
---|---|
xs | (0, 320) |
sm | (320, 600) |
md | (600, 840) |
lg | (840, 1440) |
xl | (1440, +∞) |
纵向断点以应用窗口的高宽比为判断条件,推荐按照不同的阈值分成3个区间:
断点名称 | 高宽比 |
---|---|
sm | (0, 0.8) |
md | (0.8, 1.2) |
lg | (1.2, +∞) |
下图为HarmonyOS常用设备断点区间表:
说明
一多的断点面向窗口而非面向设备类型,处于相同断点区间的窗口本质上形态相同,推荐展示相同的页面布局。因此同一设备上的不同窗口形态(例如全屏显示、分屏显示、自由窗口等),可能会落入不同的断点区间,展示不同断点对应的页面布局。
实现原理
系统在UIContext中提供系统接口getWindowWidthBreakpoint()和getWindowHeightBreakpoint(),开发者需要在windowStage.loadContent()页面加载后直接获取横向和纵向断点值。
export default class EntryAbility extends UIAbility {
private uiContext?: UIContext;
private onWindowSizeChange: (windowSize: window.Size) => void = (windowSize: window.Size) => {
let widthBp: WidthBreakpoint = this.uiContext!.getWindowWidthBreakpoint();
AppStorage.setOrCreate('currentWidthBreakpoint', widthBp);
let heightBp: HeightBreakpoint = this.uiContext!.getWindowHeightBreakpoint();
AppStorage.setOrCreate('currentHeightBreakpoint', heightBp);
};
// ...
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.');
// The system interface depends on UIContext and needs to be invoked after the page is loaded. It needs to be written in the loadContent callback function.
windowStage.getMainWindow().then((data: window.Window) => {
this.uiContext = data.getUIContext();
let widthBp: WidthBreakpoint = this.uiContext.getWindowWidthBreakpoint();
let heightBp: HeightBreakpoint = this.uiContext.getWindowHeightBreakpoint();
AppStorage.setOrCreate('currentWidthBreakpoint', widthBp);
AppStorage.setOrCreate('currentHeightBreakpoint', heightBp);
data.on('windowSizeChange', this.onWindowSizeChange);
}).catch((err: BusinessError) => {
console.error(`Failed to obtain the main window. Cause code: ${err.code}, message: ${err.message}`);
});
});
}
// ...
}
说明
对于API 12及以下或对系统接口使用有限制的应用,需要开发者在窗口创建时使用window.Rect获取横纵向断点,并使用window.on(‘windowSizeChange’)监听窗口尺寸变化,自定义实现更新横纵向断点值。
- updateWidthBp()方法按照应用窗口宽度的四个阈值分为了五个断点:xs、sm、md、lg和xl,并将断点存在全局中。
updateWidthBp(): void {
let mainWindow: window.WindowProperties = this.mainWindowClass!.getWindowProperties();
let windowWidth: number = mainWindow.windowRect.width;
let windowWidthVp = px2vp(windowWidth);
let widthBp: string = '';
if (windowWidthVp < 320) {
widthBp = 'xs';
} else if (windowWidthVp >= 320 && windowWidthVp < 600) {
widthBp = 'sm';
} else if (windowWidthVp >= 600 && windowWidthVp < 840) {
widthBp = 'md';
} else if (windowWidthVp >= 840 && windowWidthVp < 1440) {
widthBp = 'lg';
} else {
widthBp = 'xl';
}
AppStorage.setOrCreate('currentWidthBreakpoint', widthBp);
}
- updateHeightBp()方法按照高宽比的两个阈值分成了三个断点:sm、md和lg,并将断点存在全局中。
updateHeightBp(): void {
let mainWindow: window.WindowProperties = this.mainWindowClass!.getWindowProperties();
let windowHeight: number = mainWindow.windowRect.height;
let windowWidth: number = mainWindow.windowRect.width;
let windowWidthVp = px2vp(windowWidth);
let windowHeightVp = px2vp(windowHeight);
let heightBp: string = '';
let aspectRatio: number = windowHeightVp / windowWidthVp;
if (aspectRatio < 0.8) {
heightBp = 'sm';
} else if (aspectRatio >= 0.8 && aspectRatio < 1.2) {
heightBp = 'md';
} else {
heightBp = 'lg';
}
AppStorage.setOrCreate('currentHeightBreakpoint', heightBp);
}
- 在应用生命周期创建窗口时,获取当前横纵断点,并注册横向断点和纵向断点变化的监听。
export default class EntryAbility extends UIAbility {
private windowUtil?: WindowUtil = WindowUtil.getInstance();
private onWindowSizeChange: (windowSize: window.Size) => void = (windowSize: window.Size) => {
this.windowUtil!.updateHeightBp();
this.windowUtil!.updateWidthBp();
// ...
};
// ...
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
this.windowUtil?.setWindowStage(windowStage);
windowStage.getMainWindow((err: BusinessError, data: window.Window) => {
if (err.code) {
hilog.error(0x0000, 'testTag', 'Failed to get the main window. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
this.windowUtil!.updateWidthBp();
this.windowUtil!.updateHeightBp();
// ...
data.on('windowSizeChange', this.onWindowSizeChange);
})
// ...
}
// ...
}
横向断点的使用
针对布局拉通(两个宽度相近的窗口,页面布局应相同),本章节将介绍实际开发中横向断点的使用。
实现一多应用时,首先根据不同设备类型对应的断点集合,规划多种设计方案。例如一款应用需要支持手机、大折叠屏(X系列)、平板,则需要考虑的三个横向断点分别是sm、md、lg,并设计这三个断点下不同的页面布局,然后根据设计方案进行一多开发。设计方案效果图如下:
下面首先介绍使用横向断点的技术要点,其次以该应用首页为例,对三种典型的响应式布局结合断点的场景详细介绍,包括挪移布局、重复布局和分栏布局。
技术要点
实际一多应用开发,可能不会涉及到全部的横向断点,开发者可根据应用的实际需求灵活选用并整理工具类,为响应式布局的属性赋值。例如:应用仅需适配手机、大折叠屏(X系列)和平板设备,可以设计为工具类BreakpointType覆盖三个横向断点下的成员变量。使用工具类实现具体的响应式布局。
export class BreakpointType<T> {
sm: T;
md: T;
lg: T;
constructor(sm: T, md: T, lg: T) {
this.sm = sm;
this.md = md;
this.lg = lg;
}
getValue(currentWidthBreakpoint: string): T {
if (currentWidthBreakpoint === 'sm') {
return this.sm;
}
if (currentWidthBreakpoint === 'md') {
return this.md;
}
if (currentWidthBreakpoint === 'lg') {
return this.lg;
}
return this.sm;
}
}
以实际开发中设置不同断点下的字体大小为例,在sm、md、lg横向断点下字体大小分别为14fp、16fp、18fp,通过工具类BreakpointType为不同横向断点下的属性赋值。
Text('Test')
.fontSize(new BreakpointType('14fp', '16fp', '18fp').getValue(this.currentWidthBreakpoint))
如果sm、md断点下字体均为14fp,lg断点下字体为16fp,通过三元表达式结合横向断点为属性赋值。
Text('Test')
.fontSize(this.currentWidthBreakpoint === 'lg' ? '16fp' : '14fp')
挪移布局
挪移布局作为典型的响应式布局,能够调整组件的位置与展示方式,在上下布局与左右布局之间切换,通常应用于首页的顶部页签与搜索框。
实现原理
不同横向断点下,顶部页签和搜索框占用不同栅格列数,使用栅格布局实现在sm横向断点下分两行显示,在md和lg横向断点下单行显示。根据设计将栅格在sm、md和lg的横向断点上分别划分为4列、12列、12列。示意图如下:
// Movable layout.
GridRow({
columns: { sm: 4, md: 12, lg: 12 },
gutter: 12,
breakpoints: { value: ['320vp', '600vp', '840vp'], reference: BreakpointsReference.WindowSize },
direction: GridRowDirection.Row
}) {
GridCol({
span: { sm: 4, md: 7, lg: 7 }
}) {
this.topTabBar()
}
.height(56)
GridCol({
span: { sm: 4, md: 5, lg: 5 }
}) {
this.topSearch()
}
.height(56)
}
.width('100%')
重复布局
重复布局泛指在一多中将相同属性的组件重复排布,通常用于在宽度不同的窗口中多行多列展示首页的内容元素。用户能够同时浏览更多内容,提高屏幕的利用率。
实现原理
ArkUI中常见的重复布局组件包含Swiper组件、Grid组件、List组件、WaterFlow组件等。本章节以应用首页中的布局为例,介绍Swiper组件、Grid组件如何分别与横向断点结合实现重复布局。
- Swiper组件
首页展示Banner图的Swiper组件,又称运营横幅或轮播布局。在不同横向断点下,展示不同数量的图片。
通过BreakpointType工具类为Swiper组件的displayCount、indicator和nextMargin属性赋值,实现目标效果。
// Carousel layout.
Swiper() {
// ...
}
.displayCount(new BreakpointType(1, 2, 3).getValue(this.currentWidthBreakpoint))
.nextMargin(new BreakpointType(0, 16, 32).getValue(this.currentWidthBreakpoint))
.prevMargin(new BreakpointType(0, 16, 32).getValue(this.currentWidthBreakpoint))
.indicator(this.currentWidthBreakpoint === 'sm' ? Indicator.dot()
.itemWidth(6)
.itemHeight(6)
.selectedItemWidth(12)
.selectedItemHeight(6)
.color('#4DFFFFFF')
.bottom(6)
.selectedColor(Color.White) : false
)
.padding({
left: this.currentWidthBreakpoint === 'sm' ? 12 : 0,
right: this.currentWidthBreakpoint === 'sm' ? 12 : 0
})
- Grid组件
又称网格布局或宫格布局。在不同横向断点下,宫格布局的排布不同。
通过判断当前横向断点,为Grid组件的columnsTemplate属性赋值,实现目标效果。
// Palace grid layout.
Grid() {
// ...
}
.columnsTemplate('1fr '.repeat(this.currentWidthBreakpoint === 'sm' ? 4 : 8))
.rowsTemplate(this.currentWidthBreakpoint === 'sm' ? '1fr 1fr' : '1fr')
.height(this.currentWidthBreakpoint === 'sm' ? 164 : 78)
.columnsGap(12)
.rowsGap(12)
分栏布局
分栏布局可分为二分栏和三分栏,通常用于md、lg横向断点下多栏展示更多内容、高效浏览。使用Navigation组件和 SideBarContainer 组件,并结合横向断点的方式,可实现目标效果 。
纵向断点的使用
针对小窗口下的特殊布局(高度相对宽度较小的窗口,呈现横向窗口或类方型窗口时,页面布局进行特殊设计),本章节将介绍实际开发中横向断点+纵向断点结合使用。
技术要点
系统推荐按照以下方式判断横向窗口或类方型窗口,并展示特殊的页面布局。
// Judgment of the horizontal window. (The actual application may need to be combined with other conditions, for example, determine the horizontal breakpoint)
if (this.currentHeightBreakpoint === 'sm' && this.currentWidthBreakpoint === 'md') {
// Horizontal window page layout.
}
// Judgment of the square window. (The actual use may need to be combined with other conditions, such as determining horizontal breakpoints)
if (this.currentHeightBreakpoint === 'md' && this.currentWidthBreakpoint === 'sm') {
// Square-like window page layout.
}
独特的小窗口布局
在类方型的小窗口中,为了追求用户独特的体验,通常设计为独特的小窗口布局。常见场景为手机上下1:1分屏,此时可通过横向断点为sm,纵向断点为md进行区分,示意图如下。
类方屏旋转方案
在手机上下1:1分屏等类方屏小窗口场景中,通常设计应用支持旋转,以满足不同的用户体验。
实现原理
- 判断纵向断点为md时,通过 window.setPreferredOrientation() 设置窗口支持旋转。
let currentHeightBreakpoint: string | undefined = AppStorage.get('currentHeightBreakpoint');
if (currentHeightBreakpoint === 'md') {
this.mainWindowClass?.setPreferredOrientation(window.Orientation.AUTO_ROTATION_RESTRICTED);
}
- 判断窗口高宽比在[0.8, 1.2)之间时,通过window.setPreferredOrientation()设置窗口支持旋转。
let windowRect: window.Rect = this.mainWindowClass!.getWindowProperties().windowRect;
let windowWidthVp: number = px2vp(windowRect.width);
let windowHeightVp: number = px2vp(windowRect.height);
let aspectRatio: number = windowHeightVp / windowWidthVp;
if (aspectRatio < 1.2 && aspectRatio >= 0.8) {
this.mainWindowClass?.setPreferredOrientation(window.Orientation.AUTO_ROTATION_RESTRICTED);
}
说明
通常应用设置旋转方案需要结合多种条件一起判断
其他特殊场景
除了独特的小窗口布局、类方屏旋转方案外,在开发过程存在部分特殊场景仅使用横向断点无法区分,也需要结合横向断点+纵向断点进行判断。
本章节以视频类应用全屏播放页举例,在手机横屏时要求不支持旋转,在大折叠屏(X系列)和平板竖屏时支持旋转,但它们的横向断点都在md范围,无法区分。因此需要使用横向断点和纵向断点的基础上进行区分,兼容多种设备下全屏播放的窗口旋转方案。
开发步骤
- 在EntryAbility的onWindowStageCreate()生命周期中增加对宽度和“高宽比”的监听。在获取到主窗口后调用updateWidthBp()和updateHeightBp()方法初始设置一次横纵断点,之后在窗口大小变化时设置窗口尺寸变化的监听window.on(‘windowSizeChange’),当windowSize改变的时候就会触发。
export default class EntryAbility extends UIAbility {
private windowObj?: window.Window;
private windowUtil?: WindowUtil = WindowUtil.getInstance();
private onWindowSizeChange: (windowSize: window.Size) => void = (windowSize: window.Size) => {
this.windowUtil!.updateHeightBp();
this.windowUtil!.updateWidthBp();
AppStorage.setOrCreate('windowWidth', windowSize.width);
};
// ...
onWindowStageCreate(windowStage: window.WindowStage) {
// Main window is created, set main page for this ability
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
this.windowUtil!.setWindowStage(windowStage);
windowStage.getMainWindow().then((data: window.Window) => {
this.windowObj = data;
this.windowUtil!.updateWidthBp();
this.windowUtil!.updateHeightBp();
// ...
this.windowObj.on('windowSizeChange', this.onWindowSizeChange);
});
// ...
}
// ...
}
- 使用@Watch装饰器监听状态变量isFullScreen的变化判断视频是否全屏播放,在显示或隐藏时同步修改窗口方向。先来看下全屏播放时未使用断点时的窗口设置逻辑:有以下几种情况需要将窗口设置为AUTO_ROTATION_LANDSCAPE属性:手机、大折叠屏(X系列)折叠态与半折态。
手机效果图:
大折叠屏(X系列)半折态:
反例
if (isFullScreen) {
if (deviceInfo.deviceType !== '2in1') {
this.windowUtil!.disableWindowSystemBar();
}
if ((!display.isFoldable() && deviceInfo.deviceType === 'phone') ||
display.getFoldStatus() === display.FoldStatus.FOLD_STATUS_FOLDED) {
this.windowUtil!.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_LANDSCAPE);
}
if (display.isFoldable()) {
if (this.isHalfFolded) {
this.windowUtil!.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_LANDSCAPE);
}
}
}
从上述代码看到,在进入全屏页时,是通过isFoldable、deviceType 和getFoldStatus三个值共同去判断的,这种方式可读性和维护性都比较差,随着鸿蒙生态不断拓展,在不同设备上可能会出现各种异常情况。
正例
- 大折叠屏(X系列)展开态与平板,支持旋转。大折叠屏(X系列)的横向断点为md,纵向断点为md;平板横向持握时横向断点为lg,竖向持握时横向断点为md,纵向断点为lg。将窗口显示方向设置为AUTO_ROTATION_RESTRICTED。
- 手机与大折叠屏(X系列)折叠态竖屏时的横向断点为sm,纵向断点为lg,全屏播放时支持横向旋转。将窗口显示方向设置为AUTO_ROTATION_LANDSCAPE_RESTRICTED。
- 手机与大折叠屏(X系列)折叠态横屏时的横向断点为md,纵向断点为sm,此时如果退出全屏播放,则竖屏展示布局。将窗口显示方向设置为PORTRAIT。
综上所述,整理代码如下。
// features/videoDetail/src/main/ets/view/VideoDetail.ets
onFullScreenChange(): void {
// Large folding screen (X series) in unfolded state and tablet state, supporting rotation && large folding screen (X series) in hover state requires landscape display and does not support rotation.
if (((this.currentWidthBreakpoint === BreakpointConstants.BREAKPOINT_MD && this.currentHeightBreakpoint !==
BreakpointConstants.BREAKPOINT_SM) || this.currentWidthBreakpoint === BreakpointConstants.BREAKPOINT_LG) &&
!this.isHalfFolded) {
this.windowUtil?.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_RESTRICTED);
}
// Phone and large folding screen (X series) in portrait mode.
else if (this.currentWidthBreakpoint === BreakpointConstants.BREAKPOINT_SM && this.currentHeightBreakpoint ===
BreakpointConstants.BREAKPOINT_LG) {
// In full-screen mode, the layout is displayed in landscape mode. Otherwise, the layout is displayed in portrait mode.
if (this.isFullScreen) {
this.windowUtil?.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_LANDSCAPE_RESTRICTED);
} else {
this.windowUtil?.setMainWindowOrientation(window.Orientation.PORTRAIT);
}
}
// When the mobile phone and large folding screen (X series) are folded in landscape mode and the playback is not in full screen mode, the vertical display layout is displayed.
else if (this.currentWidthBreakpoint === BreakpointConstants.BREAKPOINT_MD && this.currentHeightBreakpoint ===
BreakpointConstants.BREAKPOINT_SM && !this.isFullScreen) {
this.windowUtil?.setMainWindowOrientation(window.Orientation.PORTRAIT);
}
// The navigation bar is not hidden on a 2in1 device.
if (deviceInfo.deviceType !== '2in1') {
// The navigation bar is hidden in full-screen playback. Otherwise, the navigation bar is displayed.
if (this.isFullScreen) {
this.windowUtil!.disableWindowSystemBar();
} else {
this.windowUtil!.enableWindowSystemBar();
}
}
}
通过纵向和横向断点替换掉之前的逻辑,开发者只需关注去维护一套方案即可。以上完成了“全屏播放”页多设备的兼容,实际开发中多设备兼容均可以替换成该方案去适配。