一、概述


在移动应用开发中,不同设备的屏幕形态各异,如刘海屏、全面屏、折叠屏等,同时系统状态栏、导航栏、软键盘等元素也会占据屏幕空间。为了确保应用内容在各种设备和场景下都能正常显示,不被遮挡,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,开发者可以通过该地址查看完整的区域避让示例代码,并根据自己的需求进行修改和扩展。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