Flutter BottomNavigationBarのタブ間遷移を行う方法

# 前置き
FlutterのBottomNavigationBarを使って、タブ間遷移を実現する方法に四苦八苦しました。
その実装方法を共有します。
下記画像の様に**タブA-画面１**から**タブB-画面2**へ遷移したい場合ってありませんか？
また、**タブB-画面2**から戻る際には、**タブA-画面１**では無く**タブB-画面1**に戻りたいと考えました。

![スクリーンショット 2021-05-03 13.46.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621435/ea4ab478-0880-ff44-3e84-2295e9f7350d.png)

# 本題
Pages APIをstate_notifierを使って実装しました。 

Pages APIにの使用方法については、下記の記事が参考にさせて頂きました。

https://zenn.dev/ntaoo/articles/6641e846765da1

state_notifierの使用方法については、下記の記事を参考にさせて頂きました。

https://itome.team/blog/2020/05/flutter-state-notifier-provider/

それでは、実装方法をざっくり説明します。

## Stateの定義
state_notifierで保持するState（状態）となるクラスを定義します。
プロパティとして持つのは下記項目です。

- 現在表示しているタブ
- タブに表示しているページ一覧

実装イメージは下記の通りです。

```dart
enum BottomTab {
  タブ１,
  タブ２,
}

@freezed
class RouteState with _$RouteState {
  const factory RouteState({
    required BottomTab 選択中のタブ,
    @Default([]) List<Page> タブ１ページ一覧,
    @Default([]) List<Page> タブ２ページ一覧,
  }) = _RouteState;
}
```

実際の実装サンプルはこちらです。

https://github.com/PaykeDeveloper/payke-hinagata-native/blob/main/lib/store/state/app/route/models/route_state.dart


Stateの値は下記の様に変化します。

##### 初期状態(タブ１-画面１が表示される)
| プロパティ名 | プロパティ値 |
|:-----------|:------------|
| 選択中タブ       | タブ1        |
| タブ１ページ一覧       | []        |
| タブ２ページ一覧       | []        |

##### タブ２を押下(タブ2-画面１が表示される)

| プロパティ名 | プロパティ値 |
|:-----------|:------------|
| 選択中タブ       | タブ2        |
| タブ１ページ一覧       | []        |
| タブ２ページ一覧       | []        |

##### 画面２を押下(タブ2-画面２が表示される)

| プロパティ名 | プロパティ値 |
|:-----------|:------------|
| 選択中タブ       | タブ2        |
| タブ１ページ一覧       | []        |
| タブ２ページ一覧       | [タブ２-画面２]        |

## Notifierの定義
Stateを更新するNotifierを下記の通り定義します。

```dart
class RouteStateNotifier extends StateNotifier<RouteState> with LocatorMixin {
  RouteStateNotifier() : super(const RouteState(tab: initialTab));

  Future changeIndex(BottomTab tab) async {
    // 選択中のタブを切り替える
  }

  Future push(BottomTab tab, Page page) async {
    // ページ一覧の末尾にページを追加する
  }

  Future pop(BottomTab tab) async {
    // ページ一覧の末尾のページを取り除く
  }

  Future replace(BottomTab tab, List<Page> pages) async {
    // ページ一覧のまるっと入れ替える
  }
}
```

https://github.com/PaykeDeveloper/payke-hinagata-native/blob/main/lib/store/state/app/route/notifier.dart

## Navigatorの定義
Stateの保存されているページ一覧を画面に表示する為、Navigatorのpagesに渡します。


```dart
class ProjectsNavigator extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final pages = context.select((RouteState state) => state.タブ２ページ一覧);
    return Navigator(
      pages: [
        タブ２-画面１(divisionId: divisionId!, openDrawer: _openDrawer),
        ...pages
      ],
    );
  }
}
```

https://github.com/PaykeDeveloper/payke-hinagata-native/blob/main/lib/ui/navigation/navigators/projects.dart


## BottomNavigationBarの実装
BottomNavigationBarとStateを繋ぎます。

```dart
class MainScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final tab = context.select((RouteState state) => state.選択中のタブ);
    final index = tab.getIndex();
    return WillPopScope(
      child: Scaffold(
        body: IndexedStack(
          index: index,
          children: _children,
        ),
        bottomNavigationBar: BottomNavigationBar(
          currentIndex: index,
          items: _tabItems,
          onTap: (int index) {
            final tab = BottomTabExt.getTab(index);
            final notifier = context.read<RouteStateNotifier>();
            if (tab != context.read<RouteState>().tab) {
              notifier.changeIndex(tab);
            }
          },
        ),
      ),
    );
  }
}
```

https://github.com/PaykeDeveloper/payke-hinagata-native/blob/main/lib/ui/screens/main.dart


## 画面遷移の実装
後は、Notifierを通じてStateを更新することで画面遷移が実現できます。

```dart
class HomeScreen extends StatefulWidget {
  @override
  _HomeScreenState createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  Future _onPressedProjectDetail() async {
    final notifier = context.read<RouteStateNotifier>();
    await notifier.changeIndex(BottomTab.タブ２);
    await notifier.replace(BottomTab.タブ２, [
      タブ2-画面２(),
    ]);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            ElevatedButton(
              onPressed: _onPressedProjectDetail,
              child: const Text('タブ2-画面２へ'),
            ),
          ],
        ),
      ),
    );
  }
}
```

https://github.com/PaykeDeveloper/payke-hinagata-native/blob/main/lib/ui/screens/common/home.dart

# 備考
その他、画面遷移でこだわった点のメモです。
この辺の実装サンプルは、下記クラスに集約されています。

https://github.com/PaykeDeveloper/payke-hinagata-native/blob/main/lib/ui/screens/main.dart

## タブの状態保持
IndexedStackを使ってタブの状態を保持しています。
ただし、普通に実装してしまうと親がビルドされるタイミングで子のタブ全てがビルドされてしまいます。
そこで、初期状態はLoadingScreenを保持し、タブが呼ばれたタイミングで本命のタブ(ProjectsNavigator)に入れ替えるということをしています。

## 選択中のタブ押下
選択中のタブを押下することで、先頭の画面に戻るようにしています。
（例: **タブ2-画面2**を表示している状態で、**タブ2**が押下されると**タブ２-画面１**に戻ります。）

## Androidの戻るボタン対応
WillPopScopeを使って戻るボタンを押下された際、下記の様に動作するようにしています。

- Drawerが表示されている状態の場合、Drawerを閉じる。
- **タブ２-画面２**が表示されている場合、**タブ２-画面1**に戻る。
- **タブ２-画面1**が表示されている場合、**タブ1-画面1**に戻る。
- **タブ1-画面1**が表示されている場合、アプリを閉じる。

## 横スワイプ対応
**タブ1-画面1**や**タブ2-画面1**などタブ内の先頭の画面で横スワイプした場合は、Drawerを表示したいですが、**タブ２-画面２**など遷移先にいる場合は、横スワイプで前の画面に戻りたかったりします。
その為、``drawerEnableOpenDragGesture``というプロパティをStateに合わせて制御しています。