Main featured image

FlutterのTodoアプリで Riverpod useProvider ChangeNotifier の基本的な使い方を覚える

Flutter
Dart

Flutter の Riverpod / useProvider / ChangeNotifier の基本的な使い方を覚えます。

筆者は Flutter 初学者の為、 題材として簡単な Todo アプリを選びました。

アーキテクチャは簡易版の MVVM で、今回外からデータ取得などしないので Model は作成せず、View と View からロジックを引き剥がす為、 ViewModel のみ実装します。

最終的にこんなアプリを作ります。

Flutter のまだ正しい実装方法が分からないので実装が誤っていたら Twitter で DM 頂くか、Contact まで連絡お願いします!

2021/02/15 追記

Flutter の Todo アプリで Riverpod / useProvider / StateNotifier / Freezed の使い方を覚える記事を書きました。

筆者の感想としては ChangeNotifier よりも StateNotifier + Freezed の組み合わせの方が便利と感じたので、ぜひこちらの記事もご覧ください。

環境
  • macOS Big Sur 11.1
  • Android Studio 4.1.2
  • Flutter 1.22.6
  • Dart 2.10.5
package の install

まず riverpod などの package を install します。

pubspec.yaml に以下 package を追記します。

  • pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  hooks_riverpod:
  fluttertoast:

hooks_riverpodriverpoduseProvider を利用する為に install します。

fluttertoast は Todo の追加・更新・削除時にトーストメッセージを表示する package です。

flutter pub get を実行して package を install してください。

main.dart に Provider を実装する

main.dart を開いて、以下コードを実装します。

  • lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/all.dart';
import 'package:flutter_todo_list/todo_list_view.dart';
import 'package:flutter_todo_list/todo_view_model.dart';

final todoProvider = ChangeNotifierProvider(
  (ref) => TodoViewModel(),
);

void main() {
  runApp(
    ProviderScope(
      child: TodoListView(),
    ),
  );
}

ここで登場する ChangeNotifierProvider が riverpod の provider です。

後ほど実装する ViewModel は ChangeNotifier を継承します。

その為、ViewModel の ChangeNotifier に対応する ChangeNotifierProvider を利用します。

provider とはクラスインスタンスを保持する為のもので(今回でいう ViewModel)、riverpod で提供される関数を通してはじめてアクセスできます(今回でいう useProvider)。

provider 引数には provider で保持する ViewModel を指定します。

ちなみに provider は immutable(不変)で provider をグローバルに定義して問題ないので、main メソッドの外側で宣言しています。

最後に provider を使用する Widget を ProviderScope でくくります。

void main() {
  runApp(
    ProviderScope(
      child: TodoListView(),
    ),
  );
}

蛇足ですが、Provider の ref オブジェクトを利用すると他の provider にアクセス出来ます。

複数の Provider を ref で参照するパターンを記事にしたので、興味がある方はご覧ください。

UI の状態を保持するクラスの作成

UI の状態を保持するクラスを作成します。

  • lib/todo.dart
class Todo {
  Todo(this.id, this.title);
  final int id;
  String title;
}

title は値が代入されるので final 修飾子は付与していないです。

ただし本来、状態を保持するオブジェクトは意図しない状態の変更を避けるため、immutable(不変)に操作されるべきです。

Provide と同じ作者が、状態を持つオブジェクトを immutable に管理できる便利な freezed というパッケージを出しているので次回以降、こちらも検証していきます。

ViewModel を作成する

画面状態を保持する todoList 配列を操作、管理する為の ViewModel を実装します。

lib/todo_view_model.dart を作成して以下コードを実装します。

  • lib/todo_view_model.dart
import 'dart:collection';

import 'package:flutter/cupertino.dart';
import 'package:flutter_todo_list/todo.dart';

class TodoViewModel extends ChangeNotifier {

  List<Todo> _todoList = [];
  UnmodifiableListView<Todo> get todoList => UnmodifiableListView(_todoList);

  void createTodo(String title) {
    final id = _todoList.length + 1;
    _todoList = [...todoList, Todo(id, title)];
    notifyListeners();
  }

  void updateTodo(int id, String title) {
    todoList.asMap().forEach((int index, Todo todo) {
      if (todo.id == id) {
        _todoList[index].title = title;
      }
    });
    notifyListeners();
  }

