📚

FlutterのデフォルトアプリをRiverpodを使った形にしたサンプルコード

2023/01/08に公開

概要

Flutterのプロジェクト生成時の状態のアプリ(カウンターアプリ)を、Riverpodを使った形にしたサンプルコードです。

全サンプルコード

以下のPRで全サンプルコードの全体像を参照できます。
https://github.com/eno314/flutter_demo/pull/1

作成した各ファイルの概要

  • counter_page.dart
    • providerを使ってカウンターの状態を扱い、templateに渡す値を取得したりイベントの実装をして、templateを呼び出す
  • counter_template.dart
    • カウンターページに表示させるWidgetを定義したテンプレート
  • counter_notifier.dart
    • StateNotifierを継承してカウンタの状態を管理するCounterNotifierクラスと、そのproviderを定義
  • counter_page_test.dart
    • カウンターページに表示されているものとボタンイベントのテストコード

各ファイルのコードとメモ

main.dart

void main() {
  // Riverpodのproviderを利用できるようにするため、ProviderScopeでルートのwidgetをラップ
  runApp(const ProviderScope(child: MyApp()));
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const CounterPage(),
    );
  }
}

counter_page.dart

class CounterPage extends ConsumerWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watchでカウンターの状態を取得
    final counter = ref.watch(counterNotifierProvider);
    return CounterTemplate(
      title: 'Counter with Riverpod',
      message: 'You have pushed the button this many times:',
      counter: counter,
      onPressedIncrementButton: (() => _incrementCounter(ref)),
    );
  }

  void _incrementCounter(WidgetRef ref) {
    // IncrementButtonが押された時に、ref.readでcounterNotifierを取得し、カウンターの状態を更新
    final counterNotifier = ref.read(counterNotifierProvider.notifier);
    counterNotifier.increment();
  }
}

counter_template.dart

class CounterTemplate extends StatelessWidget {
  // ページに表示させる値やイベントはテンプレート内では定義せずパラメータとして受け取る
  final String title;
  final String message;
  final int counter;
  final void Function() onPressedIncrementButton;

  const CounterTemplate({
    super.key,
    required this.title,
    required this.message,
    required this.counter,
    required this.onPressedIncrementButton,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(message),
            Text(
              '$counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: onPressedIncrementButton,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

counter_notifier

class CounterNotifier extends StateNotifier<int> {
  // `super(0)`でカウンターの初期値を0に指定
  CounterNotifier() : super(0);

  // 他のクラスから直接stateを更新することもできるが、メソッドを用意して何をしているかより明確にしている
  void increment() => state++;
}

// notifierのproviderは同じファイル内で定義したほうが分かりやすそうに思えたので、ここで定義
final counterNotifierProvider =
    StateNotifierProvider<CounterNotifier, int>((ref) => CounterNotifier());

counter_page_test.dart

void main() {
  // 表示されているwidgetのテスト
  testWidgets('''
    Counter page has a title, message, and increment button
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    const expectTitle = 'Counter with Riverpod';
    const expectMessage = 'You have pushed the button this many times:';

    expect(find.text(expectTitle), findsOneWidget);
    expect(find.text(expectMessage), findsOneWidget);
    expect(find.byType(FloatingActionButton), findsOneWidget);
  });

  // カウンタの初期値のテスト
  testWidgets('''
    Counter initial value is 0
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);
  });

  // ボタンが押された時にカウントアップされることのテスト
  testWidgets('''
    When the increment button is pressed,
    Then the counter value is incremented
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();

    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();

    expect(find.text('1'), findsNothing);
    expect(find.text('2'), findsOneWidget);
  });
}

Widget _buildTestWidget() {
  // テストでもriverpodを使うので、ProviderScopeでラップ
  return const ProviderScope(
    // CounterPageはScaffoldを使っているので、MaterialAppでラップ (詳細は下記)
    child: MaterialApp(
      home: CounterPage(),
    ),
  );
}

Scaffoldのwidgetをテストする際の注意点

Scaffoldのwidgetをtester.pumpWidgetに指定する際はMaterialAppでラップする必要があります。指定しないと以下のようなエラーが発生します。

══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building Scaffold(dirty, state: ScaffoldState#e0693(tickers:
tracking 2 tickers)):
No MediaQuery widget ancestor found.
Scaffold widgets require a MediaQuery widget ancestor.
...
This can happen because you have not added a WidgetsApp, CupertinoApp, or MaterialApp widget (those
widgets introduce a MediaQuery), or it can happen if the context you use comes from a widget above those widgets.

要約すると、Scaffold widgets のような MediaQuery widget には先祖が必要で、WidgetsApp, CupertinoApp, or MaterialApp widget がないと発生するエラー

参考

Discussion