はじめに
ユアマイスター 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 に設定している部分です。
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),
),
);
}
}
このように実現することもできます。
final _childPageList = [
_ChildPage1(key: PageStorageKey<String>("key_ChildPage1")),
_ChildPage2(key: PageStorageKey<String>("key_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();
}
やっていることしては、Stateを保存するためのキーを設定し、カウンターが増えたタイミングで PageStorage
を使って書き込み、その書き込まれたデータを読み込みすることで、Stateの状態を保っています。
データを読み込みするタイミングは、Stateが初期化される initState
の次に呼ばれる didChangeDependencies
で行っています。
AutomaticKeepAliveClientMixin
と比較すると、
- キーの設定やデータ読み込み・書き込みとやることが多い
- キーが重複すると
Duplicate key found
と出てエラーとなる - readState メソッドは型が dynamic で不定なので、型の考慮が必要
- キーが重複すると
上記のことから、 PageStorage
は使うメリットがあまりなさそうです。
まとめ
個人的には、BottomNavigationBar + IndexedStack、TabBar + AutomaticKeepAliveClientMixin を使うのがベストな方法かなと思っています。
もし、こうしたほうがいいよってやり方があれば、コメントをください。