  void deleteTodo(int id) {
    _todoList = todoList.where((todo) => todo.id != id).toList();
    notifyListeners();
  }
}

Todo タスクを保持する配列である todoList は _todoList と宣言して private にします。

外から呼ぶ public な todoListUnmodifiableListView<Todo> get todoList => UnmodifiableListView(_todoList);todoList を外から直接プロパティ操作させない為、UnmodifiableListView でラップしています。

次に、class 宣言の class TodoViewModelextends ChangeNotifier しているのが View 側に _todoList の状態を変更通知する為に利用する ChangeNotifier です。

_todoList を操作した後に、 notifyListeners() を呼ぶと View 側に todoList の状態変更が通知されます。

ただ毎回 todoList の操作箇所で notifyListeners() を呼ぶのは面倒なので、そこら辺をうまく吸収してくれる便利な StateNotifier パッケージが出ているので、次回以降そちらも検証していきます。

ここでは基本的な ChangeNotifier で実装します。

次に、todoList 要素にアクセスする際は、以下のように private な _todoList を操作しています。

  void deleteTodo(int id) {
    _todoList = todoList.where((todo) => todo.id != id).toList();
    notifyListeners();
  }

ここでは配列の要素の削除操作をしていますが、やっていることは _todoList.removeAt(index) などで配列を直接操作しているのと変わりません。

配列要素削除操作を todoList.where を使って回りくどく書いているのは、将来 _todoList を immutable(不変)に操作するようにしたいので、意識付けとしてあえて書いています。

基本的に画面の状態をもっているオブジェクト(今回でいう_todoList)を mutable(可変)に直接操作するのはバグが生まれやすく、アンチパターンだと思っています。

こちらも freezed を使えは解決しそうですが、freezed はコードジェネレーターでデータクラスを作成するらしいので、導入コストを考えて今回はライトに _todoList を mutable に操作します。

Todo 一覧 View を作成する

次に lib/todo_list_view.dart を作成して以下コードを実装します。

  • lib/todo_list_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_todo_list/main.dart';
import 'package:flutter_todo_list/todo.dart';
import 'package:flutter_todo_list/upsert_todo_view.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/all.dart';

class Const {
  static const routeNameUpsertTodo = 'upsert-todo';
}

class TodoListView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(primaryColor: Colors.white),
      routes: <String, WidgetBuilder>{
        Const.routeNameUpsertTodo: (BuildContext context) => UpsertTodoView(),
      },
      home: TodoList(),
    );
  }
}

class TodoList extends HookWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Todo'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: () => _transitionToNextScreen(context),
          ),
        ],
      ),
      body: _buildList(),
    );
  }

  Widget _buildList() {
    final viewModel = useProvider(todoProvider);
    // viewModelからtodoList取得/監視
    final List<Todo> _todoList = viewModel.todoList;
    return ListView.builder(
      padding: const EdgeInsets.all(16),
      itemCount: _todoList.length,
      itemBuilder: (BuildContext context, int index) {
        return _dismissible(_todoList[index], context);
      },
    );
  }

  Widget _dismissible(Todo todo, BuildContext context) {
    // ListViewのswipeができるwidget
    return Dismissible(
      // ユニークな値を設定
      key: UniqueKey(),
      confirmDismiss: (direction) async {
        final confirmResult =
            await _showDeleteConfirmDialog(todo.title, context);
        // Future<bool> で確認結果を返す。False の場合削除されない
        return confirmResult;
      },
      onDismissed: (DismissDirection direction) {
        // viewModelのtodoList要素を削除
        context.read(todoProvider).deleteTodo(todo.id);
        // ToastMessageを表示
        Fluttertoast.showToast(
          msg: '${todo.title}を削除しました',
          backgroundColor: Colors.grey,
        );
      },
      // swipe中ListTileのbackground
      background: Container(
        alignment: Alignment.centerLeft,
        // backgroundが赤/ゴミ箱Icon表示
        color: Colors.red,
        child: const Padding(
          padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
          child: const Icon(
            Icons.delete,
            color: Colors.white,
          ),
        ),
      ),
      child: _todoItem(todo, context),
    );
  }

  Widget _todoItem(Todo todo, BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        border: const Border(bottom: BorderSide(width: 1, color: Colors.grey)),
      ),
      child: ListTile(
        title: Text(
          todo.title,
          style: const TextStyle(
            color: Colors.black,
            fontSize: 16,
          ),
        ),
        onTap: () {
          _transitionToNextScreen(context, todo: todo);
        },
      ),
    );
  }

  Future<void> _transitionToNextScreen(BuildContext context,
      {Todo todo = null}) async {
    final result = await Navigator.pushNamed(context, Const.routeNameUpsertTodo,
        arguments: todo);

    if (result != null) {
      // ToastMessageを表示
      Fluttertoast.showToast(
        msg: result.toString(),
        backgroundColor: Colors.grey,
      );
    }
  }

  Future<bool> _showDeleteConfirmDialog(
      String title, BuildContext context) async {
    final result = await showDialog<bool>(
        context: context,
        barrierDismissible: false,
        builder: (BuildContext context) {
          return AlertDialog(
            title: const Text('削除'),
            content: Text('$titleを削除しますか?'),
            actions: [
              FlatButton(
                onPressed: () => Navigator.of(context).pop(false),
                child: const Text('cancel'),
              ),
              FlatButton(
                onPressed: () => Navigator.of(context).pop(true),
                child: const Text('OK'),
              ),
            ],
          );
        });
    return result;
  }
}

