Flutter の state_notifier と状態遷移

概要

Flutter で StateNotifier を使うときに状態遷移をどのように表現するか、また SnackBar の表示や画面遷移といったステートをマッピングするのではなく一度だけ行う副作用をどのように書くかについて、ダウンロードの進捗を表示するアプリを題材に説明します。

仕様

  • ダウンロードは、開始前・ダウンロード中・ダウンロード完了の3状態があるものとします
  • ダウンロード中はプログレスを表示し、ダウンロード完了時には SnackBar を表示します
  • 実際にはダウンロードはせず、適当なディレイでそれっぽく状態遷移を行います

できたもの

f:id:ryoheyc:20201207184345g:plain

設計

  • DownloadController: StateNotifier のサブクラスで状態を管理します。startDownload メソッドを呼ぶと状態をそれっぽく変化させます
  • MyApp: MaterialApp。また、StateNotifierProvider で DownloadController を提供します
  • MyHomePage: UI の中身の実装

コード

State

基底クラス DownloadState を継承したクラスで状態を表現します。

abstract class DownloadState {}

class IdleState extends DownloadState {}

class LoadingState extends DownloadState {
  final double progress;
  LoadingState(this.progress);
}

class CompleteState extends DownloadState {}

StateNotifier

以下の状態遷移をする StateNotifier を作ります。

  1. IdleState
  2. LoadingState (0.0〜1.0)
  3. CompleteState
class DownloadController extends StateNotifier<DownloadState> {
  DownloadController() : super(IdleState());

  void startDownloading() async {
    state = LoadingState(0);
    var progress = 0.0;
    do {
      await Future.delayed(Duration(milliseconds: 300));
      state = LoadingState(progress += 0.1);
    } while (progress < 1);
    state = CompleteState();
  }
}

main

StateNotifierProvider で DownloadController を取得できるようにします。

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StateNotifierProvider<DownloadController, DownloadState>(
        create: (context) => DownloadController(),
        child: MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
            visualDensity: VisualDensity.adaptivePlatformDensity,
          ),
          home: MyHomePage(),
        ));
  }
}

Widget

StateNotifier を利用する Widget のコード全体です。下に個々の説明を書きます。

class MyHomePage extends StatefulWidget {
  @override
  MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {
  final _scaffoldKey = GlobalKey<ScaffoldState>();
  StreamSubscription<DownloadState> _subscription;

  @override
  void didChangeDependencies() {
    final controller = context.read<DownloadController>();
    _subscription?.cancel();
    _subscription = controller.stream
        .distinct((prev, current) => prev.runtimeType == current.runtimeType)
        .listen((state) {
      if (state is CompleteState) {
        _scaffoldKey.currentState
            .showSnackBar(SnackBar(content: Text("The download is complete")));
      }
    });

    super.didChangeDependencies();
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final isLoading = context.select((DownloadState s) => (s is LoadingState));

    return Scaffold(
      key: _scaffoldKey,
      appBar: CupertinoNavigationBar(
        middle: const Text("Downloader"),
      ),
      body: Column(children: [
        StateNotifierBuilder(
            stateNotifier: context.watch<DownloadController>(),
            builder: (context, state, child) {
              if (state is LoadingState) {
                return LinearProgressIndicator(value: state.progress);
              }
              return Container();
            }),
        Center(
          child: RaisedButton(
              child: const Text("Start downloading"),
              onPressed: isLoading
                  ? null
                  : () =>
                      context.read<DownloadController>().startDownloading()),
        )
      ]),
    );
  }
}

開始ボタン

タップ時に DownloadController.startDownloading() を呼びます。DownloadController は context.read() で取得します。

RaisedButton(
  child: const Text("Start downloading"),
  onPressed: isLoading ? null : () => context.read<DownloadController>().startDownloading()
),

isLoading は context.select を使い、state が LoadingState かどうかで判別します。select を使っているので変更時に再描画されます。

final isLoading = context.select((DownloadState s) => (s is LoadingState));

ローディング表示

state が LoadingState のときだけ LinearProgressIndicator でプログレスを表示します。それ以外のときは空の Container を返し、何も表示しません。StateNotifierBuilder を使って state を読み取ります。

StateNotifierBuilder(
    stateNotifier: context.watch<DownloadController>(),
    builder: (context, state, child) {
      if (state is LoadingState) {
        return LinearProgressIndicator(value: state.progress);
      }
      return Container();
    }
),

スナックバー表示

flutter_bloc には BlocListener のような状態変化時に呼ばれるコールバックのための仕組みがありますが、StateNotifier には同等のものがありません。しかし StateNotifier には状態を Stream として取得する stream が用意されているので、これを使って状態変化を監視します。

不要な監視をキャンセルするために、必ず listen 前と dispose 時に StreamSubscription をキャンセルします。

StreamSubscription<DownloadState> _subscription;

@override
void didChangeDependencies() {
  final controller = context.read<DownloadController>();
  _subscription?.cancel();
  _subscription = controller.stream
      .listen((state) {
    if (state is CompleteState) {
      _scaffoldKey.currentState
          .showSnackBar(SnackBar(content: Text("The download is complete")));
    }
  });

  super.didChangeDependencies();
}

@override
void dispose() {
  _subscription?.cancel();
  super.dispose();
}

別パターン

サブクラスで状態を表現するのではなく、プロパティとして持つことも考えられます。enum DownloadStateType で状態を表現し、DownloadState.type に持たせます。

enum DownloadStateType { idle, loading, complete }

class DownloadState {
  final DownloadStateType type;
  final double progress;
  DownloadState(this.type, {this.progress = 0.0});
}

今回は3状態の表現として enum を使っていますが、単にロード中などを表現するなら bool 値でもよいです。しかし、今回のように progress といった、特定の状態でしか使わないデータがある場合は enum よりもサブクラスで表現したほうがよいでしょう。

残りのコード全体

state is CompleteState などとしていた部分が state.type == DownloadStateType.complete になっています。

class DownloadController extends StateNotifier<DownloadState> {
  DownloadController() : super(DownloadState(DownloadStateType.idle));

