Flutter でお勉強時間管理用のタイマーアプリを作っているのですが、頻繁にタイマーの起動/停止し忘れが発生するので、手作業で経過時間を編集できるようにしました。
以下のように別画面で編集する作りなのですが、別画面(route)で上手く値をやり取りする必要があり、その実装方法を二つほど試したので紹介します。
StatefullWidget + 画面遷移時にパラメータ渡しする方法
一つ目の方法は、StatefullWidget
で遷移元画面(route)の状態(state)を管理しつつ、別画面に遷移する際にパラメータを渡す方法です。この方法だと遷移元画面の状態(state)が画面遷移時に破棄されるものだと思っていたのですが、maintainState=true
の設定であれば破棄されないようなので、結構使い勝手が良さそうです。
画面遷移の実装は、Flutter cookbook の Navigate to a new screen and back を参照ください。
今回のアプリの遷移元画面の画面遷移に関連する実装は以下の通りです。
child: TextButton(
child: Text('edit'),
onPressed: () async {
// EditPageに遷移したし、戻ってきた際に結果をresultで受け取っている
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => EditPage(_studyTime, _playTime), maintainState: false, fullscreenDialog: true), // EditPageに遷移する際に、 _studyTime と _playTime を渡している
);
// 結果がなければ何もしない
if (result == null) return;
// 結果をstateに反映して画面に反映
setState(() {
_studyTime = result['studyTime'];
_playTime = result['playTime'];
_differenceTime = DateTime.utc(0, 0, 0).add(_studyTime.difference(_playTime));
});
},
),
-
MaterialPageRoute
の引数maintainState
のデフォルト値がtrue
なので、遷移元画面は破棄されずにメモリにキープされるらしい
Whether the route should remain in memory when it is inactive.
If this is true, then the route is maintained, so that any futures it is holding from the next route will properly resolve when the next route pops. If this is not necessary, this can be set to false to allow the framework to entirely discard the route's widget hierarchy when it is not visible. -
EditPage
のイニシャライザに必要なパラメータ(_studyTime
・_playTime
)を渡している - 遷移先画面から
Navigator.pup()
で戻った際の処理結果を、async-await
で同期的に受け取っている- この実装では、処理結果をMapで返している
遷移先画面側の実装は以下の通りです。
<イニシャライザ>
class EditPage extends StatefulWidget {
final DateTime studyTime;
final DateTime playTime;
EditPage(this.studyTime, this.playTime);
}
<遷移元画面に戻る>
child: ElevatedButton(
child: Text('Save'),
onPressed: () => {
Navigator.pop(context, {'studyTime': _studyTime, 'playTime': _playTime}) // 処理結果を指定
},
),
-
Navigator.pup()
の第二引数に処理結果を指定する
ソースコード全文はこちらを参照ください。
今回は、遷移が一つだけだし受け渡すパラメータも少ないのでこれでいいと思いますが、複数の画面(Route)に跨る遷移やパラメータが多いケースはこの方法だと面倒だと思います。
そのような場合は、Providerで状態管理しつつ、異なるRouteで共有する次に紹介する方法が良さそうです。
Providerで状態管理し、異なるRouteで共有する方法
こちら で紹介されている Provider は、同一Route内の Widget 間で状態を共有するための方法なのですが、異なる Route を作成する際に、既存のモデルクラスを渡すことができるので、異なる Route でも共有できるようです。
Provider で状態管理する
これまでStatelessWidget
で状態管理していたので、Provider で状態管理するように変更していきます。
※ ソースコードは抜粋ですので、全文をみたい場合は こちら と こちら を参照ください。
まずは、 pubspec.yaml に依存関係を追記して、Provider をインストールします。
dependencies:
provider: ^5.0.0
次に、状態管理用にChangeNotifier
を継承したモデルクラスを作ります。
- プロパティはプライベートにしておき、getter/setter を経由してアクセスする
- setter で値が変更された時に
notifyListeners()
を呼び出し、変更を Provider に伝える
class TimeKeeper extends ChangeNotifier {
DateTime _studyTime = DateTime.utc(0, 0, 0);
DateTime _playTime = DateTime.utc(0, 0, 0);
DateTime get studyTime => _studyTime;
set studyTime(DateTime datetime) {
_studyTime = datetime;
notifyListeners();
}
DateTime get playTime => _playTime;
set playTime(DateTime datetime) {
_playTime = datetime;
notifyListeners();
}
}
次に、Providerを使ったページを作ります。
-
MaterialApp
のhome
に Widget の代わりに、ChangeNotifierProvider
を指定する -
create
に、ChangeNotifier
を継承したモデルクラスを初期化する関数を指定ている -
StatelessWidget
のbuild()
内のTimeKeeper timeKeeper = context.watch<TimeKeeper>();
でProviderで管理しているモデルを、変更を監視する形で取得している- この取得方法だと、モデルの中身が変更された際に UI が rebuild される
- 監視が不要な場合は、
context.read()
で取得する -
build()
以外でアクセスしたい場合は、Provider.of
を利用する
void main() async {
runApp(
MaterialApp(
home: ChangeNotifierProvider( // Widget ではなく、ChangeNotifierProviderを指定
create: (context) => TimeKeeper(), // ChangeNotifier を継承したモデルクラス
child: TimerPage(),
)));
}
/// タイマーページ
class TimerPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
TimeKeeper timeKeeper = context.watch<TimeKeeper>(); // モデルクラスを取得
return Column(
children: <Widget>[
Text(timeKeeper.studyTime), // モデルクラスにgetterを通じてアクセス
Text(timeKeeper.playTime), // 同上
])
);
}
画面遷移時に Provider を渡す
ボタンをクリックして、画面遷移を行う処理を記載します。
-
ChangeNotifierProvider.value()
を使って、既存のモデルのインスタンスを指定して、Provider を作成する- これにより別Routeでも状態(モデル)を共有できる
Widget build(BuildContext context) {
TimeKeeper timeKeeper = context.watch<TimeKeeper>();
// いろいろ省略
TextButton(
child: Text('edit'),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: timeKeeper, // 既存のインスタンスを指定
child: EditPage(),
)),
);
}
)
)
最後に遷移先画面(編集画面)を実装しました。
今回は、画面内で編集して最終的に Save ボタンをクリックしたら反映する仕組みなので、StatefulWidget
を使って実装しています。
- Provider管理のモデルを元に、ローカル変数を初期化する処理は
didChangeDependencies
にて行う-
StatelessWidget
を使う場合は、build()
の最初で初期化処理を行えばOKです
-
-
build()
で Widgetツリーを構築する際に、context.watch<TimeKeeper>()
で Provider からモデルクラスを、監視する形で取得しておく -
Save
ボタンをタップした際に、モデルクラスの setter を呼び出し(timeKeeper.studyTime = _studyTime
)て、モデルに反映している - setter 内で
notifyListeners()
してるので、このモデルを Listen している Widget に変更が通知される
class EditPage extends StatefulWidget {
@override
_EditPageState createState() => _EditPageState();
}
class _EditPageState extends State<EditPage> {
DateTime _studyTime; // このページ内で管理する状態
DateTime _playTime; // 同上
@override
void didChangeDependencies() {
TimeKeeper timeKeeper = context.read<TimeKeeper>(); // ここでcontextが必要。参照だけなので`read`を利用
_studyTime = timeKeeper.studyTime;
_playTime = timeKeeper.playTime;
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
TimeKeeper timeKeeper = context.watch<TimeKeeper>();
// いろいろ省略
child: ElevatedButton(
child: Text('Save'),
onPressed: () {
timeKeeper.studyTime = _studyTime; // ここでProviderで管理するモデルに反映
timeKeeper.playTime = _playTime;
Navigator.pop(context); // 元画面に戻る(処理結果は返さない)
},
)
}
少し大変でしたが、Provider を利用していても、異なる Route で状態を共有することは問題なくできました。
Provider 自体も、導入することで画面とロジックが強制的に分離されて、コードの見通しが良くなった気がするのでよかったです。
参考: Provider と WidgetsBindingObserver と併用する
今回の記事と直接関係ないですが、 pause - resume した際の処理をWidgetsBindingObserver
を使って埋め込んでいたのですが、Provider と併用する際に少し工夫が必要でした。
というのも、StatelessWidget
ではdidChangeAppLifecycleState()
のタイミングでcontext
にアクセスできず、結果として Provider で管理しているモデルにアクセスできないためです。
しょうがないので、StatefullWidget
と Provider を共存させる形にしました。
- Stateクラスで
WidgetsBindingObserver
を mix-in する -
initState()
でWidgetsBinding.instance.addObserver(this)
を、dispose()
でWidgetsBinding.instance.removeObserver(this)
を実行する -
didChangeAppLifecycleState()
で Provider からモデルを取得する
class TimerApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Study Timer',
home: ChangeNotifierProvider(
create: (context) => TimeKeeper(),
child: TimerPage(),
));
}
}
/// タイマーページ
class TimerPage extends StatefulWidget {
@override
_TimerPageState createState() => _TimerPageState();
}
class _TimerPageState extends State<TimerPage> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
TimeKeeper timeKeeper = context.read<TimeKeeper>(); // ここでProviderからモデルを取得
if (state == AppLifecycleState.paused) {
timeKeeper.handleOnPaused();
} else if (state == AppLifecycleState.resumed) {
timeKeeper.handleOnResumed();
}
}
@override
Widget build(BuildContext context) {
TimeKeeper timeKeeper = context.watch<TimeKeeper>();
return Scaffold(
// 省略
)
}
}
さいごに
今回、異なる画面(Route)で状態を共有するところにフォーカスして調査しましたが、結局、状態管理として、StatefullWidget
とProvider
どっちがいいか?ということな気がしました。
そういう意味では、結局複雑になってきたら、Provider
で管理したくなることを考えると、最初からProvider
で実装しておくのが良さそうだな、、というのが所感です。
ちなみに、状態管理については、Provider を進化させたような Riverpod というパッケージがあるので、正式版がリリースされたら試してみたいと思います。