Flutter Riverpod を Todo List を実装して理解する
Flutter Riverpod を Todo List を実装して理解します。
概要
前回「Flutter Provider を TODO List を実装して理解する」の記事でProviderについてある程度理解しましたが、最近だと Riverpod を使った実装も多いので、こちらも Todo List を実装してみて理解しておきます。ちょうど Riverpod の公式 example に Todo List の実装があったため、こちらを参考にします。
https://github.com/rrousselGit/riverpod/tree/master/examples/todos
Riverpodの最新版のドキュメントは絶賛更新中のようですが、quickstart だけでも読むと理解が深まりそうです。Todo Listのサンプル実装では使われていないが上記のドキュメントでも紹介されている riverpod_generator を使って Todo List を実装してみようと思ってましたが、実際に使ってみたらうまく動かず、調べてもまだ情報量が少なくてハマりそうだったので、一旦やめておくことにしました。また情報量が増えてきたら試してみようと思います。
以下のリポジトリも参考になりました。
https://github.com/IsaiasCuvula/flutter_riverpod_todo_app
ソースコード
一応以下にpushしておきました。
https://github.com/tkugimot/flutter_riverpod_todo
完成動画
こんな感じの機能が実装できます。
Hello World
いつも通りテンプレのカウンターアップが作成されるコマンドを打ちます。
$ flutter create flutter_riverpod_todo
公式に従って必要なライブラリをインストールします。
$ flutter pub add \
hooks_riverpod \
flutter_hooks \
riverpod_annotation \
dev:riverpod_generator \
dev:build_runner \
dev:custom_lint \
dev:riverpod_lint
参考: https://docs-v2.riverpod.dev/docs/introduction/getting_started
analysis_options.yaml を追加します。
analyzer:
plugins:
- custom_lint
一旦、hello world を実装して動作確認しておきます。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'main.g.dart';
// We create a "provider", which will store a value (here "Hello world").
// By using a provider, this allows us to mock/override the value exposed.
@riverpod
String helloWorld(HelloWorldRef ref) {
return 'Hello world';
}
void main() {
runApp(
// For widgets to be able to read providers, we need to wrap the entire
// application in a "ProviderScope" widget.
// This is where the state of our providers will be stored.
ProviderScope(
child: MyApp(),
),
);
}
// Extend HookConsumerWidget instead of HookWidget, which is exposed by Riverpod
class MyApp extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// We can use hooks inside HookConsumerWidget
final counter = useState(0);
final String value = ref.watch(helloWorldProvider);
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Example')),
body: Center(
child: Text('$value ${counter.value}'),
),
),
);
}
}
下記コマンドを実行すると、 main.g.dart
が作成されます。
$ dart run build_runner watch
アプリケーションを実行すると以下の表示になりました。
無事hello worldまで完了となり、これでRiverpodのセットアップは完了です。
ディレクトリ構成
以下のディレクトリ構成で作成 (※ repositoryは今回はファイルを作るだけで実際は何もしません)
- todo
- model
- todo_model.dart
- notifier
- todo_list_state_notifier.dart
- repository
- task_repository.dart
- task_repository_impl.dart
- provider
- todo_state_provider.dart
- task_repository_provider.dart
- widget
- title.dart
- todo_item.dart
- toolbar.dart
- screen
- todo_list_page.dart
- model
色々な構成がありえますが、機能毎にディレクトリを整理する方針を試してみます。screenの中は一つのページとして成立するものをおきます。中身はAtomicデザインのコンポーネントのように適度に分割されたwidgetを配置し、screenから呼び出すイメージです。もし機能横断的なものがあればtodoからは切り出して共通のディレクトリに置くことになると思います。
公式のサンプル実装だと todo.dart と main.dart しかなく、さすがに詰め込みすぎている印象があったので、以下のリポジトリを参考に自分なりに考えてみてます。
Todo List 実装
Todo のモデルを作成
import 'package:flutter/foundation.dart' show immutable;
@immutable
class Todo {
const Todo(
{required this.description, required this.id, this.completed = false});
final String id;
final String description;
final bool completed;
@override
String toString() {
return 'Todo(description: $description, completed: $completed)';
}
}
UTも書いておきます。
import 'package:flutter_riverpod_todo/todo/model/todo_model.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Todo', () {
test('Todo instance should be created', () {
final description = 'sampledescription';
final id = 'sampleid';
final todo = Todo(description: description, id: id);
expect(todo.completed, false);
});
test('toString', () {
final description = 'sampledescription';
final id = 'sampleid';
final todo = Todo(description: description, id: id);
expect(todo.completed, false);
final actual = todo.toString();
final expected = 'Todo(description: sampledescription, completed: false)';
expect(actual, expected);
});
});
}
Todo の repository を作成
ここでは何もしないrepositoryだけを作成しますが、本来であればDBやAPIと通信します。一応 interface (abstract class) を用意しますが、必須ではないです。
import 'package:flutter_riverpod_todo/todo/model/todo_model.dart';
abstract class TodoRepository {
Future<void> add(Todo todo);
Future<void> update(Todo todo);
Future<void> delete(Todo todo);
}
import 'package:flutter_riverpod_todo/todo/repository/todo_repository.dart';
import '../model/todo_model.dart';
class TodoRepositoryImpl implements TodoRepository {
@override
Future<void> add(Todo todo) async {}
@override
Future<void> update(Todo todo) async {}
@override
Future<void> delete(Todo todo) async {}
}
※何もしてません
Todo repository の provider を作成
repository の provider を作成します。ここで provider とは、以下のように singleton, service locators, dependency injection, inheritedWidgets の代替とのことです。Java と Spring Boot でよく開発しているBEメインの私としては、Spring BootにBean登録されたシングルトンクラスをDIするイメージで考えてます。
Providers are a complete replacement for patterns like Singletons, Service Locators, Dependency Injection or InheritedWidgets.
https://riverpod.dev/docs/concepts/providers
import 'package:flutter_riverpod_todo/todo/repository/todo_repository.dart';
import 'package:flutter_riverpod_todo/todo/repository/todo_repository_impl.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
return TodoRepositoryImpl();
});
※ riverpod_generator を使う場合はここの実装で @riverpod アノテーションを使うことになる想定です
Todo の StateNotifier を作成
Providerの時はこういった場合は ChangeNotifier を使っていました。Riverpodでも同様にChangeNotifierは使えるそうですが、あくまでProviderからの橋渡しに用意しているもので、非推奨とのことです。代わりに StateNotifier を使います。
参考: https://docs-v2.riverpod.dev/docs/from_provider/quickstart
ちなみに、以下にご丁寧にChangeNotifierを使った実装まで書いてくれてました。
https://docs-v2.riverpod.dev/docs/providers/change_notifier_provider
import 'package:flutter_riverpod_todo/todo/model/todo_model.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../repository/todo_repository.dart';
class TodoListStateNotifier extends StateNotifier<List<Todo>> {
final TodoRepository _todoRepository;
TodoListStateNotifier(this._todoRepository, [List<Todo>? initialTodos])
: super(initialTodos ?? []);
void add(String description, String id) {
state = [
...state,
Todo(
id: id,
description: description,
),
];
}
void toggle(String id) {
state = [
for (final todo in state)
if (todo.id == id)
Todo(
id: todo.id,
completed: !todo.completed,
description: todo.description,
)
else
todo,
];
}
void edit({required String id, required String description}) {
state = [
for (final todo in state)
if (todo.id == id)
Todo(
id: todo.id,
completed: todo.completed,
description: description,
)
else
todo,
];
}
void remove(String id) {
state = state.where((todo) => todo.id != id).toList();
}
}
簡単ですがUTも書いておきます。
import 'package:flutter_riverpod_todo/todo/notifier/todo_list_state_notifier.dart';
import 'package:flutter_riverpod_todo/todo/repository/todo_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockTodoRepository extends Mock implements TodoRepository {}
void main() {
late TodoListStateNotifier target;
late MockTodoRepository mockTodoRepository;
setUp(() {
mockTodoRepository = MockTodoRepository();
target = TodoListStateNotifier(mockTodoRepository);
});
group('add', () {
test('Should check add works expectedly', () {
final String description = 'newTodo';
final String id = 'sampleid';
expect(() => target.add(description, id), returnsNormally);
});
});
group('toggle', () {
test('Should check toggle works expectedly', () {
final String description = 'newTodo';
final String id = 'sampleid';
expect(() => target.add(description, id), returnsNormally);
expect(() => target.toggle(id), returnsNormally);
});
});
group('edit', () {
test('Should check edit works expectedly', () {
final String description = 'newTodo';
final String id = 'sampleid';
expect(() => target.add(description, id), returnsNormally);
expect(() => target.edit(id: id, description: 'edit'), returnsNormally);
});
});
group('remove', () {
test('Should check remove works expectedly', () {
final String description = 'newTodo';
final String id = 'sampleid';
expect(() => target.add(description, id), returnsNormally);
expect(() => target.remove(id), returnsNormally);
});
});
}
Todo の Provider を実装
TodoList の state を参照したりフィルタしたりする機能をここにまとめて実装しておきます。追ってscreen/widget側から参照します。
import 'package:flutter_riverpod_todo/todo/notifier/todo_list_state_notifier.dart';
import 'package:flutter_riverpod_todo/todo/provider/todo_repository_provider.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../model/todo_model.dart';
final todoListProvider =
StateNotifierProvider<TodoListStateNotifier, List<Todo>>((ref) {
final todoRepository = ref.watch(todoRepositoryProvider);
return TodoListStateNotifier(todoRepository, const [
Todo(id: 'todo-0', description: 'hi'),
Todo(id: 'todo-1', description: 'hello'),
Todo(id: 'todo-2', description: 'bonjour'),
]);
});
enum TodoListFilter {
all,
active,
completed,
}
/// The currently active filter.
///
/// We use [StateProvider] here as there is no fancy logic behind manipulating
/// the value since it's just enum.
final todoListFilter = StateProvider((_) => TodoListFilter.all);
/// The number of uncompleted todos
///
/// By using [Provider], this value is cached, making it performant.\
/// Even multiple widgets try to read the number of uncompleted todos,
/// the value will be computed only once (until the todo-list changes).
///
/// This will also optimise unneeded rebuilds if the todo-list changes, but the
/// number of uncompleted todos doesn't (such as when editing a todo).
final uncompletedTodosCount = Provider<int>((ref) {
return ref.watch(todoListProvider).where((todo) => !todo.completed).length;
});
final filteredTodos = Provider<List<Todo>>((ref) {
final filter = ref.watch(todoListFilter);
final todos = ref.watch(todoListProvider);
switch (filter) {
case TodoListFilter.completed:
return todos.where((todo) => todo.completed).toList();
case TodoListFilter.active:
return todos.where((todo) => !todo.completed).toList();
case TodoListFilter.all:
return todos;
}
});
/// A provider which exposes the [Todo] displayed by a [TodoItem].
///
/// By retrieving the [Todo] through a provider instead of through its
/// constructor, this allows [TodoItem] to be instantiated using the `const` keyword.
///
/// This ensures that when we add/remove/edit todos, only what the
/// impacted widgets rebuilds, instead of the entire list of items.
final currentTodo = Provider<Todo>((ref) => throw UnimplementedError());
Widgetの実装
import 'package:flutter/material.dart';
class TodoTitle extends StatelessWidget {
const TodoTitle({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const Text(
'todos',
textAlign: TextAlign.center,
style: TextStyle(
color: Color.fromARGB(38, 47, 47, 247),
fontSize: 100,
fontWeight: FontWeight.w100,
fontFamily: 'Helvetica Neue',
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod_todo/todo/screen/todo_list_page.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../provider/todo_list_state_provider.dart';
class Toolbar extends HookConsumerWidget {
const Toolbar({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(todoListFilter);
Color? textColorFor(TodoListFilter value) {
return filter == value ? Colors.blue : Colors.black;
}
return Material(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'${ref.watch(uncompletedTodosCount)} items left',
overflow: TextOverflow.ellipsis,
),
),
Tooltip(
key: allFilterKey,
message: 'All todos',
child: TextButton(
onPressed: () =>
ref.read(todoListFilter.notifier).state = TodoListFilter.all,
style: ButtonStyle(
visualDensity: VisualDensity.compact,
foregroundColor:
MaterialStateProperty.all(textColorFor(TodoListFilter.all)),
),
child: const Text('All'),
),
),
Tooltip(
key: activeFilterKey,
message: 'Only uncompleted todos',
child: TextButton(
onPressed: () => ref.read(todoListFilter.notifier).state =
TodoListFilter.active,
style: ButtonStyle(
visualDensity: VisualDensity.compact,
foregroundColor: MaterialStateProperty.all(
textColorFor(TodoListFilter.active),
),
),
child: const Text('Active'),
),
),
Tooltip(
key: completedFilterKey,
message: 'Only completed todos',
child: TextButton(
onPressed: () => ref.read(todoListFilter.notifier).state =
TodoListFilter.completed,
style: ButtonStyle(
visualDensity: VisualDensity.compact,
foregroundColor: MaterialStateProperty.all(
textColorFor(TodoListFilter.completed),
),
),
child: const Text('Completed'),
),
),
],
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../provider/todo_list_state_provider.dart';
/// The widget that that displays the components of an individual Todo Item
class TodoItem extends HookConsumerWidget {
const TodoItem({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final todo = ref.watch(currentTodo);
final itemFocusNode = useFocusNode();
final itemIsFocused = useIsFocused(itemFocusNode);
final textEditingController = useTextEditingController();
final textFieldFocusNode = useFocusNode();
return Material(
color: Colors.white,
elevation: 6,
child: Focus(
focusNode: itemFocusNode,
onFocusChange: (focused) {
if (focused) {
textEditingController.text = todo.description;
} else {
// Commit changes only when the textfield is unfocused, for performance
ref
.read(todoListProvider.notifier)
.edit(id: todo.id, description: textEditingController.text);
}
},
child: ListTile(
onTap: () {
itemFocusNode.requestFocus();
textFieldFocusNode.requestFocus();
},
leading: Checkbox(
value: todo.completed,
onChanged: (value) =>
ref.read(todoListProvider.notifier).toggle(todo.id),
),
title: itemIsFocused
? TextField(
autofocus: true,
focusNode: textFieldFocusNode,
controller: textEditingController,
)
: Text(todo.description),
),
),
);
}
}
bool useIsFocused(FocusNode node) {
final isFocused = useState(node.hasFocus);
useEffect(
() {
void listener() {
isFocused.value = node.hasFocus;
}
node.addListener(listener);
return () => node.removeListener(listener);
},
[node],
);
return isFocused.value;
}
screenの実装
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod_todo/todo/widget/toolbar.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../provider/todo_list_state_provider.dart';
import '../widget/title.dart';
import '../widget/todo_item.dart';
/// Some keys used for testing
final addTodoKey = UniqueKey();
final activeFilterKey = UniqueKey();
final completedFilterKey = UniqueKey();
final allFilterKey = UniqueKey();
const _uuid = Uuid();
class TodoListPage extends HookConsumerWidget {
const TodoListPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(filteredTodos);
final newTodoController = useTextEditingController();
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
children: [
const TodoTitle(),
TextField(
key: addTodoKey,
controller: newTodoController,
decoration: const InputDecoration(
labelText: 'What needs to be done?',
),
onSubmitted: (value) {
ref.read(todoListProvider.notifier).add(value, _uuid.v4());
newTodoController.clear();
},
),
const SizedBox(height: 42),
const Toolbar(),
if (todos.isNotEmpty) const Divider(height: 0),
for (var i = 0; i < todos.length; i++) ...[
if (i > 0) const Divider(height: 0),
Dismissible(
key: ValueKey(todos[i].id),
onDismissed: (_) {
ref.read(todoListProvider.notifier).remove(todos[i].id);
},
child: ProviderScope(
overrides: [
currentTodo.overrideWithValue(todos[i]),
],
child: const TodoItem(),
),
),
],
],
),
),
);
}
}
main.dart の修正
import 'package:flutter/material.dart';
import 'package:flutter_riverpod_todo/todo/screen/todo_list_page.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
runApp(
// For widgets to be able to read providers, we need to wrap the entire
// application in a "ProviderScope" widget.
// This is where the state of our providers will be stored.
ProviderScope(
child: MyApp(),
),
);
}
// Extend HookConsumerWidget instead of HookWidget, which is exposed by Riverpod
class MyApp extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return const MaterialApp(home: TodoListPage());
}
}
これで main.dart を実行してみると、動画にあげたTodo Listが動きます。
感想
2023年9月時点だとまだドキュメントがあまり充実しているとは言えない状況で、最初は結構複雑なんじゃないかなと思ってました。が、いざ手を動かしてみると、React とか Redux とかと大きく変わらないなという印象を受けました。まだ完璧に使いこなせるようになったとは言い難いですが、少なくとも何か実装できる程度には理解できたんじゃないかなと思います。
そろそろ本格的にオリジナルアプリを開発していこうと思います。