BottomNavigationBarを固定したまま画面遷移する方法をまとめてみる

iOS版のTwitterなどで使われているお馴染みのUIです。ググれば様々な実装方法が出てきますが、たくさんあって混乱するので一度ここにまとめたいと思います。

<img width="360" alt="twitter" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112065/44165578-3b9f-47e8-c0d6-22583f7f88ed.gif">

# 1. CupertinoTab系のWidgetを使う

FlutterにはiOS風のUIを再現するために`Cupertino*`と名のつくWidget群が用意されており、その中の`CupertinoTabScaffold`や`CupertinoTabBar`等を使うと固定タブバーが実現できます。この方法については以下の記事が詳しいです。

https://qiita.com/canisterism/items/d648da85c300a3751db0

また公式ドキュメントにはブラウザで動かせるインタラクティブな実装例が掲載されているのでオススメです。

https://api.flutter.dev/flutter/cupertino/CupertinoTabScaffold-class.html

<img width="360" alt="cupertino" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112065/a09c975d-164d-8921-b6e7-f12fd83144b6.gif">



ただしこの方法だと`CupertinoTabBar`以外のタブバーが使えません。[BottomNavigationBar](https://api.flutter.dev/flutter/material/BottomNavigationBar-class.html)や[animated_bottom_navigation_bar](https://pub.dev/packages/animated_bottom_navigation_bar)、[curved_navigation_bar](https://pub.dev/packages/curved_navigation_bar)のようなサードパーティー製のタブバーなどを使用したい場合は他の手法を選択する必要があります。

# 2. persistent_bottom_nav_barを使う

読んで字の如しなパッケージがあります。タブバーの見た目にはいくつか選択肢があり、かなり柔軟に使用できそうです。また拡張することで自分好みのスタイルも作ることができるようです。

https://pub.dev/packages/persistent_bottom_nav_bar

![persistentbottomnavbar](https://github.com/BilalShahid13/PersistentBottomNavBar/raw/master/gifs/persistent.gif)


# 3. go_routerを使う

[go_router](https://pub.dev/packages/go_router)は[Navigator2.0](https://medium.com/flutter/learning-flutters-new-navigation-and-routing-system-7c9068155ade)を簡単に利用できるルーティングライブラリです。go_routerでは`ShellRoute`による階層的なナビゲーションもサポートされており、これにより固定タブバーの永続化が実現できます。日本語記事では

https://zenn.dev/10_tofu_01/scraps/0342ed7ab2b1d8

あたりが分かりやすいかと思います。公式にもサンプルが用意されているため、go_routerをすでに使用している場合はこれを利用するのが一番楽かもしれません。

https://pub.dev/documentation/go_router/latest/go_router/ShellRoute-class.html

https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/shell_route.dart

ただし、現状`ShellRoute`は各タブの状態を保持してくれません。例えばホームタブで作業したあと検索タブに遷移し、その後ホームタブに再び戻ると作業状態がリセットされてしまいます。

<img width="360" alt="shellroute" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112065/03504e76-89bb-72ff-af09-9c7893df9460.gif">



この問題についてはすでにGitHub上でissueが挙がっており、現在はプルリクエストのマージ待ちのようです。

https://github.com/flutter/flutter/issues/99124

https://github.com/flutter/packages/pull/2650

## 追記（2023/5/27）

マージされたようですね。v7.1から使えるそうです。

https://github.com/flutter/packages/pull/2650

# 4. navigator_scopeを使う

宣伝です。このために記事を書いたようなものです。前述の`CupertinoTab*`で実装されている入れ子のナビゲーション機能を抜き出し、より一般化してパッケージにまとめました。

https://github.com/fujidaiti/navigator_scope

とにかくシンプルなことが売りです。Navigator2.0は複雑すぎ、`Navigation.push`して`Navigation.pop`するだけで十分、という場合におすすめです。新しいAPIを学ぶ必要もありません。また[BottomNavigationBar](https://api.flutter.dev/flutter/material/BottomNavigationBar-class.html)だけでなく、[NavigationBar](https://api.flutter.dev/flutter/material/NavigationBar-class.html#material.NavigationBar.2)や[NavigationDrawer](https://api.flutter.dev/flutter/material/NavigationDrawer-class.html)（横から飛び出すメニュー）、さらには[animated_bottom_navigation_bar](https://pub.dev/packages/animated_bottom_navigation_bar)や[curved_navigation_bar](https://pub.dev/packages/curved_navigation_bar)などの著名なサードパーティー製のタブバーも自由に利用できます。

<img width="360" alt="navigatorscope" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112065/8d3031e4-7078-b0de-eac8-d0be5b9afb8a.gif">


使い方は簡単で、`NavigatorScope`の中で各タブのNestedNavigatorを作るだけです。もちろん画面遷移の前後で各タブの状態は保持されます。

```dart
Scaffold(
  bottomNavigationBar: NavigationBar( // お好きなタブバーをひとつ
    selectedIndex: selectedTabIndex,
    destinations: tabs,
    onDestinationSelected: (index) {
        setState(() => selectedTabIndex = index);
    },
    ...
  ),
  body: NavigatorScope( // ローカルなNavigatorのハブとなるWidget
    currentDestination: selectedTabIndex, // 現在アクティブなタブのインデックス
    destinationCount: tabs.length,
    destinationBuilder: (context, index) {
      return NestedNavigator( // ローカルなNavigator
        // このNavigatorのデフォルトのページを作成する
        builder: (context) => Container(),
      );
    },
  ),
);
```

タブを切り替えるときは`currentDestination`の値を変えて`NavigatorScope`をrebuildするだけです。タブ内で画面遷移する場合は通常通り`Navigator.push`や`Navigator.pop`を使用します。詳しくは[README](https://github.com/fujidaiti/navigator_scope/tree/master)をご覧ください。

# 5. 自前で実装する

最終手段です。Deep linkingやらNavigator2.0やらを考慮しだすと複雑になりますが、基本的な部分だけを抜き出してみると案外簡単に実装できます。

## CupertinoTab*の実装を理解する

前述の通り、`CupertinoTab*`系のWidget群はデフォルトで固定タブバーの機能を提供しています。特に重要なのは`CupertinoTabView`と`CupertinoTabScaffold`で、前者はタブの`Navigator`を内部で保持し、後者は各タブの切り替えとその`Navigator`の状態保持を担当しています。Widgetツリーのイメージとしては下のような感じです。

```
CupertinoTabScaffold
  └ _TabSwitchingView
      ├ CupertinoTabView
      │   └ Navigator
      ├ CupertinoTabView
      ...
```

`_TabSwitchingView`は`CupertinoTabScaffold`内で使われているプライベートなクラスで、`Navigator`の切り替えや状態保持を実質的に担当しているWidgetです。またこれらは全てStatefulWidgetとして定義されているので、以後「`CupertinoTabView`の`build`メソッド」のような表記がされていた場合は「対応する`State`クラスの`build`メソッド」と読み替えてください。

### CupertinoTabView

細かい話を抜きにすれば、`CupertinoTabView`がしていることは`Navigator`を１つ保持する、ただそれだけです。実際`build`メソッドは以下のようにとてもシンプルな実装です（[ソースコード](https://github.com/flutter/flutter/blob/9a68bffb7b3c8d85da3a4895bba7fc5e323444fd/packages/flutter/lib/src/cupertino/tab_view.dart#L165-L174)）。

```dart
// _CupertinoTabViewState
  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: widget.navigatorKey,
      onGenerateRoute: _onGenerateRoute,
      onUnknownRoute: _onUnknownRoute,
      observers: _navigatorObservers,
      restorationScopeId: widget.restorationScopeId,
    );
  }
```

初期ページを作成したり新しいページが`Navigator.pushNamed`されたりすると`onGenerateRoute`が呼ばれます。ここで作成された`Route`（デフォルトでは`CupertinoPageRoute`）がページスタックに積まれることになるのですが、今回は関係ないので割愛します。

### CupertinoTabScafold

各タブの`Navigator`（`CupertinoTabView`）は`CupertinoTabScaffold`内の`_TabSwitchingView`が管理しています。`CupertinoTabScaffold`の`build`メソッドを見てみましょう（[ソースコード](https://github.com/flutter/flutter/blob/1459633486847ea3dba9aa649b2d5013a45722ec/packages/flutter/lib/src/cupertino/tab_scaffold.dart#L305-L384)）。重要な部分以外は省略しました。`_controller.index`が現在アクティブなタブのインデックスを表しており、これが変わると`setState`でrebuildが走るように実装されています。また各`Nvigator`は`widget.tabBuilder`を通して`_TabSwitchingView`に渡されます。

```dart
// _CupertinoTabScaffoldState
@override
  Widget build(BuildContext context) {
    ...
    Widget content = _TabSwitchingView(
      currentTabIndex: _controller.index, // これが変わるとrebuild
      tabCount: widget.tabBar.items.length,
      tabBuilder: widget.tabBuilder, // 各タブのCupertinoTabViewを作成するbuilder
    );
    ...
    return DecoratedBox(
      ...
      child: Stack(
        children: <Widget>[
          content,
          ...
        ],
      ),
    );
  }
```

最後に`_TabSwitchingView`の`build`メソッドも見てみましょう（[ソースコード](https://github.com/flutter/flutter/blob/9a68bffb7b3c8d85da3a4895bba7fc5e323444fd/packages/flutter/lib/src/cupertino/tab_scaffold.dart#L483-L509)）。こちらも至ってシンプルです。ネストが少々深いですが、要点は「前述の`tabBuilder`が作る`CupertinoTabView`を`Offstage`で囲み、`Stack`で重ねる」ことです。`Offstage`を使うのは非アクティブなタブを非表示にするためです。`HeroMode`や`TickerMode`にも囲まれていますが、これは非表示タブのアニメーションを無効化するためです。親の`CupertinoTabScaffold`に巻き込まれてrebuildされると各タブの`active = index == widget.currentTabIndex;`の値が更新され、タブが切り替わります。

```dart
// _TabSwitchingViewState
@override
  Widget build(BuildContext context) {
    return Stack(
      fit: StackFit.expand,
      children: List<Widget>.generate(widget.tabCount, (int index) {
        // 各タブがアクティブかどうか？
        final bool active = index == widget.currentTabIndex;
        shouldBuildTab[index] = active || shouldBuildTab[index];

        return HeroMode(
          enabled: active,
          child: Offstage(
            offstage: !active, // 非アクティブなタブは非表示
            child: TickerMode(
              enabled: active,
              child: FocusScope(
                node: tabFocusNodes[index],
                child: Builder(builder: (BuildContext context) {
                  return shouldBuildTab[index] // 一度も表示してないタブはbuildしない
                    ? widget.tabBuilder(context, index) // TabViewを作る
                    : const SizedBox.shrink(); // プレースホルダー
                }),
              ),
            ),
          ),
        );
      }),
    );
  }
}
```

色々と細かい話を省略しましたが、基本的には`Navigator`を`Offstage`で包み、`Stack`で重ねることで入れ子になったナビゲーションを実現できます。思いの外シンプルですね。これを一般化してまとめたのが[navigator_scope](https://github.com/fujidaiti/navigator_scope)です。

## Flutter公式による実装例

公式ドキュメントには`NavigationBar`を使用した実装例が掲載されています。実装の方針自体は前述の`Offstage`と`Stack`を用いたものと大差ないですが、ブラウザ上で実際に動かせるので一見の価値があると思います。

https://api.flutter.dev/flutter/material/NavigationBar-class.html#material.NavigationBar.2

# まとめ

最後に雑ですが個人的フローチャートを書き残しておきます。

- go_routerを使っている 👉 `ShellRoute`~~（状態保存はプルリクエストのマージ待ち）~~
- iOS風のUIを作りたい 👉 `CupertinoTabScaffold`とその仲間たち
- `Cupertino`は嫌だ 👉 [persistent_bottom_nav_bar](https://pub.dev/packages/persistent_bottom_nav_bar)
- とにかくシンプルが良い＆好きなタブバーを使いたい 👉 [navigator_scope](https://github.com/fujidaiti/navigator_scope)（使ってみてネ）
- 全部嫌だ 👉 自炊🍚



終わり！