Help us understand the problem. What is going on with this article?

Flutter で Stateを保持したまま画面切り替えができる BottomNavigationBar や TabBar の作り方

More than 1 year has passed since last update.

はじめに

ユアマイスター Advent Calendar 2019 の6日目の記事です。

ユアマイスターでは、 あなたのマイスター でハウスクリーニングや修理などのサービスを提供するプロや職人が利用する店舗向けアプリ「マイスターアプリ」 (Android版, iOS版) があります。
このアプリでは、Flutterを採用しており、2019年10月にリリースされました。(その他の技術スタックは こちら)

このアプリには、 BottomNavigationBar や TabBar など使い、1つの画面の中で複数の画面を切り替える画面があります。
この画面を作る際、Flutterの公式ドキュメントを参考に作ったのですが、このままだと画面が切り替わる度に画面がリロードされてしまいます。
今回は、画面が切り替わっても画面がリロードされないようにする方法を書きたいと思います。

解決方法

色々調べたところ、以下を使う方法が見つかりました。

  • IndexedStack
    • 画面をスタックさせて、画面の再構築しないようにする
  • AutomaticKeepAliveClientMixin
    • Stateを保持するようにマークして、Stateが破棄されないようにする
  • PageStore
    • Stateを保存して、画面が再構築されたときにそのStateを読み込む

今回は、BottomNavigationBar + IndexedStack を使った方法と、TabBar + AutomaticKeepAliveClientMixin、TabBar + PageStore を使った方法をやります。

BottomNavigationBar を使った画面

class BottomNavigationBarDemoPage extends StatefulWidget {
  BottomNavigationBarDemoPage({Key key}) : super(key: key);

  @override
  _BottomNavigationBarDemoPageState createState() =>
      _BottomNavigationBarDemoPageState();
}

class _BottomNavigationBarDemoPageState
    extends State<BottomNavigationBarDemoPage> {
  int _currentIndex = 0;

  final _childPageList = [
    _ChildPage1(),
    _ChildPage2(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('BottomNavigationBar Demo'),
      ),
      body: _childPageList[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            title: Text('Child Page1'),
            icon: Icon(Icons.home),
          ),
          BottomNavigationBarItem(
            title: Text('Child Page2'),
            icon: Icon(Icons.person),
          )
        ],
        onTap: (int index) {
          setState(() {
            _currentIndex = index;
          });
        },
      ),
    );
  }
}

class _ChildPage1 extends StatefulWidget {
  _ChildPage1({Key key}) : super(key: key);

  @override
  _ChildPage1State createState() => _ChildPage1State();
}

class _ChildPage1State extends State<_ChildPage1> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('ChildPage1 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

class _ChildPage2 extends StatefulWidget {
  _ChildPage2({Key key}) : super(key: key);

  @override
  _ChildPage2State createState() => _ChildPage2State();
}

class _ChildPage2State extends State<_ChildPage2> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('ChildPage2 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

簡単に画面を説明すると、
FloatingActionButton を押すと、カウンターが増えていく子画面(ChildPage1, ChildPage2)があり、その子画面を BottomNavigationBar で切り替えができる画面(BottomNavigationBarDemoPage)です。
このままだと、画面が切り替わったタイミングで、画面のカウンターが0にリセットされてしまいます。

BottomNavigationBar + IndexedStack を使うパターン

class BottomNavigationBarDemoPage extends StatefulWidget {
  BottomNavigationBarDemoPage({Key key}) : super(key: key);

  @override
  _BottomNavigationBarDemoPageState createState() =>
      _BottomNavigationBarDemoPageState();
}

class _BottomNavigationBarDemoPageState
    extends State<BottomNavigationBarDemoPage> {
  int _currentIndex = 0;

  final _childPageList = [
    _ChildPage1(),
    _ChildPage2(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('BottomNavigationBar Demo'),
      ),
      body: IndexedStack(
        index: _currentIndex,
        children: _childPageList,
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            title: Text('Child Page1'),
            icon: Icon(Icons.home),
          ),
          BottomNavigationBarItem(
            title: Text('Child Page2'),
            icon: Icon(Icons.person),
          )
        ],
        onTap: (int index) {
          setState(() {
            _currentIndex = index;
          });
        },
      ),
    );
  }
}

class _ChildPage1 extends StatefulWidget {
  _ChildPage1({Key key}) : super(key: key);

  @override
  _ChildPage1State createState() => _ChildPage1State();
}

