Chapter 03

StateNotifierの使い方

JboyHashimoto
JboyHashimoto
2023.02.27に更新

https://docs-v2.riverpod.dev/docs/providers/state_notifier_provider
StateNotifierProviderは、StateNotifier(Riverpodが再エクスポートするstate_notifierパッケージから)をリスンして公開するために使用するプロバイダです。

これは、通常、次のために使用されます。

カスタムイベントに反応した後、時間の経過とともに変化することができる、不変の状態を公開する。
状態を変更するためのロジック(別名「ビジネスロジック」)を一箇所に集中させ、長期的なメンテナンス性を向上させる。


使用するユースケース

StateNotifierは、アプリの状態を管理するためのFlutterの機能です。

StateNotifierを使用するユースケースとしては、以下のようなものが挙げられます。

  • ユーザーのログイン状態を管理する。
  • ユーザーのプロフィール情報を管理する。
  • ユーザーの設定情報を管理する。
  • ユーザーが検索した内容を記録しておく。
  • ユーザーのフォーム入力を管理する。
  • ユーザーが選択した項目を管理する。
  • ユーザーがアプリ内で実行した操作を記録する。

StateNotifierクラスの中に書いてある文字について、調べてみました。

stateとは?

The current "state" of this StateNotifier.
Updating this variable will synchronously call all the listeners. Notifying the listeners is O(N) with N the number of listeners.
Updating the state will throw if at least one listener throws.

このStateNotifierの現在の "状態"。
この変数を更新すると、すべてのリスナーが同期的に呼び出されます。リスナーへの通知は、リスナーの数をNとするとO(N)です。
状態の更新は、少なくとも1つのリスナーがスローした場合、スローします。

...とは?

Dart スプレッド構文

Dart におけるスプレッド構文とは、リストの要素を展開する構文です。以下の例を見てみましょう。

List<int> list = [1, 2, 3];
List<int> list2 = [4, 5, 6];

List<int> combinedList = [...list, ...list2];
// combinedList = [1, 2, 3, 4, 5, 6]

この例では、listlist2 の要素を結合した combinedList を作成しています。スプレッド構文を使うと、配列やコレクションの要素を他のオブジェクトに展開してコピーすることができます。

.whereとは?

Iterable where(bool Function(NotesModel) test) Containing class: Iterable Type: Iterable<NotesModel> Function(bool Function(NotesModel))
Returns a new lazy Iterable with all elements that satisfy the predicate test.
The matching elements have the same order in the returned iterable as they have in iterator.
This method returns a view of the mapped elements. As long as the returned Iterable is not iterated over, the supplied function test will not be invoked. Iterating will not cache results, and thus iterating multiple times over the returned Iterable may invoke the supplied function test multiple times on the same element.
Example:
final numbers = [1, 2, 3, 5, 6, 7];
var result = numbers.where((x) => x 5); // (1, 2, 3)
result = numbers.where((x) => x > 5); // (6, 7)
result = numbers.where((x) => x.isEven); // (2, 6)

Iterable where(bool Function(NotesModel) test) 含有するクラスです。Iterable 型。Iterable<NotesModel> Function(bool Function(NotesModel))
述語 test を満たす全ての要素を持つ新しい遅延 Iterable を返します。
マッチする要素は、返された Iterable の中でも iterator の中と同じ順序で並びます。
このメソッドは、マッピングされた要素のビューを返します。返されたIterableが反復されない限り、提供された関数テストは起動されません。反復処理では結果がキャッシュされないため、返された Iterable を何度も反復処理すると、指定した関数テストが同じ要素で何度も呼び出される可能性があります。

final numbers = [1, 2, 3, 5, 6, 7];
var result = numbers.where((x) => x 5); // (1, 2, 3)
result = numbers.where((x)=>x>5);//(6、7)
result = numbers.where((x)=>x.isEven);//(2、6)


ダミーデータを使うノートアプリで理解を深める

Freezedでint型のid,String型の文字、DateTime型の作成時刻を保存するビジネスロジックを考えてみました。

ダミーデータの型を定義したモデルクラスをまずは作ります。こちらのファイルを作成したら、Freezedのコマンドを入力して、ファイルを自動生成します。

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'notes_model.freezed.dart';
part 'notes_model.g.dart';


