flutter

[Flutter] BottomNavigationBarで画面の状態(State)を保持する方法

flutter-BottomNavigationBar

こんにちは!

flutterで画面遷移をする際に、BottomNavigationBarを使うことがよくあると思います。

その際、そのままだと遷移するたびに画面が初期化されてしまい、状態が保持されません。

今回は、IndexedStackを使って他の画面に遷移しても、初期化されず状態が保持される方法を紹介します。

また、IndexedStackだと最初に全ての画面を初期化してしまいます。

AutomaticKeepAliveClientMixinを使うことにより、遷移した時のみ初期化され、1度遷移したら初期化されないようにすることができるので、その方法も見ていきましょう。

通常のBottomNavigationBarを見ていく

まずはBottomNavigationBarを設定して、通常のパターンを見ていきます。

_TabBar1と_TabBar2という画面を作り、BottomNavigationBarで画面を切り変えるようにします。

各画面で表示する内容は、以下のようにします。

_TabBar1 : 数字が縦に順番に表示され、スクロールができる
_TabBar2 : 0から始まり、1秒ごとに1足していく

ソース全体は以下のようになります。

class BasicBottomNavigation extends StatefulWidget {
  const BasicBottomNavigation({Key? key}) : super(key: key);

  @override
  State<BasicBottomNavigation> createState() => _BasicBottomNavigationState();
}

class _BasicBottomNavigationState extends State<BasicBottomNavigation> {
  int currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: [
        const _Tabbar1(),
        const _Tabbar2(),
      ][currentIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: currentIndex,
        onTap: (index) {
          setState(() {
            currentIndex = index;
          });
        },
        items: const [
          BottomNavigationBarItem(icon: Text("1"), label: "Tab"),
          BottomNavigationBarItem(icon: Text("2"), label: "Tab"),
        ],
      ),
    );
  }
}

class _Tabbar1 extends StatelessWidget {
  const _Tabbar1({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print("Tabbar 1 build");

    return Scaffold(
      appBar: AppBar(title: const Text("Tab bar 1")),
      body: ListView.builder(
        itemBuilder: (context, index) {
          return ListTile(
            title: Text("${index + 1}"),
          );
        },
        itemCount: 50,
      ),
    );
  }
}

class _Tabbar2 extends StatefulWidget {
  const _Tabbar2({Key? key}) : super(key: key);

  @override
  State<_Tabbar2> createState() => _Tabbar2State();
}

class _Tabbar2State extends State<_Tabbar2>
    with SingleTickerProviderStateMixin {
  late final Ticker _ticker;
  Duration _escapedDuration = Duration.zero;

  get escapedSeconds => _escapedDuration.inSeconds.toString();

  @override
  void initState() {
    super.initState();
    print("Tabbar 2 initState");

    _ticker = createTicker((elapsed) {
      if (elapsed.inSeconds - _escapedDuration.inSeconds == 1) {
        setState(() {
          _escapedDuration = elapsed;
        });
      }
    });

    _ticker.start();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Tab bar 2")),
      body: Center(
        child: Text(escapedSeconds),
      ),
    );
  }
}

デモ動画は以下になります。

メモ

  • 各画面は遷移するたびに初期化されます。
  • TabBar1画面のスクロール位置は保持されません。
  • TabBar2画面のカウント値は保持されません。

この場合、遷移するたびに初期化され、各画面の状態が保持されません。

IndexedStackを使って状態を保持する

状態を保持する方法として、IndexedStackを使ってbody以下をラップしてあげます。

変更部分は以下になります。

class _BasicBottomNavigationState extends State<BasicBottomNavigation> {
  int currentIndex = 0;

