Flutter实现区域避让开发指导
一、概述
在移动应用开发中,不同设备的屏幕形态各异,如刘海屏、全面屏、折叠屏等,同时系统状态栏、导航栏、软键盘等元素也会占据屏幕空间。为了确保应用内容在各种设备和场景下都能正常显示,不被遮挡,Flutter提供了一系列区域避让的机制和接口。此外,随着折叠屏设备逐渐普及,为了让应用在折叠屏设备上提供良好的用户体验,需要对布局进行特殊处理。FolderStack和FoldSplitContainer这两个组件是折叠屏布局的专用组件。本文将详细介绍Flutter实现区域避让和折叠屏布局组件的原理、适配指导以及具体的场景案例。
使用场景
典型应用全屏窗口UI元素包括状态栏、应用界面和底部导航条,其中状态栏和导航条,通常在全屏沉浸式布局下称为避让区,避让区之外的区域称为安全区。界面元素在不同设备上存在差异,下面是不同设备上状态栏、挖孔区、导航栏的位置差异,包括:直板机、PAD、PC、折叠屏(小折叠、双折、三折)。
说明
下图中标记区域含义:1为状态栏、2为挖孔区、3为导航栏。
图1-1直板机界面元素示意图
图1-2PAD界面元素示意图
图1-3PC界面元素示意图
图1-4折叠屏--小折叠 界面元素示意图(左图展开态 右图折叠态)
图1-5折叠屏--双折叠 界面元素示意图(左图折叠态 右图展开态)
图1-6折叠屏--三折叠 界面元素示意图(左图折叠态 右图二屏折叠态 下图三屏全展开态)
面对上述不同设备的避让区差异,可以按照如下三种场景实现应用沉浸式效果:
1.1 安全区布局场景
布局系统保持安全区内布局,确保应用内容不会延伸到状态栏、导航栏区域。
1.2 安全区布局+背景沉浸模式
布局系统保持安全区内布局,然后延伸绘制内容(如背景色、背景图)到状态栏和导航条区域,实现沉浸式效果。
1.3 全屏布局+避让场景布局系统保持全屏布局,通过相关接口获取避让区域位置、大小等信息,调整元素位置,确保不会被避让区遮挡。
二、实现原理
2.1 区域避让实现原理
区域避让主要借助 AvoidArea API 对外提供能力的接口来实现,该接口包含几个核心方法,用于处理与区域避让相关的操作。
- getWindowAvoidArea:此方法接收一个 AvoidAreaType 类型的参数,通过该参数可以获取当前应用窗口内容需要规避的区域。这些区域可能包括系统栏、刘海屏、手势操作区、软键盘等与窗口内容重叠时需要避让的区域。例如,在处理刘海屏设备时,应用可以通过该方法获取刘海屏区域的信息,从而调整内容布局,避免内容被刘海遮挡。
AvoidAreaPlugin.ets 类中的 getWindowAvoidArea 方法负责获取避让区域信息。通过 FlutterManager 获取窗口实例,然后调用窗口的 getWindowAvoidArea 方法获取相应类型的避让区域,并将结果通过 MethodResult 返回给 Flutter 端。
// packages/avoid_area/ohos/src/main/ets/hadss/avoid_area/AvoidAreaPlugin.ets
getWindowAvoidArea(type: window.AvoidAreaType, result: MethodResult) {
const windowAvoidArea = this.windowClass?.getWindowAvoidArea(type ?? window.AvoidAreaType.TYPE_SYSTEM);
result.success(JSON.stringify(windowAvoidArea));
}
- addAvoidAreaListener:该方法用于添加系统规避区变化事件的监听。当系统规避区发生变化时,如软键盘弹出或收起、设备旋转等,会触发相应的回调函数,应用可以在回调中更新布局。
AvoidAreaPlugin.ets 类中的 addAvoidAreaListener 方法通过监听窗口的 avoidAreaChange 事件,当避让区域发生变化时,将变化信息通过 EventSink 发送给 Flutter 端。
// packages/avoid_area/ohos/src/main/ets/hadss/avoid_area/AvoidAreaPlugin.ets
addAvoidAreaListener(eventSink?: EventSink) {
this.windowClass?.on('avoidAreaChange', (avoidAreaOptions: window.AvoidAreaOptions) => {
eventSink?.success(JSON.stringify(avoidAreaOptions))
})
}
- removeAvoidAreaListener:用于移除之前添加的系统规避区变化事件监听,避免不必要的回调触发,节省系统资源。
AvoidAreaPlugin.ets 类中的 removeAvoidAreaListener 方法通过调用窗口的 off 方法移除 avoidAreaChange 事件的监听。
2.2 折叠屏布局组件实现原理
FoldSplitContainer
folder_split_view.dart 定义了 FoldSplitContainer 组件,用于实现折叠屏二分栏、三分栏在展开态、悬停态以及折叠态的区域控制。
- 状态监听:通过 FolderStackPlugin.folderStateEvents 监听折叠屏状态变化,当状态变化时更新界面。
- 布局构建:根据不同的折叠屏状态(展开、半折叠、折叠)调用不同的布局构建方法,如 _buildExpandedLayout、_buildHoverModeLayout 和 _buildFoldedRegionLayout。
- 比例转换:通过_ratioToFlex 方法将比例转换为整数 flex 因子,用于 Flex 组件的布局。
FolderStack
folder_stack.dart 定义了 FolderStack 组件,继承于 Stack 控件,新增了折叠屏悬停能力,通过识别 upperItems 自动避让折叠屏折痕区后移到上半屏。
- 状态监听:同样通过 FolderStackPlugin.folderStateEvents 监听折叠屏状态变化。
- 布局处理:当设备处于半折叠且折痕方向为水平时,将 upperItems 中的子组件堆叠到上半屏,其他组件堆叠在下半屏。
- 上下屏:通过折痕区域范围算出上下屏占用比例,然后使用 Flex 组件的布局。
HarmonyOS:FolderStateStreamHandler.ets 文件实现了折叠屏状态的监听功能,当折叠屏状态变化时,将状态信息通过 EventSink 发送给 Flutter 端。
// packages/avoid_area/ohos/src/main/ets/hadss/avoid_area/FolderStateStreamHandler.ets
export class FolderStateStreamHandler implements StreamHandler {
private windowEventSink?: EventSink;
private preSuccEvent: string = '';
constructor() {
if (display.isFoldable()) {
display.on('foldStatusChange', this.foldStatusChangeCallback)
display.on('change', this.changeCallback)
}
}
}
接口桥接和公用封装主要在 folder_options.dart 和 folder_plugin.dart 中实现。
- folder_options.dart 定义了折叠屏布局的各种配置选项,如 FoldedRegionLayoutOptions、ExpandedRegionLayoutOptions 等,以及 FoldingFeature 类用于解析从原生传递过来的折叠屏状态信息。
/// packages/avoid_area/lib/component/folder_options.dart
/// 从原生传递过来的参数,折叠屏状态
/// JSON传递 {"state":1,"orientation":0,"bounds":{"left":0,"right":1840,"top":1104,"bottom":1104}}
/// state(折叠状态):0:未知,1:展开,2:折叠,3:半折叠
/// orientation(折痕方向):0:水平,1:垂直
/// bounds(折叠区域):包含四个属性,left、right、top、bottom,分别表示折叠区域的左、右、上、下边界,单位为像素。
class FoldingFeature {
///折叠状态
final FoldStatus state;
///折痕方向
final Axis orientation;
///折叠区域
final Rect bounds;
FoldingFeature(
{this.state = FoldStatus.unknown,
this.orientation = Axis.horizontal,
this.bounds = Rect.zero});
factory FoldingFeature.fromJson(Map<String, dynamic> json) {
FoldStatus stateFromInt(int state) {
switch (state) {
case 1:
return FoldStatus.expanded;
case 2:
return FoldStatus.folded;
case 3:
return FoldStatus.halfFolded;
default:
return FoldStatus.unknown;
}
}
Axis axisFromInt(int orientation) {
switch (orientation) {
case 1:
return Axis.vertical;
default:
return Axis.horizontal;
}
}
return FoldingFeature(
state: stateFromInt(json['state']),
orientation: axisFromInt(json['orientation']),
bounds: Rect.fromLTRB(
(json['bounds']["left"]).toDouble(),
(json['bounds']["top"]).toDouble(),
(json['bounds']["right"]).toDouble(),
(json['bounds']["bottom"]).toDouble()),
);
}
}
- folder_plugin.dart 定义了 FolderStackPlugin 类,用于与原生进行通信,通过 EventChannel 监听折叠屏状态变化。
/// packages/avoid_area/lib/component/folder_plugin.dart
class FolderStackPlugin {
static const EventChannel _folderStatusEventChannel =
EventChannel('avoid_area/folder_state');
static Stream<FoldingFeature>? _folderStatusEvents;
///折叠屏信息变换监听
static Stream<FoldingFeature> get folderStateEvents {
try {
_folderStatusEvents ??= _repeatLatest(
_folderStatusEventChannel
.receiveBroadcastStream()
.map((event) => FoldingFeature.fromJson(jsonDecode(event))),
);
return _folderStatusEvents!;
} catch (e) {
return const Stream.empty();
}
}
}
三、适配指导
3.1 区域避让适配指导
- 安全区布局场景
安全区布局场景主要是为了确保应用内容不会被系统状态栏、导航栏等遮挡。Flutter 提供了 SafeArea 组件,该组件可以自动将内容放置在安全区域内。同时,也可以结合 AvoidArea API 获取的避让区域信息进行更精确的布局。
SafeArea(
child: Container(
// 这里可以放置应用的主要内容
child: Text('这是安全区布局内的内容'),
),
);
- 安全区布局 + 背景沉浸模式
在背景沉浸模式下,需要使用 Stack 组件,背景全屏展示,然后使用 Safearea 包裹安全区域 。
Stack(
children: [
Image.asset(
'assets/background.png',
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
SafeArea(
child: Container(
child: Text('这是安全区布局内的内容'),
),
),
],
)
- 全屏布局 + 避让场景
在全屏布局中,需要隐藏系统状态栏和导航栏,并添加避让区域变化监听。根据避让区域的变化动态调整布局,确保应用在各种情况下都能正常显示。
1. 隐藏状态栏和导航栏。
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays:[]);
2. 添加避让区域变化监听,根据避让区域的变化动态调整布局。
/// samples/avoid_area_sample/lib/game_page.dart
AvoidAreaApi.addAvoidAreaListener((event) {
var result = json.decode(event);
AvoidArea avoidArea = AvoidArea.fromJson(result["area"]);
switch(AvoidAreaType.values[result["type"]]){
// 状态栏
case AvoidAreaType.system:
avoidAreaSystem = avoidArea;
break;
// 刘海区域
case AvoidAreaType.cutout:
avoidAreaCutout = avoidArea;
break;
// 键盘区域
case AvoidAreaType.keyboard:
avoidAreaKeyboard = avoidArea;
break;
// 手势区域
case AvoidAreaType.systemGesture:
avoidAreaSystemGesture = avoidArea;
break;
// 底部导航栏
case AvoidAreaType.navigationIndicator:
avoidAreaNavigationIndicator = avoidArea;
break;
}
update();
});
3.2 折叠屏布局组件适配指导
在使用 FoldSplitContainer 和 FolderStack 组件时,需要根据不同的折叠状态(展开、半折叠、折叠)进行布局适配。需注意这两个组件必须占用整个屏幕,否则位置会计算失败。
1.导入区域避让功能库
import 'package:hadss_avoid_area/hadss_avoid_area.dart';
2. FoldSplitContainer 可以通过配置不同的布局选项,如 FoldedRegionLayoutOptions、ExpandedRegionLayoutOptions 和 HoverModeRegionLayoutOptions,来实现不同状态下的布局效果。
/// packages/avoid_area/example/lib/fold_split_container_ui.dart
class _MyAppState extends State<FoldSplitContainerUI> {
// 展开态布局信息
ExpandedRegionLayoutOptions expandedRegionLayoutOptions =
ExpandedRegionLayoutOptions(
horizontalSplitRatio: PresetSplitRatio.layout_3V2,
verticalSplitRatio: PresetSplitRatio.layout_1V1,
isExtraRegionPerpendicular: true,
extraRegionPosition: ExtraRegionPosition.top);
// 折叠态布局信息
HoverModeRegionLayoutOptions foldingRegionLayoutOptions =
HoverModeRegionLayoutOptions(
horizontalSplitRatio: PresetSplitRatio.layout_3V2,
showExtraRegion: false,
extraRegionPosition: ExtraRegionPosition.top);
// 悬停态布局信息
FoldedRegionLayoutOptions foldedRegionLayoutOptions =
FoldedRegionLayoutOptions(
verticalSplitRatio: PresetSplitRatio.layout_1V1);
@override
Widget build(BuildContext context) {
return Scaffold(
body: FoldSplitContainer(
primary: Container(
// 主要区域布局
),
secondary: Container(
// 次要区域布局
),
extra: Container(
// 扩展区域布局,不传入的情况,没有对应区域
),
// 折叠态布局信息
foldedLayoutOptions: foldedRegionLayoutOptions,
// 展开态布局信息
expandedLayoutOptions: expandedRegionLayoutOptions,
// 悬停态布局信息
hoverModeLayoutOptions: foldingRegionLayoutOptions,
));
}
}
参考链接:fold_split_container_ui.dart。
3. FolderStack 在悬停状态通过识别 upperItems 自动避让折叠屏折痕区后移到上半屏,上方为显示区下方为操作区。
/// packages/avoid_area/example/lib/folder_stack_ui.dart
FolderStack(
// 配置item1和item2悬停状显示在上半屏
upperItems: const ["item1", "item2"],
onFolderStateChange: (state) {
if (kDebugMode) {
print("state=$state");
}
},
children: [
Container(
// item1的Key配置
key: const ValueKey('item1'),
width: double.infinity,
height: double.infinity,
color: Colors.red,
child: const Center(
child: Text("背景"),
),
),
Container(
// item2的Key配置
key: const ValueKey('item2'),
color: Colors.blue,
width: double.infinity,
margin: const EdgeInsets.all(20),
height: 30,
child: const Text(
"弹幕",
textAlign: TextAlign.center,
),
),
Container(
color: Colors.yellow,
child: const Text("标题,返回"),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
width: double.infinity,
height: 100,
color: Colors.grey,
alignment: Alignment.center,
child: const Text(
"进度条",
textAlign: TextAlign.center,
),
),
)
]
)
参考链接:folder_stack_ui.dart。
四、场景案例
4.1 区域避让场景案例
在 avoid_area_sample 示例库中,展示了区域避让的具体使用方法。以下是 AvoidAreaManager 类中的部分代码。
1. 根据固定区域计算出上下左右需要避让的距离,然后用 padding 封装。
/// samples/avoid_area_sample/lib/game_page.dart
List<AvoidArea?> avoidAreaList = [
avoidAreaSystem,
avoidAreaKeyboard,
avoidAreaNavigationIndicator,
avoidAreaSystemGesture
];
var left = 0.0;
var top = 0.0;
var right = 0.0;
var bottom = 0.0;
for (var avoidArea in avoidAreaList) {
if (avoidArea != null) {
if (avoidArea.leftRect.width > left) {
left = avoidArea.leftRect.width / mediaQuery!.devicePixelRatio;
}
if (avoidArea.topRect.height > top) {
top = avoidArea.topRect.height / mediaQuery!.devicePixelRatio;
}
if (avoidArea.rightRect.width > right) {
right = avoidArea.rightRect.width / mediaQuery!.devicePixelRatio;
}
if (avoidArea.bottomRect.height > bottom) {
bottom = avoidArea.bottomRect.height / mediaQuery!.devicePixelRatio;
}
}
}
padding = EdgeInsets.fromLTRB(left, top, right, bottom);
2. 计算异形避让区域(刘海屏)的方位以及距离。
/// samples/avoid_area_sample/lib/game_page.dart
void initAreaCutout() {
CutoutPosition cutoutPosition = CutoutPosition.unknown;
var rect = ui.Rect.zero;
if (mediaQuery != null && avoidAreaCutout != null) {
final AvoidArea avoidArea = avoidAreaCutout!;
double screenWidth = getScreenWidth();
double screenHeight = getScreenHeight();
if (avoidArea.topRect.width != 0 && avoidArea.topRect.height != 0) {
rect = createRect(avoidArea.topRect);
double offset = rect.center.dx - screenWidth / 2.0;
cutoutPosition = calculateCutoutPosition(offset, CutoutPosition.topLeft,
CutoutPosition.topRight, CutoutPosition.topCenter);
} else if (avoidArea.bottomRect.width != 0 &&
avoidArea.bottomRect.height != 0) {
rect = createRect(avoidArea.bottomRect);
double offset = rect.center.dx - screenWidth / 2.0;
cutoutPosition = calculateCutoutPosition(offset, CutoutPosition.bottomLeft,
CutoutPosition.bottomRight, CutoutPosition.bottomCenter);
} else if (avoidArea.leftRect.width != 0 &&
avoidArea.leftRect.height != 0) {
rect = createRect(avoidArea.leftRect);
double offset = rect.center.dy - screenHeight / 2.0;
cutoutPosition = calculateCutoutPosition(offset, CutoutPosition.topLeft,
CutoutPosition.bottomLeft, CutoutPosition.left);
} else if (avoidArea.rightRect.width != 0 &&
avoidArea.rightRect.height != 0) {
rect = createRect(avoidArea.rightRect);
double offset = rect.center.dy - screenHeight / 2.0;
cutoutPosition = calculateCutoutPosition(offset, CutoutPosition.topRight,
CutoutPosition.bottomRight, CutoutPosition.right);
}
}
}
3. 添加区域避让监听,避让区域发生变化后重新计算布局。
/// samples/avoid_area_sample/lib/game_page.dart
void initAvoidAreaListener(){
AvoidAreaApi.addAvoidAreaListener((event) {
var result = json.decode(event);
AvoidArea avoidArea = AvoidArea.fromJson(result["area"]);
switch(AvoidAreaType.values[result["type"]]){
case AvoidAreaType.system:
avoidAreaSystem = avoidArea;
break;
case AvoidAreaType.cutout:
avoidAreaCutout = avoidArea;
break;
case AvoidAreaType.keyboard:
avoidAreaKeyboard = avoidArea;
break;
case AvoidAreaType.systemGesture:
avoidAreaSystemGesture = avoidArea;
break;
case AvoidAreaType.navigationIndicator:
avoidAreaNavigationIndicator = avoidArea;
break;
}
update();
});
}
4. 使用 avoidAreaState.padding 做整体避让,然后根据 avoidAreaState.cutoutPosition 判断挖孔区域避让。
/// samples/avoid_area_sample/lib/game_page.dart
Container(
//使用avoidAreaState.padding整体避让
margin: avoidAreaState.padding,
padding: const EdgeInsets.all(10),
child: Column(
children: [
Row(
children: [
// 挖孔区域在上左区域的时候执行
createSizedBox(CutoutPosition.topLeft,
widthOffset: getWidthOffset(true)),
Expanded(child: _buildTop()),
// 挖孔区域在上右区域的时候执行
createSizedBox(CutoutPosition.topRight,
widthOffset: getWidthOffset(false)),
],
),
Expanded(
child: Row(
children: [
// 挖孔区域在左中区域的时候执行
createSizedBox(CutoutPosition.left,
widthOffset: getWidthOffset(true)),
Expanded(child: _buildCenter()),
// 挖孔区域在右中区域的时候执行
createSizedBox(CutoutPosition.right,
widthOffset: getWidthOffset(false)),
],
),
),
Row(
children: [
// 挖孔区域在下左区域的时候执行
createSizedBox(CutoutPosition.bottomLeft,
widthOffset: getWidthOffset(true)),
Expanded(child: _buildBottom()),
// 挖孔区域在下右区域的时候执行
createSizedBox(CutoutPosition.bottomRight,
widthOffset: getWidthOffset(false)),
],
),
// 挖孔区域在下中区域的时候执行
createSizedBox(CutoutPosition.bottomCenter,
heightOffset: getHeightOffset(false)),
],
),
),
///根据传入的区域和实际区域对比,如果相同就获取对应的宽高创建对应的避让大小
Widget createSizedBox(CutoutPosition cutoutPosition,
{double widthOffset = 0, double heightOffset = 0}) {
return cutoutPosition == avoidAreaState.cutoutPosition
? SizedBox(
width: avoidAreaState.rect.width + widthOffset,
height: avoidAreaState.rect.height + heightOffset,
)
: const SizedBox(
width: 0,
height: 0,
);
}
图1-7
图1-8
4.2 折叠屏布局组件场景案例
- FoldSplitContainer 使用示例
fold_split_container_ui.dart 文件展示了 FoldSplitContainer 组件的使用方法。通过配置不同的布局选项,可以实现折叠态、悬停态和展开态的布局控制。
/// packages/avoid_area/example/lib/fold_split_container_ui.dart
class _MyAppState extends State<FoldSplitContainerUI> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: FoldSplitContainer(
primary: Container(
width: double.infinity,
height: double.infinity,
color: const Color.fromARGB(100, 255, 0, 0),
child: Column(
children: [
const Text('折叠态配置'),
/// ... existing code ...
],
),
),
secondary: Container(
width: double.infinity,
height: double.infinity,
color: const Color.fromARGB(100, 0, 255, 0),
child: SingleChildScrollView(
child: Column(
children: [
const Text('悬停态配置'),
/// ... existing code ...
],
),
),
),
foldedLayoutOptions: foldedRegionLayoutOptions,
expandedLayoutOptions: expandedRegionLayoutOptions,
hoverModeLayoutOptions: foldingRegionLayoutOptions,
));
}
}
图1-9
- FolderStack 使用示例
folder_stack_ui.dart 文件展示了 FolderStack 组件的使用方法。通过配置 upperItems 属性,可以指定悬停态时需要移到上半屏的子组件。
/// packages/avoid_area/example/lib/folder_stack_ui.dart
class _MyAppState extends State<FolderStackUI> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: FolderStack(
upperItems: const ["item1", "item2"],
onFolderStateChange: (state) {
if (kDebugMode) {
print("state=$state");
}
},
children: [
Container(
key: const ValueKey('item1'),
width: double.infinity,
height: double.infinity,
color: Colors.red,
child: const Center(
child: Text("背景"),
),
),
],
),
);
}
}
图1-10
五、示例代码
组件库的地址为:hadss_avoid_area,开发者可以通过该地址查看完整的组件接入指导,并根据自己的需求进行开发。
示例库的地址为:avoid_area_sample,开发者可以通过该地址查看完整的区域避让示例代码,并根据自己的需求进行修改和扩展。
更多推荐
所有评论(0)