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を使ったページを作ります。

  • MaterialApphomeに Widget の代わりに、ChangeNotifierProvider を指定する
  • createに、ChangeNotifierを継承したモデルクラスを初期化する関数を指定ている
  • StatelessWidgetbuild()内の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)で状態を共有するところにフォーカスして調査しましたが、結局、状態管理として、StatefullWidgetProviderどっちがいいか?ということな気がしました。
そういう意味では、結局複雑になってきたら、Providerで管理したくなることを考えると、最初からProviderで実装しておくのが良さそうだな、、というのが所感です。

ちなみに、状態管理については、Provider を進化させたような Riverpod というパッケージがあるので、正式版がリリースされたら試してみたいと思います。