ポイントは final viewModel = useProvider(todoProvider); で先程 main.dart で作成した todoProvider と useProvider を利用して viewModel を取得しています。

  Widget _buildList() {
    final viewModel = useProvider(todoProvider);
    final List<Todo> _todoList = viewModel.todoList;
    return ListView.builder(
      padding: const EdgeInsets.all(16),
      itemCount: _todoList.length,
      itemBuilder: (BuildContext context, int index) {
        return _dismissible(_todoList[index], context);
      },
    );
  }

次に final List<Todo> _todoList = viewModel.todoList; で viewModel から todoList を取得しています。

取得した todoList は ListView.builder の itemCount と itemBuilder に指定します。

useProvider を通して取得したオブジェクトは状態監視されて変更が起きたら Widget をリビルドします。

Flutter の宣言的 UI の仕組みと ChangeNotifier の通知の仕組み、 flutter hooks のuseProvider、 riverpod の ChangeNotifierProvider の状態監視のおかげで、 ViewModel で起きた todoList の変更を監視し、変更があれば自動で UI に反映されるようになります。

useProvider は flutter hooks と呼ばれるもので、利用するには、 HookWidget を継承する必要があります。

class TodoList extends HookWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Todo'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: () => _transitionToNextScreen(context),
          ),
        ],
      ),
      body: _buildList(),
    );
  }

今回は親 Widget の class TodoListextends HookWidget をして flutter hooks を利用出来るようにしています。

その他、 Navigator.pushNamed の箇所で、Todo 作成・更新画面に選択した Todo のオブジェクトを渡しています。

  Future<void> _transitionToNextScreen(BuildContext context,
      {Todo todo = null}) async {
    final result = await Navigator.pushNamed(context, Const.routeNameUpsertTodo,
        arguments: todo);

    if (result != null) {
      Fluttertoast.showToast(
        msg: result.toString(),
        backgroundColor: Colors.grey,
      );
    }
  }

Todo 新規作成の場合 null が渡されるので、次に実装する Todo 作成・更新画面で Todo が null の場合の条件分岐で表示出し分けをしています。

また、 Navigator.pushNamed の戻り値の result には Todo 作成 or 更新メッセージが返ってきます。

Todo 作成 or 更新メッセージを Fluttertoast で表示するようにしています。

Todo 作成・更新をする View を作成する

次に lib/upsert_todo_view.dart を作成して以下コードを実装します。

  • lib/upsert_todo_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_todo_list/main.dart';
import 'package:flutter_todo_list/todo.dart';
import 'package:hooks_riverpod/all.dart';

class UpsertTodoView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final todo = ModalRoute.of(context).settings.arguments as Todo;
    return Scaffold(
      appBar: AppBar(
        title: Text('Todo${todo == null ? '作成' : '更新'}'),
      ),
      body: TodoForm(),
    );
  }
}

class TodoForm extends StatefulWidget {
  @override
  _TodoFormState createState() => _TodoFormState();
}

class _TodoFormState extends State<TodoForm> {
  final _formKey = GlobalKey<FormState>();
  String _title = '';

