7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

やりたいこと

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

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

やったことの概要

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

※ 状態の保持・処理の伝搬には state_notifier を使っています。(後述)

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

完成動作イメージ

hai01-0c0qq.gif

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

実装の内容

タブの先頭画面に戻す

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

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

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

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

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

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

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に伝搬させるためのメソッドを追加しました。

  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]);
    }
  }
class BottomTabStateNotifier extends StateNotifier<BottomTabState> {
  // ...omit
  void notifyRefresh(TabItem item) {
    listeners[item]?.call();
  }
}

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

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

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
[Flutter] Tab内で遷移したPageをAndroid Hardware Back Buttonで閉じる - Qiita
https://stackoverflow.com/a/62018741/4531070

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

  2. ここに状態を持つことがOKなのか?は検証しきれていません。。(メモリの開放とかで消えたりしそう。。?)

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?