往期推文全新看点(文中附带全新鸿蒙5.0全栈学习笔录)
✏️ 鸿蒙应用开发与鸿蒙系统开发哪个更有前景?
✏️ 嵌入式开发适不适合做鸿蒙南向开发?看完这篇你就了解了~
✏️ 对于大前端开发来说,转鸿蒙开发究竟是福还是祸?
✏️ 鸿蒙岗位需求突增!移动端、PC端、IoT到底该怎么选?
✏️ 记录一场鸿蒙开发岗位面试经历~
✏️ 持续更新中……
概述
3月20日,华为正式发布了“阔折叠”设计的手机——Pura X,该机型采用上下折叠的方式,配有一块16:10比例的内屏和一块1:1比例的方形外屏,兼顾大屏沉浸体验与单手便捷操作。阔折叠屏Pura X的外屏,在日常使用中大幅提升了效率和交互体验,用户无需展开手机即可快速完成高频操作,例如:
- 即时信息处理:查看通知、日程、天气等关键信息。
- 高效影音控制:切换歌曲、调节音量、预览视频进度等。
- 便捷出行导航:步行或驾车时快速查看路线关键指引。
- 快速移动支付:显示付款码,快捷支付。
- 个性化表达:通过动态壁纸、定制图案等展现独特风格。
为充分发挥1:1方形外屏的优势,应用需针对其特殊比例进行适配,优化页面布局与交互逻辑,确保用户在外屏上也能获得流畅、直观的操作体验。
说明
Pura X开合连续规则:
- 外屏切换到内屏,界面可以直接接续。
- 内屏(锁屏或非锁屏状态)切换到外屏,默认都显示为锁屏的亮屏状态。用户解锁后:对于应用已适配外屏的情况下,应用界面可以接续到外屏。
在Pura X的外屏,应用窗口的高度默认减小至约内屏高度的一半。下文将从布局设计的维度,针对Pura X外屏常见的五种开发场景,给出推荐的设计方案与开发指导。
布局设计
本章节将介绍Pura X外屏上推荐的设计方案,保证页面布局能够完整显示,避免出现截断、挤压、堆叠等现象,并充分利用屏幕空间,提供最佳的用户体验。
独特的小窗口布局
说明
应用通常针对类方屏的小窗口页面会设计不同的布局,因此需要在代码中实现响应式布局。一多场景中所有的响应式布局都基于断点来开发,Pura X外屏场景的区分需要在项目中添加横纵断点。
实现原理
- Pura X的外屏,横向断点为sm,纵向断点为md,推荐使用独特的小窗口布局。
以设置图片的高度为例,在Pura X外屏布局中高度为24vp,内屏时高度为48vp。使用横纵向断点判断,设置具体的属性值。
Image($r('app.media.icon_arrow_right'))
.height(this.currentWidthBreakpoint === 'sm' && this.currentHeightBreakpoint === 'md' ? 24 : 48)
.aspectRatio(1)
- 应用中有Pura X外屏布局中显示,内屏布局中隐藏的内容,使用visibility或if…else…结合横纵断点判断是否显示。
// 方案一
Column() {
// 小窗口布局显示的内容
}
.visibility(this.currentWidthBreakpoint === 'sm' && this.currentHeightBreakpoint === 'md' ? Visibility.Visible :
Visibility.None)
// 方案二
if (this.currentWidthBreakpoint === 'sm' && this.currentHeightBreakpoint === 'md') {
Column() {
// 小窗口布局显示的内容
}
}
页面支持滑动
Pura X的外屏,窗口高度会减小至约内屏的1/2,可能导致内屏能够完整显示的内容在外屏上显示不全。
实现原理
设置Scroll组件的scrollBar属性为BarState.Off,控制滚动条不显示。当窗口高度足够显示页面全部内容时,Scroll组件自动失效,页面不可滑动;当窗口高度不足以显示页面全部内容时,Scroll组件自动生效,页面可以滑动。
Scroll() {
Column() {
// ...
}
.width('100%')
}
.scrollBar(BarState.Off)
.height('100%')
.width('100%')
短视频播放页面
Pura X外屏展示短视频播放页面,要求背景图片(视频)进行等比例缩放,并进行上下沉浸,上方沉浸至顶部标题栏,下方沉浸至底部页签栏。侧边控件可滑动,完整显示页面内容。
实现原理
使用Stack组件控制页面内容显示层级,控制背景图片上下沉浸,且互相不影响交互事件。Z层级由下到上分别是背景图片(视频)区、底部页签区、短视频描述区、侧边控件区、顶部页签区。顶部和底部页签设置内边距padding为topAvoidHeight或bottomAvoidHeight避让系统规避区。侧边控件区使用Scroll组件自动控制滑动是否生效,使用 Blank组件 和 displayPriority属性 控制侧边控件区上下两侧的留白,容器高度足够时上下留白,容器高度不足时自动隐藏。
Stack({ alignContent: Alignment.BottomEnd }) {
// Background image.
Row() {
Image($r('app.media.background_image'))
.height('100%')
.objectFit(ImageFit.Cover)
.aspectRatio(0.6)
}
.height('100%')
.width('100%')
.justifyContent(FlexAlign.Center)
// Bottom tabs.
List() {
// ...
}
.backgroundColor('#99000000')
.listDirection(Axis.Horizontal)
.height(this.bottomBarHeight)
.padding({ bottom: this.bottomAvoidHeight })
// ...
// Video description.
Column() {
// ...
}
.alignItems(HorizontalAlign.Start)
.padding({
left: $r('app.float.margin_md'),
right: $r('app.float.margin_md')
})
// ...
// Sidebar buttons.
Scroll() {
Column() {
Blank()
.layoutWeight(3)
.displayPriority(1)
// ...
Blank()
.layoutWeight(1)
.displayPriority(1)
}
// ...
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('56vp')
.edgeEffect(EdgeEffect.None)
.align(Alignment.Bottom)
.margin({
top: this.topAvoidHeight + 24,
bottom: this.bottomBarHeight,
right: '8vp'
})
// Top tabs.
Row() {
// ...
}
.height('100%')
.width('100%')
.backgroundColor(Color.Black)
自定义弹窗适配小窗口
在Pura X外屏上,当窗口高度无法完整显示自定义弹窗时,可能出现弹窗内容截断,需要进行自定义弹窗适配小窗口。效果图如下:
实现原理
使用constraintSize设置约束尺寸,自定义弹窗的最大高度不超过父组件高度的90%。同时最外层使用Scroll组件自动支持滚动。
Scroll() {
Column() {
// ...
}
}
.scrollBar(BarState.Off)
.constraintSize({
minHeight: 0,
maxHeight: '90%'
})
滑动沉浸式浏览
在Pura X外屏的通用场景下,推荐上滑隐藏、下滑恢复显示。用户可以通过手指向上滑动屏幕临时隐藏掉标题栏、页签栏等界面元素,达到全屏浏览内容的效果,同时手指向下滑动屏幕时,标题栏和页签栏通过动画逐渐显示,从而可以更专注于应用展示的内容。效果图如下:
实现原理
通过滚动时动态调整页面组件高度和透明度,达到视觉上逐渐显示和隐藏的效果。
开发步骤
- 使用状态变量控制顶部标题栏、底部页签栏的高度和透明度。标题栏高度为topBarHeight,页签栏高度为bottomBarHeight,标题栏和页签栏的透明度为barOpacity。
@StorageLink('topBarHeight') topBarHeight: number = CommonConstants.UTIL_HEIGHTS[1] + this.topAvoidHeight;
@State bottomBarHeight: number = CommonConstants.UTIL_HEIGHTS[0] + this.bottomAvoidHeight;
@State barOpacity: number = 1;
- 在沉浸式布局下,标题栏高度在应用上下分屏时由固定值78vp+顶部系统规避区的高度topAvoidHeight组成;页签栏高度由固定值56vp+底部系统规避区的高度bottomAvoidHeight组成,页签栏的底部内边距padding为bottomAvoidHeight,避让底部系统导航条。
@StorageLink('topAvoidHeight') @Watch('topBarHeightChange') topAvoidHeight: number = 0;
@StorageLink('bottomAvoidHeight') @Watch('bottomBarHeightChange') bottomAvoidHeight: number = 0;
// ...
topBarHeightChange(): void {
if (this.currentWidthBreakpoint === 'sm' && (this.currentHeightBreakpoint === 'md' ||
this.currentHeightBreakpoint === 'sm')) {
this.topBarHeight = 78 + this.topAvoidHeight;
}
// ...
};
bottomBarHeightChange(): void {
this.bottomBarHeight = 56 + this.bottomAvoidHeight;
};
- 顶部和底部系统规避区高度会随应用窗口变化而变化,需要在窗口生命周期创建时调用window.getAvoidArea()获取初始的系统避让区高度,并使用window.on(‘avoidAreaChange’)监听系统避让区的变化,常见触发系统避让区回调的场景可参考 on(‘avoidAreaChange’) 。
export default class EntryAbility extends UIAbility {
private windowUtil?: WindowUtil = WindowUtil.getInstance();
private onAvoidAreaChange: (avoidArea: window.AvoidAreaOptions) => void = (avoidArea: window.AvoidAreaOptions) => {
if (avoidArea.type === window.AvoidAreaType.TYPE_SYSTEM) {
AppStorage.setOrCreate('topAvoidHeight', px2vp(avoidArea.area.topRect.height));
} else if (avoidArea.type === window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) {
AppStorage.setOrCreate('bottomAvoidHeight', px2vp(avoidArea.area.bottomRect.height));
}
};
// ...
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!.setFullScreen();
// ...
let topAvoidHeight: window.AvoidArea = data.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
AppStorage.setOrCreate('topAvoidHeight', px2vp(topAvoidHeight.topRect.height));
let bottomAvoidHeight: window.AvoidArea = data.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
AppStorage.setOrCreate('bottomAvoidHeight', px2vp(bottomAvoidHeight.bottomRect.height));
data.on('avoidAreaChange', this.onAvoidAreaChange);
if (AppStorage.get('currentWidthBreakpoint') === 'sm' && (AppStorage.get('currentHeightBreakpoint') === 'md' ||
AppStorage.get('currentHeightBreakpoint') === 'sm')) {
// Set top bar height when the application is in small screen.
AppStorage.setOrCreate('topBarHeight', CommonConstants.UTIL_HEIGHTS[1] + px2vp(topAvoidHeight.topRect.height));
} else {
// Set top bar height when the application is in full screen.
AppStorage.setOrCreate('topBarHeight', CommonConstants.UTIL_HEIGHTS[2] + px2vp(topAvoidHeight.topRect.height));
}
})
// ...
}
// ...
}
- 设置顶部标题栏的高度为topBarHeight,透明度为barOpacity;设置底部页签栏的高度为bottomBarHeight,透明度为barOpacity,确保滑动时标题栏和页签栏能够逐渐显隐。列表内容在Stack组件内顶部外边距设置为topBarHeight,确保滑动沉浸式浏览时列表占满剩余高度。
Tabs() {
TabContent() {
Stack({ alignContent: Alignment.Top }) {
Row() {
Text($r('app.string.app_title'))
.fontSize($r('app.float.font_size_xl'))
.fontWeight(CommonConstants.FONT_WEIGHTS[1])
.height(this.topBarHeight)
.align(Alignment.Bottom)
.padding({ bottom: 12 })
}
.height(this.topBarHeight)
.opacity(this.barOpacity)
// ...
List({
space: CommonConstants.LIST_SPACE[0],
scroller: this.listScroller
}) {
// ...
}
.margin({ top: this.topBarHeight })
// ...
}
.height('100%')
.width('100%')
}
.tabBar(this.bottomTabBuilder(0))
// ...
}
// ...
.barHeight(this.bottomBarHeight)
// ...
- 当横向断点为sm,纵向断点为sm或md,应用窗口属于Pura X外屏或手机上下分屏等小窗口时,在滑动过程中,如果当前Y轴滑动的偏移量>0上滑时且固定区(顶部标题栏和底部页签栏)未完全隐藏,逐渐减少固定区的高度和透明度,实现滑动过程隐藏的效果;如果当前Y轴滑动的偏移量<0下滑时且未显示恢复动画且固定区处于隐藏状态,则通过动画逐渐恢复固定区的高度和透明度。
.onScrollFrameBegin((offset: number) => {
if (this.currentWidthBreakpoint !== 'sm' || (this.currentHeightBreakpoint !== 'md' &&
this.currentHeightBreakpoint !== 'sm')) {
return { offsetRemain: offset };
}
if (offset > 0 && !this.hideDone) {
this.currentYOffset += offset;
if (this.currentYOffset <= 100) {
this.bottomBarHeight = this.bottomBarHeight * (1 - this.currentYOffset / 100);
this.topBarHeight = this.topBarHeight * (1 - this.currentYOffset / 100);
this.barOpacity = 1 - this.currentYOffset / 100;
} else {
this.topBarHeight = 0;
this.bottomBarHeight = 0;
this.barOpacity = 0;
this.hideDone = true;
}
this.isHiding = true;
}
if (offset < 0 && this.isHiding) {
this.hideDone = false;
this.getUIContext().animateTo({
duration: 300
}, () => {
this.bottomBarHeight = 56 + this.bottomAvoidHeight;
this.topBarHeight = 78 + this.topAvoidHeight;
this.barOpacity = 1;
this.currentYOffset = 0;
this.isHiding = false;
});
}
return { offsetRemain: offset };
})