アプリのSafeAreaを適切に処理して体験を向上させる

こんにちは。株式会社レスキューナウにアプリエンジニアとして参画している伊藤です。
社内では、アプリ開発にFlutterを使っています。そこで、スマートフォンにおけるSafeAreaの適切な実装をFlutterのコードを用いて説明しようと思います。

SafeAreaについて

スマートフォンには、SafeArea(下記画像の赤点線の領域)と呼ばれる領域があります。SafeAreaは、ステータスバーやiPhoneのHome IndicatorといったOSレベルで扱う領域とアプリのコンテンツが重ならず、ユーザーの操作を妨げることがない領域を指します。

左: AppBar及びBottomNavigationBarなし    右: AppBar及びBottomNavigationBarあり

Android、iOS問わずSafeAreaは存在し、特にベゼルがない機種が多い昨今ではSafeAreaを適切に扱わないと、操作しにくくなったり、コンテンツが途切れたり、不自然な位置でコンテンツが見えなくなったりとユーザーに不利益を被ります。
以下のフルーツの一覧をリスト表示する例を元に説明していきます。
なお、AppBarやBottomNavigationBarがある場合は、自ずとステータスバーやHome Indicatorと干渉することがないようになるので、説明は割愛します。

// fruits.dart

class Fruit {
  const Fruit({required this.name, required this.emoji});

  final String name;
  final String emoji;
}

// 'フルーツ'を変換して出てくるので以下、全てフルーツ
const List<Fruit> fruits = [
  Fruit(name: 'さくらんぼ', emoji: '🍒'),
  Fruit(name: 'りんご', emoji: '🍎'),
  Fruit(name: 'バナナ', emoji: '🍌'),
  Fruit(name: 'オレンジ', emoji: '🍊'),
  Fruit(name: 'ぶどう', emoji: '🍇'),
  Fruit(name: 'スイカ', emoji: '🍉'),
  Fruit(name: 'マンゴー', emoji: '🥭'),
  Fruit(name: '青リンゴ', emoji: '🍏'),
  Fruit(name: 'イチゴ', emoji: '🍓'),
  Fruit(name: 'レモン', emoji: '🍋'),
  Fruit(name: '桃', emoji: '🍑'),
  Fruit(name: '洋梨', emoji: '🍐'),
  Fruit(name: '栗', emoji: '🌰'),
  Fruit(name: 'キウイ', emoji: '🥝'),
  Fruit(name: 'パイナップル', emoji: '🍍'),
];


SafeAreaを適切に扱わなかった例

case1. SafeAreaを未設定の時

まずSafeAreaを未設定にした場合、明らかに不具合に見えるため、SafeAreaなしで作ることはないと思いますが、一例として記載しておきます。

下記画像のように、ノッチにさくらんぼ文字が被って見えなくなったり、Home Indicatorにパイナップルの文字が重なっています。スクロールしても重なったまま表示されており、リストをタップして詳細画面に遷移する場合、誤ってHome Indicatorが反応し、ホーム画面に戻ってしまうかもしれません。

左: スクロール前    右: スクロール後

以下、コード例

// fruits_list_page.dart

class FruitsListPage extends StatelessWidget {
  const FruitsListPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SingleChildScrollView(
        child: Column(
          children: fruits
              .map(
                (e) => Column(
                  children: [
                    ListTile(
                      leading: Text(
                        e.emoji,
                        style: const TextStyle(fontSize: 32.0),
                      ),
                      title: Text(
                        e.name,
                        style: const TextStyle(fontSize: 24.0),
                      ),
                    ),
                    const Divider(height: 1.0),
                  ],
                ),
              )
              .toList(),
        ),
      ),
    );
  }
}


case2. SafeAreaの設定位置が適切ではない時

SafeAreaは設定されているものの設定位置が適切ではなく、操作上は問題ないものの、スクロールした際に不自然な位置で見えなくなる例です。
世の中のアプリで1番多く見かける例でもあります。

操作上問題がない分、軽視されがちで見逃されていますが、不自然な挙動は無意識下で違和感を覚えさせるため、体験としては良くありません。

