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

## はじめに

[ユアマイスター Advent Calendar 2019](https://qiita.com/advent-calendar/2019/yourmystar) の6日目の記事です。

ユアマイスターでは、 [あなたのマイスター](https://yourmystar.jp/) でハウスクリーニングや修理などのサービスを提供するプロや職人が利用する店舗向けアプリ「マイスターアプリ」 ([Android版](https://apps.apple.com/jp/app/id1478724946?l=ja), [iOS版](https://apps.apple.com/jp/app/id1478724946?l=ja)) があります。
このアプリでは、Flutterを採用しており、2019年10月にリリースされました。(その他の技術スタックは [こちら](https://yourmystar-engineer.hatenablog.jp/entry/2019/11/25/090000))

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

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

- [IndexedStack](https://api.flutter.dev/flutter/widgets/IndexedStack-class.html)
    - 画面をスタックさせて、画面の再構築しないようにする
- [AutomaticKeepAliveClientMixin](https://api.flutter.dev/flutter/widgets/AutomaticKeepAliveClientMixin-mixin.html)
    - Stateを保持するようにマークして、Stateが破棄されないようにする
- [PageStore](https://api.flutter.dev/flutter/widgets/PageStorageKey-class.html)
    - Stateを保存して、画面が再構築されたときにそのStateを読み込む

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

## BottomNavigationBar を使った画面

```dart
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 を使うパターン

```dart
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 に設定している部分です。

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

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

## TabBar を使った画面

```dart
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 を使うパターン

```dart
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` を設定することで同じことを実現することができます。

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

```dart
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 を使うパターン

```dart
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),
      ),
    );
  }
}
```

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


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

```dart:_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 を使うのがベストな方法かなと思っています。
もし、こうしたほうがいいよってやり方があれば、コメントをください。