  @override
  Widget build(BuildContext context) {
    final todo = ModalRoute.of(context).settings.arguments as Todo;
    return Form(
      key: _formKey,
      child: Container(
        padding: const EdgeInsets.all(64),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            TextFormField(
              initialValue: todo != null ? todo.title : '',
              maxLength: 20,
              maxLengthEnforced: true,
              decoration: const InputDecoration(
                hintText: 'Todoタイトルを入力してください',
                labelText: 'Todoタイトル',
              ),
              validator: (String title) {
                return title.isEmpty ? 'Todoタイトルを入力してください' : null;
              },
              onSaved: (String title) {
                _title = title;
              },
            ),
            RaisedButton(
              onPressed: () => _submission(context, todo),
              child: Text('Todoを${todo == null ? '作成' : '更新'}する'),
            ),
          ],
        ),
      ),
    );
  }

  void _submission(BuildContext context, Todo todo) {
    if (_formKey.currentState.validate()) {
      _formKey.currentState.save();
      if (todo != null) {
        // viewModelのtodoListを更新
        context.read(todoProvider).updateTodo(todo.id, _title);
      } else {
        // viewModelのtodoListを作成
        context.read(todoProvider).createTodo(_title);
      }
      // 前の画面に戻る
      Navigator.pop(context, '$_titleを${todo == null ? '作成' : '更新'}しました');
    }
  }
}

ポイントは context.read(todoProvider) の処理で、先程作成した ViewModel の updateTodocreateTodo メソッドを呼び出しています。

  void _submission(BuildContext context, Todo todo) {
    if (_formKey.currentState.validate()) {
      _formKey.currentState.save();
      if (todo != null) {
        // viewModelのtodoListを更新
        context.read(todoProvider).updateTodo(todo.id, _title);
      } else {
        // viewModelのtodoListを作成
        context.read(todoProvider).createTodo(_title);
      }
      Navigator.pop(context, '$_titleを${todo == null ? '作成' : '更新'}しました');
    }
  }
}

context.read は、状態の検知・監視を伴わない場合の Provider 取得の仕組みで、主に Provider で保持しているオブジェクト(今回でいう ViewModel)の関数呼び出しの時などに利用します。

Todo 一覧では状態の検知・監視をする useProvider を通して Provider で保持している ViewModel の todoList を取得、ListView.builder にセットしています。

context.read で Provider で保持している ViewModel の関数呼び出し、ViewModel で UI の状態を保持した todoList の変更、ChangeNotifierで変更を通知、useProvider と Provider で状態変更を検知して ListView.builder をリビルドをします。

この一連の流れで状態変更による自動 UI 反映が実現できるという訳です。

そのほか、ModalRoute.of(context).settings.arguments as Todo で Todo 一覧画面から Todo オブジェクトを取得しています。

class UpsertTodoView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final todo = ModalRoute.of(context).settings.arguments as Todo;
    return Scaffold(
      appBar: AppBar(
        title: Text('Todo${todo == null ? '作成' : '更新'}'),
      ),
      body: TodoForm(),
    );
  }
}

Todo オブジェクトがあれば更新、無ければ新規作成の表示出し分けをいれています。

おわりに

Flutter の宣言的 UI と ChangeNotifier による通知の仕組み、riverpod + useProvider による状態監視の組み合わせで直感的かつコード量を抑えて Todo アプリを実装することができました。

次のステップとして、同じ Todo アプリに Freezed を導入して状態を持つオブジェクトを immutable に管理しつつ、StateNotifier、StateProvider で直感的かつ、更にコードを減らす実装をしてみたいと思います。

筆者は Flutter 初学者の為、まだ正しい実装方法が分からないので実装が誤っていたら Twitter で DM 頂くか、Contact まで連絡お願いします!

最後に今回実装した Todo アプリは Github にあるので参照ください。

2021/02/15 追記

Flutter の Todo アプリで Riverpod / useProvider / StateNotifier / Freezed の使い方を覚える記事を書きました。

筆者の感想としては ChangeNotifier よりも StateNotifier + Freezed の組み合わせの方が便利と感じたので、ぜひこちらの記事もご覧ください。

Written by ZUMA a.k.a. Kazuma. Web/Mobile App developer.  My profile.
Tags
Archives
2021-042021-032021-022021-01
Recent Posts