下記画像のようにステータスバーやHome Indicatorにフルーツ名が重なることはなくなりましたが、左の画像では栗の下にあるキウイが見切れています。同様に右の画像ではバナナの上にあるりんごが見切れています。
スクロールした際に、ディスプレイとしての表示領域はあるにも関わらず、SafeArea外に入った途端コンテンツが途切れるため、違和感を覚える挙動をします。

左: スクロール前    右: スクロール後

以下、コード例

// fruits_list_page.dart

class FruitsListPage extends StatelessWidget {
  const FruitsListPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: SingleChildScrollView(
          child: Column(
            children: fruits
                .map(
                  (e) => Column(
                    children: [
                      ListTile(
                        leading: Text(
                          e.emoji,
                          style: const TextStyle(fontSize: 32.0),
                        ),
                        title: Text(
                          e.name,
                          style: const TextStyle(fontSize: 24.0),
                        ),
                      ),
                      const Divider(height: 1.0),
                    ],
                  ),
                )
                .toList(),
          ),
        ),
      ),
    );
  }
}


SafeAreaを適切に扱った例

最後に適切にSafeAreaを扱った例を挙げます。

case3. SafeAreaを設定

適切にSafeAreaを処理した結果、下記画像のようにスクロール前の左の画像では、キウイが見切れることなく表示され、右の画像ではりんごが見切れずに表示されています。その上、スクロール停止位置がステータスバーやHome Indicatorに重ならず、誤操作の心配もありません。

左: スクロール前    右: スクロール後

以下、コード例

// fruits_list_page.dart

class FruitsListPage extends StatelessWidget {
  const FruitsListPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SingleChildScrollView(
        child: SafeArea( // SafeAreaの位置は、ScrollView系の下に配置するのが適切
          child: Column(
            children: fruits
                .map(
                  (e) => Column(
                    children: [
                      ListTile(
                        leading: Text(
                          e.emoji,
                          style: const TextStyle(fontSize: 32.0),
                        ),
                        title: Text(
                          e.name,
                          style: const TextStyle(fontSize: 24.0),
                        ),
                      ),
                      const Divider(height: 1.0),
                    ],
                  ),
                )
                .toList(),
          ),
        ),
      ),
    );
  }
}


case4. ListViewを用いる

ここまでSafeAreaのWidgetを用いて書いてきましたが、実はListViewを用いることで、SafeAreaが適切に扱われます。
case3. と見た目は変わらないですが、スクリーンショットを載せておきます。

ListViewは、SingleChildScrollView + SafeArea + Columnを組み合わせたような処理を内部で行っています。厳密には違うのですが、詳しく知りたい人は、ListViewの定義からソースコードを読んでみると良いです。

左: スクロール前    右: スクロール後

以下、コード例

// fruits_list_page.dart

class FruitsListPage extends StatelessWidget {
  const FruitsListPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView.separated(
        itemBuilder: (_, index) {
          final fruit = fruits[index];

          return ListTile(
            leading: Text(
              fruit.emoji,
              style: const TextStyle(fontSize: 32.0),
            ),
            title: Text(
              fruit.name,
              style: const TextStyle(fontSize: 24.0),
            ),
          );
        },
        separatorBuilder: (_, __) => const Divider(height: 1.0),
        itemCount: fruits.length,
      ),
    );
  }
}


まとめ

今回は、FlutterのSafeAreaでお話ししましたが、NativeであるAndroidのUI ComponentsやiOSのUIKit、SwiftUIにも通じる話です。
普段触っているアプリでSafeAreaが適切な作りになっているか確認してみてください。きっと適切に処理されていないアプリの多いことに気づくでしょう。

多くのアプリがSafeAreaを適切に扱えてないということは、逆に言えば適切に扱うことで、アプリの体験を1歩リードできるということです。
こういった小さな積み重ねでアプリの品質は差が出てくると思うので、気をつけていきたいですね!

最後に

現在、レスキューナウでは、災害情報の提供、災害情報を活用した安否確認サービスなどのWebサービスの開発エンジニアを募集しています!
社員・フリーランスに関わらず、参画後に安心してご活躍できることを目指し、応募された方の特性・ご希望にマッチしたチームをご紹介します。
ちょっと話を聞いてみたい、ぜひ応募したい、など、当社にご興味を持っていただけましたら、お気軽にエントリーください!!