  @override
  Widget build(BuildContext context) {
  return Scaffold(
    // ここから変更
    body: IndexedStack(
      index: currentIndex,
      children: const [
        _Tabbar1(),
        _Tabbar2(),
      ],
    ),
    // ここまで変更
    bottomNavigationBar: BottomNavigationBar(
      currentIndex: currentIndex,
      onTap: (index) {
        setState(() {
          currentIndex = index;
        });
      }
   ・・・・
   ・・・・
   ・・・・

メモ

  • 各画面は遷移するたびに初期化されません。
  • TabBar1画面のスクロール位置は保持されます
  • TabBar2画面のカウント値は保持されます

IndexedStackを使うと、TabBar1画面とTabBar2画面の状態は保持されます。

しかし、動画にある通り、最初にBottomNavigationBarで設定している画面を全て初期化します。

そのためTabBar1画面が表示されたタイミングで、TabBar2の画面も初期化されているので、その時点でTabBar2のカウントが開始しています。

これで問題なければいいのですが、TabBar2の画面に初めて遷移したタイミングで初期化をしてほしい場合もあると思います。

次はそのパターンを見ていきます。

AutomaticKeepAliveClientMixinを使って状態を保持する

画面に初めて遷移した時のみ初期化され、1度遷移したら初期化されずに状態を保持する方法として、AutomaticKeepAliveClientMixinを使っていきます。

AutomaticKeepAliveClientMixinを使う場合は、PageViewでbody以下をラップしてあげます。

以下が全体のコードです。

class AliveMixinDemo extends StatefulWidget {
  const AliveMixinDemo({Key? key}) : super(key: key);

  @override
  State<AliveMixinDemo> createState() => _AliveMixinDemoState();
}

class _AliveMixinDemoState extends State<AliveMixinDemo> {
  int currentIndex = 0;

  final PageController controller = PageController();   // PageControllerを初期化

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView(       // PageViewでラップする
        controller: controller,
        children: [
          _Tabbar1(),
          _Tabbar2(),
        ],
        onPageChanged: (index) {
          setState(() {
            currentIndex = index;
          });
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: currentIndex,
        onTap: (index) {
          controller.jumpToPage(index);   // PageViewに表示されるページを変更する
          setState(() {
            currentIndex = index;
          });
        },
        items: const [
          BottomNavigationBarItem(icon: Text("1"), label: "Tab"),
          BottomNavigationBarItem(icon: Text("2"), label: "Tab"),
        ],
      ),
    );
  }
}

class _Tabbar1 extends StatefulWidget {
  const _Tabbar1({Key? key}) : super(key: key);

  @override
  State<_Tabbar1> createState() => _Tabbar1State();
}

class _Tabbar1State extends State<_Tabbar1>
  with AutomaticKeepAliveClientMixin {     // ミックスインを使う

  @override
  bool get wantKeepAlive => true;   // 値をオーバーライドして状態を維持する

  @override
  Widget build(BuildContext context) {
    super.build(context);    // mixinのbuildメソッドを呼び出す
    return Scaffold(
      appBar: AppBar(title: const Text("Tab bar 1")),
      body: ListView.builder(
        itemBuilder: (context, index) {
          return ListTile(
            title: Text("${index + 1}"),
          );
        },
        itemCount: 50,
      ),
    );
  }
}

class _Tabbar2 extends StatefulWidget {
  const _Tabbar2({Key? key}) : super(key: key);

  @override
  State<_Tabbar2> createState() => _Tabbar2State();
}

class _Tabbar2State extends State<_Tabbar2>
  with SingleTickerProviderStateMixin,
  AutomaticKeepAliveClientMixin {   // ミックスインを使う
  late final Ticker _ticker;
  Duration _escapedDuration = Duration.zero;

  get escapedSeconds => _escapedDuration.inSeconds.toString();

  @override
  void initState() {
    super.initState();
    print("Tabbar 2 initState");

    _ticker = createTicker((elapsed) {
      if (elapsed.inSeconds - _escapedDuration.inSeconds == 1) {
        setState(() {
          _escapedDuration = elapsed;
        });
      }
    });

    _ticker.start();
  }

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

  @override
  bool get wantKeepAlive => true;   // 値をオーバーライドして状態を維持する

  @override
  Widget build(BuildContext context) {
    super.build(context);     // mixinのbuildメソッドを呼び出す
    return Scaffold(
      appBar: AppBar(title: const Text("Tab bar 2")),
      body: Center(
        child: Text(escapedSeconds),
      ),
    );
  }
}

メモ

  • 各画面は遷移した時のみ初期化され、1度遷移したら初期化されません。
  • TabBar1画面のスクロール位置は保持されます
  • TabBar2画面のカウント値は保持されます

IndexedStackを使っていた時とは違い、AutomaticKeepAliveClientMixinを使うことで、TabBar2画面のカウント値は遷移されたらカウントを開始し、値は保持され続け、TabBar1画面のスクロール位置も保持されます。

このパターンは、画面に遷移したタイミングでapiを叩いてデータを取得し、値を保持し続けたい場合とかによく使うのではないでしょうか。

まとめ

まとめると以下のようになります。

何を使うか状況
BottomBarNavigation各画面の状態を保持したくない
BottomBarNavigation + IndexedStack各画面の状態を保持したいが、1度に全ての画面を初期化したい
BottomBarNavigation + AutomaticKeepAliveClientMixin各画面の状態を保持したいが、タブは遷移した時のみ初期化され、
1度遷移したら初期化されないようにしたい

以上になります!

-flutter
-, , , , ,