class _ChildPage1State extends State<_ChildPage1> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('ChildPage1 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

class _ChildPage2 extends StatefulWidget {
  _ChildPage2({Key key}) : super(key: key);

  @override
  _ChildPage2State createState() => _ChildPage2State();
}

class _ChildPage2State extends State<_ChildPage2> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('ChildPage2 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

このように書けば、画面が切り替わっても、それぞれの画面のカウンターがリセットされることはありません。
先ほどのコードと違っている部分は、親画面(BottomNavigationBarDemoPage)の Scaffold の body に設定している部分です。

_BottomNavigationBarDemoPageStateクラス
return Scaffold(
      // ...
      body: IndexedStack(
        index: _currentIndex,
        children: _childPageList,
      ),

IndexedStack を使うことで、画面をスタックさせて、指定されたインデックスを持つ画面を表示させています。

TabBar を使った画面

class TabBarDemoPage extends StatefulWidget {
  TabBarDemoPage({Key key}) : super(key: key);

  @override
  _TabBarDemoPageState createState() => _TabBarDemoPageState();
}

class _TabBarDemoPageState extends State<TabBarDemoPage> {
  int _currentIndex = 0;

  final _childPageList = [
    _ChildPage1(),
    _ChildPage2(),
  ];

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      initialIndex: _currentIndex,
      length: _childPageList.length,
      child: Scaffold(
        appBar: AppBar(
          title: Text('TabBar Demo'),
          bottom: TabBar(
            tabs: [
              Tab(text: "Child Page1"),
              Tab(text: "Child Page2"),
            ],
          ),
        ),
        body: TabBarView(
          children: _childPageList,
        ),
      ),
    );
  }
}

class _ChildPage1 extends StatefulWidget {
  _ChildPage1({Key key}) : super(key: key);

  @override
  _ChildPage1State createState() => _ChildPage1State();
}

class _ChildPage1State extends State<_ChildPage1> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('ChildPage1 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

class _ChildPage2 extends StatefulWidget {
  _ChildPage2({Key key}) : super(key: key);

  @override
  _ChildPage2State createState() => _ChildPage2State();
}

class _ChildPage2State extends State<_ChildPage2> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('ChildPage2 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

簡単に画面を説明すると、
FloatingActionButton を押すと、カウンターが増えていく子画面(ChildPage1, ChildPage2)があり、その子画面を TabBar で切り替えができる画面(TabBarDemoPage)です。
このままだと、画面が切り替わったタイミングで、画面のカウンターが0にリセットされてしまいます。

TabBar + AutomaticKeepAliveClientMixin を使うパターン

class TabBarDemoPage extends StatefulWidget {
  TabBarDemoPage({Key key}) : super(key: key);

  @override
  _TabBarDemoPageState createState() => _TabBarDemoPageState();
}

class _TabBarDemoPageState extends State<TabBarDemoPage> {
  int _currentIndex = 0;

  final _childPageList = [
    _ChildPage1(),
    _ChildPage2(),
  ];

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      initialIndex: _currentIndex,
      length: _childPageList.length,
      child: Scaffold(
        appBar: AppBar(
          title: Text('TabBar Demo'),
          bottom: TabBar(
            tabs: [
              Tab(text: "Child Page1"),
              Tab(text: "Child Page2"),
            ],
          ),
        ),
        body: TabBarView(
          children: _childPageList,
        ),
      ),
    );
  }
}

class _ChildPage1 extends StatefulWidget {
  _ChildPage1({Key key}) : super(key: key);

  @override
  _ChildPage1State createState() => _ChildPage1State();
}

class _ChildPage1State extends State<_ChildPage1>
    with AutomaticKeepAliveClientMixin {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);

    return Scaffold(
      body: Center(
        child: Text('ChildPage1 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

class _ChildPage2 extends StatefulWidget {
  _ChildPage2({Key key}) : super(key: key);

  @override
  _ChildPage2State createState() => _ChildPage2State();
}

class _ChildPage2State extends State<_ChildPage2>
    with AutomaticKeepAliveClientMixin {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);

    return Scaffold(
      body: Center(
        child: Text('ChildPage2 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

このように書けば、画面が切り替わっても、それぞれの画面のカウンターがリセットされることはありません。
今回のケースでは、画面を左右にスワイプしても切り替えれるよう TabBarView を使っており、BottomNavigationBar でお見せした IndexedStack を使った方法はできません。
その代わり、子画面(ChildPage1, ChildPage2)のStateに AutomaticKeepAliveClientMixin を設定することで同じことを実現することができます。

先ほどのコードと違っているのは、以下の部分です。

class _ChildPage1State extends State<_ChildPage1>
    with AutomaticKeepAliveClientMixin {
  // ...
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);

    return Scaffold(
    // ...
  }
}

AutomaticKeepAliveClientMixin を使うことで、State(ChildPage1State) が破棄されなくなり、画面をそのまま保った状態で画面を切り替えることができます。

TabBar + PageStore を使うパターン

class TabBarDemoPage extends StatefulWidget {
  TabBarDemoPage({Key key}) : super(key: key);

  @override
  _TabBarDemoPageState createState() => _TabBarDemoPageState();
}

class _TabBarDemoPageState extends State<TabBarDemoPage> {
  int _currentIndex = 0;

  final _childPageList = [
    _ChildPage1(key: PageStorageKey<String>("key_ChildPage1")),
    _ChildPage2(key: PageStorageKey<String>("key_ChildPage2")),
  ];

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      initialIndex: _currentIndex,
      length: _childPageList.length,
      child: Scaffold(
        appBar: AppBar(
          title: Text('TabBar Demo'),
          bottom: TabBar(
            tabs: [
              Tab(text: "Child Page1"),
              Tab(text: "Child Page2"),
            ],
          ),
        ),
        body: TabBarView(
          children: _childPageList,
        ),
      ),
    );
  }
}

class _ChildPage1 extends StatefulWidget {
  _ChildPage1({Key key}) : super(key: key);

  @override
  _ChildPage1State createState() => _ChildPage1State();
}

class _ChildPage1State extends State<_ChildPage1> {
  int _counter;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
    PageStorage.of(context).writeState(context, _counter);
  }

  @override
  void didChangeDependencies() {
    int counter = PageStorage.of(context).readState(context);
    if (counter != null) {
      _counter = counter;
    } else {
      _counter = 0;
    }
    super.didChangeDependencies();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('ChildPage1 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

class _ChildPage2 extends StatefulWidget {
  _ChildPage2({Key key}) : super(key: key);

  @override
  _ChildPage2State createState() => _ChildPage2State();
}

class _ChildPage2State extends State<_ChildPage2> {
  int _counter;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
    PageStorage.of(context).writeState(context, _counter);
  }

  @override
  void didChangeDependencies() {
    int counter = PageStorage.of(context).readState(context);
    if (counter != null) {
      _counter = counter;
    } else {
      _counter = 0;
    }
    super.didChangeDependencies();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('ChildPage2 count is $_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

このように実現することもできます。

_BottomNavigationBarDemoPageStateクラス
final _childPageList = [
  _ChildPage1(key: PageStorageKey<String>("key_ChildPage1")),
  _ChildPage2(key: PageStorageKey<String>("key_ChildPage2")),
];
_ChildPage2Stateクラス
int _counter;

void _incrementCounter() {
  setState(() {
    _counter++;
  });
  PageStorage.of(context).writeState(context, _counter);
}

@override
void didChangeDependencies() {
  int counter = PageStorage.of(context).readState(context);
  if (counter != null) {
    _counter = counter;
  } else {
    _counter = 0;
  }
  super.didChangeDependencies();
}

やっていることしては、Stateを保存するためのキーを設定し、カウンターが増えたタイミングで PageStorage を使って書き込み、その書き込まれたデータを読み込みすることで、Stateの状態を保っています。
データを読み込みするタイミングは、Stateが初期化される initState の次に呼ばれる didChangeDependencies で行っています。

AutomaticKeepAliveClientMixin と比較すると、

  • キーの設定やデータ読み込み・書き込みとやることが多い
    • キーが重複すると Duplicate key found と出てエラーとなる
    • readState メソッドは型が dynamic で不定なので、型の考慮が必要

上記のことから、 PageStorage は使うメリットがあまりなさそうです。

まとめ

個人的には、BottomNavigationBar + IndexedStack、TabBar + AutomaticKeepAliveClientMixin を使うのがベストな方法かなと思っています。
もし、こうしたほうがいいよってやり方があれば、コメントをください。

taki4227
中小企業のSIer、ベンチャー企業を経て、今はSHOWROOMのエンジニアをしています。Android・iOS・Flutterでアプリ開発をしてきました。良いものを作って届けたい、良いものを作ることに携わりたい。
https://note.com/taki4227
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away