[Flutter]BottomAppBarを使ってボタンをめり込ませたナビゲーション(下タブ)を作る

この記事ではFlutterでボタンを避けるようにノッチの形状になった下タブを実現できるBottomAppBarを取り上げます。
2020.05.21

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

最近やっと今Flutterで使われているState managementの手法を一通り触ってユースケースに応じて選択できるようになったのですが、UIに関しては実装したことがなかったり知らないといったことがまだまだあります。

今回はボトムのナビゲーション、いわゆる下タブのカスタマイズを探している時に知ったBottomAppBarについて記事を書こうと思います。

この記事で説明すること

  • FlutterでBottomAppBarとFloatingActionButtonを使った下タブのカスタマイズ
  • BottomAppBarとBottomNavigatoinBarの違い

BottomAppBarの内部実装についてもソースコードを読みつつ少しだけ触れます。

環境

BottomAppBarとは

通常はScaffold.bottomNavigationBarでContainerです。このWidgetを使うことで簡単にFloatingActionButtonが重複する場所を避けるようにノッチを作ることができます。

まずは一旦基本的な実装をひと目でみるためサンプルを用意しました。

コードは以下になります。codepenで何故か真っ白になり表示されなかったのでコードを載せます。

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Sample1'),
        ),
        floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
        floatingActionButton: FloatingActionButton(
          backgroundColor: Theme.of(context).accentColor,
          onPressed: () {},
          child: Icon(Icons.add),
        ),
        bottomNavigationBar: BottomAppBar(
          color: Theme.of(context).primaryColor,
          notchMargin: 6.0,
          shape: AutomaticNotchedShape(
            RoundedRectangleBorder(),
            StadiumBorder(
              side: BorderSide(),
            ),
          ),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8.0),
            child: new Row(
              mainAxisSize: MainAxisSize.max,
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                IconButton(
                  icon: Icon(
                    Icons.person_outline,
                    color: Colors.white,
                  ),
                  onPressed: () {},
                ),
                IconButton(
                  icon: Icon(
                    Icons.info_outline,
                    color: Colors.white,
                  ),
                  onPressed: () {},
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

実行すると以下のように表示されます。

中央のボタンをさけるように下タブがノッチを持っているのがわかります。

実装方法やカスタマイズ

実装方法はScaffoldのコンストラクタ引数で行います。floatingActionButtonLocationでFloatingActionButtonLocationの値を渡してFloatingActionButton(以下、FAB)の位置を決めます。

        floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,

サンプルコードではcenterDockedを指定しています。static constantで値を返すようになっていてFABにノッチを作れるのはcenterDockedの他にendDockedがあります。

  /// Center-aligned [FloatingActionButton], floating over the
  /// [Scaffold.bottomNavigationBar] so that the center of the floating
  /// action button lines up with the top of the bottom navigation bar.
  ///
  /// If the value of [Scaffold.bottomNavigationBar] is a [BottomAppBar],
  /// the bottom app bar can include a "notch" in its shape that accommodates
  /// the overlapping floating action button.
  ///
  /// This is unlikely to be a useful location for apps that lack a bottom
  /// navigation bar.
  static const FloatingActionButtonLocation centerDocked = _CenterDockedFloatingActionButtonLocation();

endDockedを指定した場合の表示は以下になります。

FABの表示位置のカスタマイズについて

enumではなくstatic constantなので位置のカスタマイズは不可能ではなさそうです。ということでソースを読んでみましょう。

先程引用したFloatingActionButtonLocation.centerDockedのソースコードを見ると_CenterDockedFloatingActionButtonLocation()とあります。さらに追っていくと_CenterDockedFloatingActionButtonLocationというクラスのインスタンスであることがわかります。そしてこのクラスは_DockedFloatingActionButtonLocationというabstract classをextendsしています。_DockedFloatingActionButtonLocationはFloationgActionButtonLocationを継承しているので結局FloationgActionButtonLocationを返しているわけですが、カスタマイズするなら_DockedFloatingActionButtonLocationをextendsして自作すれば良さそうです。

_DockedFloatingActionButtonLocationの実装

// Provider of common logic for [FloatingActionButtonLocation]s that
// dock to the [BottomAppBar].
abstract class _DockedFloatingActionButtonLocation extends FloatingActionButtonLocation {
  const _DockedFloatingActionButtonLocation();

  // Positions the Y coordinate of the [FloatingActionButton] at a height
  // where it docks to the [BottomAppBar].
  @protected
  double getDockedY(ScaffoldPrelayoutGeometry scaffoldGeometry) {
    final double contentBottom = scaffoldGeometry.contentBottom;
    final double bottomSheetHeight = scaffoldGeometry.bottomSheetSize.height;
    final double fabHeight = scaffoldGeometry.floatingActionButtonSize.height;
    final double snackBarHeight = scaffoldGeometry.snackBarSize.height;

    double fabY = contentBottom - fabHeight / 2.0;
    // The FAB should sit with a margin between it and the snack bar.
    if (snackBarHeight > 0.0)
      fabY = math.min(fabY, contentBottom - snackBarHeight - fabHeight - kFloatingActionButtonMargin);
    // The FAB should sit with its center in front of the top of the bottom sheet.
    if (bottomSheetHeight > 0.0)
      fabY = math.min(fabY, contentBottom - bottomSheetHeight - fabHeight / 2.0);

    final double maxFabY = scaffoldGeometry.scaffoldSize.height - fabHeight;
    return math.min(maxFabY, fabY);
  }
}

_DockedFloatingActionButtonLocation はアンダースコアがついていてprivateなので外から参照できないため。そのため、実装を流用しながらx軸方向のみ調整するようにFloatingActionButtonLocationのサブクラスを作ってextensionを生やします。

extension CustomizeFloatingActionButtonLocation
    on FloatingActionButtonLocation {
  static FloatingActionButtonLocation customizedOffset(
          {@required double aspect}) =>
      DockedFloatingActionButtonLocation(aspect: aspect);
}

class DockedFloatingActionButtonLocation extends FloatingActionButtonLocation {
  const DockedFloatingActionButtonLocation({@required this.aspect});

  final double aspect;

  @protected
  double getDockedY(ScaffoldPrelayoutGeometry scaffoldGeometry) {
    final double contentBottom = scaffoldGeometry.contentBottom;
    final double bottomSheetHeight = scaffoldGeometry.bottomSheetSize.height;
    final double fabHeight = scaffoldGeometry.floatingActionButtonSize.height;
    final double snackBarHeight = scaffoldGeometry.snackBarSize.height;

    double fabY = contentBottom - fabHeight / 2.0;
    if (snackBarHeight > 0.0)
      fabY = math.min(
          fabY,
          contentBottom -
              snackBarHeight -
              fabHeight -
              kFloatingActionButtonMargin);
    if (bottomSheetHeight > 0.0)
      fabY =
          math.min(fabY, contentBottom - bottomSheetHeight - fabHeight / 2.0);

    final double maxFabY = scaffoldGeometry.scaffoldSize.height - fabHeight;
    return math.min(maxFabY, fabY);
  }

  @override
  Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
    final double fabX = (scaffoldGeometry.scaffoldSize.width -
            scaffoldGeometry.floatingActionButtonSize.width) /
        aspect;
    return Offset(fabX, getDockedY(scaffoldGeometry));
  }

  @override
  String toString() => 'FloatingActionButtonLocation.centerDocked';
}

これを以下のように使うとx軸方向に任意の場所にFABを表示できていることがわかります。

 floatingActionButtonLocation: CustomizeFloatingActionButtonLocation.customizedOffset(aspect: 3.0),

作っておいてこう書くのも憚れますがフレームワークで位置をカスタマイズできるような書き方を用意していない場合、無理にカスタマイズすると潜在的なバグの原因になったり、Flutterが則っているMaterial Designに即していないカスタマイズをしてしまう恐れもあるので個人的にはこの手のことはしたくない気持ちです。

また、この書き方だと一部実装がprivateになっていてそれをそのまま流用しているので、将来の内部的な変更に対応できないためよくないです。

bottomNavigationBar引数にBottomAppBarをセット

floatingActionButtonに関するconstructor引数を指定したらbottomNavigationBarです。型はWidgetです。ここにBottomAppBarのインスタンスを渡します。

constructor引数の内、解説が必要なのは

  • clipBehavior
  • elevation
  • notchMargin
  • shape

の4つで、今回のBottomNavigationBarを使ったノッチのレイアウトに関わるのはnotchMarginとshapeです。clipBehaviorとelevationは他のWidgetでも出てくるので直接は今回の話とは関わらないです。とはいえ何のためのプロパティなのかはあらかじめ説明しておきたいと思います。

clipBehavior

clipBehaviorにはClip型の値を渡す必要があります。コンピュータグラフィックスにおいてレンダリングする領域に対して制限を加えることをClippingと言います。

そしてClipのドキュメンテーションコメントを読むとDifferent ways to clip a widget's content.と記載されています。

このenumを指定することでClippingをどのように行うか決定することができます。Clip.hardEdgeがアンチエイリアスなしで最速です。そしてClip.antiAliasWithSaveLayerがsaveLayerを使って忠実にClippingします。

デフォルトではnoneになっているのでClippingを意識しないようならnoneで良いと思っています。

Clippingについて解説している記事でわかりやすかったのは以下でした。

elevation

これを使ってWidgetの下の影のサイズを制御します。FABをカスタマイズして影がわかりやすいように色も白に近い色に設定してみます。

ボタンが少し盛り上がっているように見えていると思います。elevationに0.0を指定します。

先程と全く異なり盛り上がっているようなスタイルではなく平面に表示されているようなUIになっているのが確認できます。

notchMargin

FABとBottomAppBarとのマージンを指定します。このConstructor引数に指定したdouble型の値分マージンが作られます。

最初のサンプルコードのBottomAppBarのnotchMarginプロパティに0を指定してみると以下のような表示になります。

最初のサンプルコードでは8.0を指定していますが、先ほどと反対に8.0より大きな数字、今回は20.0を指定してみます。

マージンが広く取られているのが確認できます。

shape

このConstructor引数にはNotchedShapeの値を渡す必要があります。命名から分かる通りノッチの形状をここで指定できます。

NotchedShapeはソースコードやドキュメントを参照すると分かる通りabstract classです。ドキュメントにAutomaticNotchedShapeとShapeBorderを参照するように案内されているので確認してみます。ShapeBorderは外形の形を決めるために提供されている基底クラスです。そしてAutomaticNotchedShapeはShapeBorderからNotchedShapeを生成します。

サンプルコードではAutomaticNotchedShapeを使っていましたが、NotchedShapeを継承しているCircularNotchedRectangleを使ってみた例が以下です。

shape: CircularNotchedRectangle()

サンプルコードで使っていたAutomaticNotchedShapeの使い方を知るためにAutomaticNotchedShapeのソースコードを見てみます。

class AutomaticNotchedShape extends NotchedShape {

  const AutomaticNotchedShape(this.host, [ this.guest ]);
  final ShapeBorder host;
  final ShapeBorder guest;

  @override
  Path getOuterPath(Rect hostRect, Rect guestRect) { // ignore: avoid_renaming_method_parameters, the
    // parameters are renamed over the baseclass because they would clash
    // with properties of this object, and the use of all four of them in
    // the code below is really confusing if they have the same names.
    final Path hostPath = host.getOuterPath(hostRect);
    if (guest != null && guestRect != null) {
      final Path guestPath = guest.getOuterPath(guestRect);
      return Path.combine(PathOperation.difference, hostPath, guestPath);
    }
    return hostPath;
  }
}

getOuterPathはabstract classのNotchedShapeを継承する時に実装しないといけないメソッドです。コンストラクタを見るとShapeBorderクラスのプロパティ用にコンストラクタ引数が必要であることがわかります。

hostはNotchedShapeを使用するウィジェットの形状です。ここでいうウィジェットはドキュメントに記載されている通り通常はBottomAppBarを指します。guestはノッチを作成するために、FABのためのスペースを作るためにhostから差し引くのに使用されます。

つまり、この値でノッチの形状を指定することができます。

shapeにRoundedRectangleBorderを2つ渡すと角丸を除去できます。

AutomaticNotchedShape(
  RoundedRectangleBorder(),
  RoundedRectangleBorder(),
)

RoundedRectangleBorderを使って一部分だけ角丸にしてみます。

AutomaticNotchedShape(
  RoundedRectangleBorder(),
  RoundedRectangleBorder(
    borderRadius: BorderRadius.all(Radius.circular(10)),
  ),
)

このようにAutomaticNotchedShapeを使うと柔軟にノッチの形状を操作できます。

BottomAppBarとBottomNavigationBarの違い

FABを避けるようにノッチを作ることはBottomNavigationBarでは不可能というわけではありません。しかし、簡単に実装できる手段が提供されているのでこのようなレイアウトを実現したい時はBottomAppBarを使用するのが良いと考えています。

ドキュメントにもBottomAppBarの定義として一般にScaffold.bottomNavigationBarで使用され、上部にノッチを配置してFABのためのスペースを作ることができるウィジェットと紹介されています。そのようなレイアウトを使いたい時はドキュメントに従いBottomAppBarを使うのが良いでしょう。

BottomAppBarはBottomNavigationBarに比べるとコードの記述量は多くなりますが、BottomAppBarの利点としてFABの下に空のテキストを追加したりバーの中のWidgetの融通が効くのがメリットです。

BottomNavigationBarについては過去に記事を書いたので気になる方は御覧ください。

まとめ

アプリ開発、楽しいですが特定のアプリの製作だけをやっていると知らないパーツを知る機会がないのでインプットが欠かせません。動画を観るだけ、記事を読むだけだとすぐに忘れるのでこのような場所はありがたいです。これからもインプットとアウトプット両方を続けていきたいと思います。また、記事や認識に誤りがあるかもしれません。何かお気づきの際はコメントかTwitterにてお知らせください。