FlutterでiOS風のBottomNavigationを更新機能付きで実現したい

## やりたいこと

iOSのFacebookアプリのように、BottomNavigationのタップでページ切り替えやリフレッシュを行いたい。

1. 詳細画面は、タブを表示したまま遷移したい。
1. 他タブから戻ってきたときには、以前の状態のまま表示したい。
1. 詳細画面表示中に選択中のタブを押された場合、タブの先頭画面を表示したい。
1. タブの先頭画面表示中に選択中のタブを押された場合、タブの先頭画面をリロードしたい。

## やったことの概要

1.や2.を実現するため、 `CupertinoTabScaffold` を利用します。
（materialに含まれる `BottomNavigationBar` などでは、下部タブは残りません。）
3.と4.を実現するために、いくつか処理を実装しました。

※ 状態の保持・処理の伝搬には [state_notifier](https://pub.dev/packages/state_notifier) を使っています。（後述）

コードの全体は https://github.com/noboru-i/flutter_sample/commit/1308f35413d372a987f88822aa6403f25ed5bcb0 のあたりです。

### 完成動作イメージ

![hai01-0c0qq.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/c9200683-9d0f-8c89-1a67-b2dcca18b4b4.gif)

HomePage表示
-> アカウントタブ選択でAccountPage表示
-> ホームタブ選択でHomePageに戻る（リストの数字は変わらない）
-> ページ内ボタンタップでHomeDetailに遷移
-> ホームタブタップでHomePageに戻る
-> 再度ホームタブタップで画面がリロードされる（リストの数字が変わる）

## 実装の内容

### タブの先頭画面に戻す

`CupertinoTabScaffold` の `tabBuilder` で返却する `CupertinoTabView` にて、生成しておいた `navigatorKey` を渡します。
（そのため、 `CupertinoTabScaffold` を内包するWidgetはStatefulWidgetにしておきます）
また、 `CupertinoTabScaffold` にも `CupertinoTabController` を設定しておきます。

`CupertinoTabBar` の `onTap` のタイミングで、
`_tabNavKeyList[_controller.index].currentState.popUntil((route) => route.isFirst);`
のように、先頭までpopします。

抜粋すると、こんな感じです。

```dart
class _BottomTabScreenState extends State<BottomTabScreen> {
  final List<GlobalKey<NavigatorState>> _tabNavKeyList =
      List.generate(2, (index) => index)
          .map((e) => GlobalKey<NavigatorState>())
          .toList();

  final CupertinoTabController _controller = CupertinoTabController();
  int _oldIndex = 0;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return StateNotifierProvider<BottomTabStateNotifier, BottomTabState>(
      create: (_) => BottomTabStateNotifier(),
      builder: (context, snapshot) {
        return WillPopScope(
          onWillPop: () async {
            return !await _tabNavKeyList[_controller.index]
                .currentState
                .maybePop();
          },
          child: CupertinoTabScaffold(
            controller: _controller,
            tabBar: CupertinoTabBar(
              onTap: (index) => _onTapItem(context, index),
              items: <BottomNavigationBarItem>[
                // ...return any bar items.
              ],
            ),
            tabBuilder: (BuildContext context, int index) {
              return CupertinoTabView(
                navigatorKey: _tabNavKeyList[index],
                builder: (BuildContext context) {
                  // ...return any widgets
                },
              );
            },
          ),
        );
      },
    );
  }

  void _onTapItem(BuildContext context, int index) {
    if (index != _oldIndex) {
      _oldIndex = index;
      return;
    }

    _tabNavKeyList[_controller.index].currentState.popUntil((route) => route.isFirst);
  }
}
```

### タブの先頭画面をリロード

API実行や情報の保持は `state_notifier` を利用していました。
そのため、タブがリロードを意図して押されたことを伝搬するためにも、 `state_notifier` の仕組みを利用しました。[^1]

[^1]: イベントの伝播自体は、 `GlobalKey` などを利用しようと思ったのですが、画面のStateクラスまで伝搬しても、その画面で設定したStateNotifierが解決できず、諦めました。（contextが「画面のcontext」になるので、その内部で設定したStateNotifierが取得できない）

まず、「タブ全体画面」用のStateNotifier (=`BottomTabStateNotifier`)と、「タブの中の画面」用のStateNotifier (=`HomeStateNotifier`)を用意しました。

`BottomTabStateNotifier` の中で `Map<TabItem, RefreshListener> listeners = {};` という変数を定義して、そこに外部から追加・削除をできるようにしました。[^2]

[^2]: ここに状態を持つことがOKなのか？は検証しきれていません。。（メモリの開放とかで消えたりしそう。。？）

```dart
class BottomTabStateNotifier extends StateNotifier<BottomTabState> {
  BottomTabStateNotifier() : super(BottomTabState());

  Map<TabItem, RefreshListener> listeners = {};

  void addRefreshListener(TabItem item, RefreshListener listener) {
    listeners[item] = listener;
  }

  void removeRefreshListener(TabItem item) {
    listeners.remove(item);
  }
}

enum TabItem {
  home,
  account,
}

typedef RefreshListener = void Function();
```

また、先程の `_onTapItem` から実行され、登録されたListenerに伝搬させるためのメソッドを追加しました。

```dart
  void _onTapItem(BuildContext context, int index) {
    if (index != _oldIndex) {
      _oldIndex = index;
      return;
    }

    final canPop = _tabNavKeyList[_controller.index].currentState.canPop();
    if (canPop) {
      _tabNavKeyList[_controller.index]
          .currentState
          .popUntil((route) => route.isFirst);
    } else {
      context.read<BottomTabStateNotifier>().notifyRefresh(_tabTypes[index]);
    }
  }
```

```dart
class BottomTabStateNotifier extends StateNotifier<BottomTabState> {
  // ...omit
  void notifyRefresh(TabItem item) {
    listeners[item]?.call();
  }
}
```

`canPop` で判定することで、先頭ページと、それ以外のページの動作を分けることができています。

あとは、イベントを受け取る側の `HomeStateNotifier` を実装します。

```dart
class HomeStateNotifier extends StateNotifier<HomeState> with LocatorMixin {
  HomeStateNotifier() : super(HomeState()) {
    refresh();
  }

  @override
  void initState() {
    read<BottomTabStateNotifier>().addRefreshListener(
      TabItem.home,
      () => refresh(),
    );
  }

  @override
  void dispose() {
    read<BottomTabStateNotifier>().removeRefreshListener(TabItem.home);
    super.dispose();
  }

  Future<void> refresh() async {
    // ...something API calling
  }
```

`initState` のタイミングでListener登録しておきます。また、 `dispose` のタイミングで削除しておきます。

あとは、 `refresh` でstateを変更して、それをUI側で処理してやれば、タブのタップで画面のリロードが実現できます。


## 参考にしたもの

[BottomNavigationBar をキープしたまま画面遷移する - Qiita](https://qiita.com/canisterism/items/d648da85c300a3751db0)
[\[Flutter\] Tab内で遷移したPageをAndroid Hardware Back Buttonで閉じる - Qiita](https://qiita.com/masuhajime/items/4fd1fa7b3038b9cf3c22)
https://stackoverflow.com/a/62018741/4531070