class NotesModel with _$NotesModel {
  const factory NotesModel({
    required int id,
    required String body,
    required DateTime createdAt,
  }) = _NotesModel;

  factory NotesModel.fromJson(Map<String, dynamic> json) =>
      _$NotesModelFromJson(json);
}

ファイルを自動生成するコマンド
ずっと起動していて、Freezedのクラスを書き換えたら、新しくファイルを自動生成してくれる。停止したいときは、Macだと、command + c のキーを押す。

flutter pub run build_runner watch --delete-conflicting-outputs

次に、初期値として空のリストを変数で持っていて、状態を操作することができるロジックを持っているメソッドを定義したStateNotifierクラスを作成します。
イメージとしては、この中には状態を持っている変数と関数が入っています。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/about_state_notifire/note_app/note_model/notes_model.dart';

class NoteNotifier extends StateNotifier<List<NotesModel>> {
  NoteNotifier() : super([]); // ここで初期化処理をする

  // 空っぽのクラスに、id、フォームの値、現在時刻を保存する.
  void addNote(NotesModel note) {
    state = [...state, note];
  }
  // ダミーのデータを削除する.
  void removeNote(NotesModel note) {
    state = state.where((_note) => _note != note).toList();
  }
}

ノートアプリを操作する画面。追加、表示、削除を行うことができます。

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/about_state_notifire/note_app/note_model/notes_model.dart';
import 'package:flutter_template/about_state_notifire/note_app/note_state/notes_statet.dart';

// StateNotifierクラスを外部ファイルで呼び出すプロバイダー.
final noteProvider = StateNotifierProvider<NoteNotifier, List<NotesModel>>(
    (ref) => NoteNotifier());

class NotesPage extends ConsumerWidget {
  const NotesPage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watchで、初期値が空のリストを呼び出して、監視する。
    final notesList = ref.watch(noteProvider);
    // 入力フォームの値をリストに保存するTextEditingController.
    final bodyController = TextEditingController();
    // ランダムな数値を作り出す変数.
    final randomId = Random().nextInt(100) + 50;
    // 現在時刻を表取得する変数.
    DateTime createdAt = DateTime.now();

    return Scaffold(
        appBar: AppBar(
          title: const Text('Notes App'),
        ),
        body: SingleChildScrollView(
          child: Center(
            child: Column(
              children: [
                const SizedBox(height: 40),
                Container(
                  width: 300,
                  child: TextField(
                    controller: bodyController,
                    decoration: InputDecoration(
                      labelText: 'Content',
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(10),
                      ),
                    ),
                  ),
                ),
                const SizedBox(height: 20),
                ElevatedButton(
                    onPressed: () {
                      // NoteModelクラスに、ダミーのデータを引数として渡して保存する.
                      ref.read(noteProvider.notifier).addNote(NotesModel(
                          id: randomId,
                          body: bodyController.text,
                          createdAt: createdAt));
                    },
                    child: const Text('Add note')),
                const SizedBox(height: 20),
                notesList.isEmpty
                    ? const Text('Add notes ')
                    : ListView.builder(
                        itemCount: notesList.length,// StateNotifierのリストに追加されたデータの数を数える.
                        shrinkWrap: true,
                        itemBuilder: (context, index) {
                          final note = notesList[index];
                          return ListTile(
                            title: Text('id: ${note.id} memo: ${note.body}'),// idとフォームから入力された値を表示.
                            subtitle: Text(note.createdAt.toIso8601String()),// リストにデータを追加した時刻を表示.
                            trailing: IconButton(
                              icon: const Icon(Icons.delete),
                              onPressed: () {
                                // リストのidを取得してボタンを押したリストだけ削除する.
                                ref
                                    .read(noteProvider.notifier)
                                    .removeNote(notesList[index]);
                              },
                            ),
                          );
                        })
              ],
            ),
          ),
        ));
  }
}

main.dartでNotePageインポートして、ビルドすればアプリの動作を試すことができます。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/about_state_notifire/note_app/ui/notes_page.dart';

void main() {
  runApp(ProviderScope(child: const MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
          appBarTheme: const AppBarTheme(
        backgroundColor: Colors.indigoAccent, // AppBarの色.
        foregroundColor: Colors.grey, // AppBarのフォントの色.
        centerTitle: true, // AppBarのAndroidのタイトルを中央寄せにする.
      )),
      home: const NotesPage(),
    );
  }
}