  void startDownloading() async {
    state = DownloadState(DownloadStateType.loading, progress: 0.0);
    var progress = 0.0;
    do {
      await Future.delayed(Duration(milliseconds: 300));
      state =
          DownloadState(DownloadStateType.loading, progress: progress += 0.1);
    } while (progress < 1);
    state = DownloadState(DownloadStateType.complete);
  }
}


class MyHomePage extends StatefulWidget {
  @override
  MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {
  final _scaffoldKey = GlobalKey<ScaffoldState>();
  StreamSubscription<DownloadState> _subscription;

  @override
  void didChangeDependencies() {
    final controller = context.read<DownloadController>();
    _subscription?.cancel();
    _subscription = controller.stream.listen((state) {
      if (state.type == DownloadStateType.complete) {
        _scaffoldKey.currentState
            .showSnackBar(SnackBar(content: Text("The download is complete")));
      }
    });

    super.didChangeDependencies();
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final isLoading = context
        .select((DownloadState s) => (s.type == DownloadStateType.loading));

    return Scaffold(
      key: _scaffoldKey,
      appBar: CupertinoNavigationBar(
        middle: const Text("Downloader"),
      ),
      body: Column(children: [
        StateNotifierBuilder(
            stateNotifier: context.watch<DownloadController>(),
            builder: (context, state, child) {
              if (state.type == DownloadStateType.loading) {
                return LinearProgressIndicator(value: state.progress);
              }
              return Container();
            }),
        Center(
          child: RaisedButton(
              child: const Text("Start downloading"),
              onPressed: isLoading
                  ? null
                  : () =>
                      context.read<DownloadController>().startDownloading()),
        )
      ]),
    );
  }
}

所感

Bloc というと Stream を使うのでハードルが高い感じがしますが、flutter_bloc には BlocListener などがあるため、Bloc を利用する Widget 側のコードではほとんど Stream を意識しないコードを書くことができます。一方で state_notifier は基本的には Stream を触らないものの、今回の SnackBar などにおいては Stream を利用しないと実装が面倒な部分があります。 しかし、flutter_bloc を使った Bloc 実装と比べてかなり素直なコードになるので、多少 Stream を触ることになるとしても、チームでの導入は簡単そうです。

サブクラスで状態を表現するのは少しモヤモヤするので、Swift のようにデータをもたせた enum を作りたいところです。freezed パッケージの when を使うと良い感じにかけそうですね。