🦁

Flutter の SafeArea を正しく理解する。

に公開

はじめに

こんにちは!
株式会社アンドエーアイの荻野と申します!
今回は「Flutter のセーフエリアを正しく理解する」と題して記事を書いていこうと思います!

セーフエリアとは

普段我々が使用しているAndroidやiOSには、システムバーやホームインジケータ等、画面の上下に画面操作を行ったり、端末情報を表示するための領域が存在します。

そして、それらの領域はアプリケーションの上に被さるように表示され、適切な実装がなされていないと、これらがアプリ内のコンテンツを遮ってしまうようなケースが発生します。

こういった問題を防ぐために、画面上に設けられた余白のことをセーフエリアと呼びます。

次項では Flutter におけるセーフエリアの仕組み、正しい実装方法を解説していきます。

セーフエリアの実装

SafeAreaについて

Flutter では文字通りセーフエリアを実装するためのウィジェットとして、SafeAreaというウィジェットが存在しています。

このウィジェットの内部実装は以下のようになっています。

SafeArea
const SafeArea({
    super.key,
    this.left = true,
    this.top = true,
    this.right = true,
    this.bottom = true,
    this.minimum = EdgeInsets.zero,
    this.maintainBottomViewPadding = false,
    required this.child,
  });

...

Widget build(BuildContext context) {
    assert(debugCheckHasMediaQuery(context));
    EdgeInsets padding = MediaQuery.paddingOf(context);
    // Bottom padding has been consumed - i.e. by the keyboard
    if (maintainBottomViewPadding) {
        padding = padding.copyWith(bottom: MediaQuery.viewPaddingOf(context).bottom);
    }

    return Padding(
        padding: EdgeInsets.only(
            left: math.max(left ? padding.left : 0.0, minimum.left),
            top: math.max(top ? padding.top : 0.0, minimum.top),
            right: math.max(right ? padding.right : 0.0, minimum.right),
            bottom: math.max(bottom ? padding.bottom : 0.0, minimum.bottom),
        ),
        child: MediaQuery.removePadding(
            context: context,
            removeLeft: left,
            removeTop: top,
            removeRight: right,
            removeBottom: bottom,
            child: child,
        ),
    );
};

minumum, maintainBottomViewPaddingといったケースに応じた調整のためのパラメータがありますが、大まかに機能を見ると、以下の通り余白を操作しています。

  1. MediaQuery.paddingOf(context)によってMediaQueryからpaddingの値を取得
  2. 取得したpaddingを子に適用
  3. MediaQuerypaddingを削除

3によって、仮にSafeAreaが重複していたとしても最も祖先のSafeAreaが余白を削除するので、多重に余白がついてしまうといった心配はありません。

標準ウィジェット(AppBar, NavigationBar, ListView等)について

実は Flutter においては特に何も対応しなくとも、標準ウィジェットがセーフエリアの対応をしてくれている場合が多いです。

通常Flutterでアプリを作成する場合、Scaffold を用いて ①appBar、②bottomNavigationBar、③body、にそれぞれコンポーネントを配置してレイアウトを行うかと思います。

特に 前者2つ(①②) として、標準で用意されているAppBarBottomNabigationBarは内部でセーフエリアが設定されており、これらのウィジェットを使う場合は、自動的にシステムバーを考慮した余白が適用されることになります。

AppBar
class AppBar extends StatefulWidget implements PreferredSizeWidget {
    ...

    // SafeArea でラップされているため、余白が設定される。
    // (widget.primary は Appbar を画面が上部に配置されているかを示す真偽値)
    if (widget.primary) {
      appBar = SafeArea(bottom: false, child: appBar);
    }

    ...
}

BottomNabigationBar
class BottomNavigationBar extends StatefulWidget {
    // システムバーの高さを取得
final double additionalBottomPadding = MediaQuery.viewPaddingOf(context).bottom;

    ...

    // 余白を設けてシステムバーを回避
    child: Padding(
        padding: EdgeInsets.only(bottom: additionalBottomPadding),
        child: MediaQuery.removePadding(
            context: context,
            removeBottom: true,
            child: ...
        ),
    ),

    ...
}

基本的にAppBarやNavigationBarと併用されることが多いため、目立ちませんがListViewGridViewといったBoxScrollViewクラスも内部にセーフエリアが仕込まれています。

セーフエリアを考慮した独自コンポーネントの実装

前項に記載した通り、Flutter標準ウィジェットを使用することで、開発者は効率的にセーフエリアを実装することができます。

しかしながら、独自に実装したコンポーネントを画面の上部や下部に配置するケースでは、自身でセーフエリアをコンポーネント内に組み込む必要があります。

次項では独自のAppBar実装しながら適切なセーフエリアの設定を確認していきます。

特になにも考慮しない実装

まずは特に何も考えずに以下のように実装します。
このままビルドするとどのようになるでしょうか。

class _CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
  
  Widget build(BuildContext context) {
    final textStyle = TextTheme.of(context)
        .displayLarge
        ?.copyWith(color: Theme.of(context).colorScheme.onPrimary);

    return Container(
      color: Theme.of(context).primaryColor,
      child: Center(
        child: Text(
          'Header',
          style: textStyle,
        ),
      ),
    );
  }

  
  Size get preferredSize => const Size(double.infinity, 64);
}

画像の通りAppBar内のタイトルがシステムバーおよびダイナミックアイランドに重なってしまっています。

SafeAreaを追加する

このままではコンテンツを十分に表示できませんので、ここでSafeAreaウィジェットを使用します。

return Container(
  color: Theme.of(context).primaryColor,
  child: SafeArea(
    child: Center(
      child: Text(
        'Header',
        style: textStyle,
      ),
    ),
  ),
);

これにより、綺麗にコンテンツがシステムUIを避けて表示されるようになりました。

補足:SingleChildScrollについて

標準ウィジェットについての項で少し記載しましたが、SingleChildScrollViewListViewなどと同じようにスクローラブルなウィジェットですが、BoxScrollViewクラスではないので、そのままだと端までスクロールしてもコンテンツがシステムUIに隠れてしまう問題が発生します。

ヘッダやフッタがない画面でSingleChildScrollViewを使用する際は、忘れず子をSafeAreaで囲むようにしましょう。

SafeArea有り SafeArea無し

最後に

Flutterでは個々のコンポーネントがそれぞれ内部でセーフエリアを持つことでシステムUIの余白を解決します。
これはすなわち、コンポーネントを独自実装する際、その画面の上端/下端に配置される可能性があるかどうかをあらかじめ念頭にいれて実装を行う必要があります。

個々の対応としてはそこまで難しくないですが、コンポーネントごとに対応する必要がある以上、数があったり、対応漏れがないかの確認作業で地味に時間を取られる可能性があります。

最初の実装段階で全てのケースに耐えられるUIパーツを作成することは難しいですが、
余計な手戻りを防ぐためにも、UI作成は起こりうる問題をある程度知識として知っておくことが大事だと、この記事を書いていて私自身も再認識しました。

PR

アンドエーアイでは事業拡大のため、即戦力エンジニアを募集中!Flutterだけでなく、インフラ、Web、ネイティブ開発などの知識を持つ方も歓迎します。最新技術を追い、チームに積極的に貢献できる方をお待ちしています!

採用ページ
https://iwantyou.andai.net/

エンジニア採用ページ
https://iwantyou.andai.net/engineer

アンドエーアイTechBlog

Discussion