🔖

【Flutter】Riverpodを使ってフォームの値を管理するサンプル

2023/01/11に公開

概要

FlutterのTextFieldやDropdownButtonの値を、Riverpodを使って管理するサンプルです。

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

機能

  1. 適当なテキストを入力できるTextField, 固定値を選択できるDropdownButton, Post用のButton
  2. TextFieldとDropdownButtonに値があるときだけ、Post用のButtonを押すことができる
  3. Post用のButtonを押すと、TextFieldとDropdownButtonに値をログに落として、値をリセットする

各ファイルの概要と主なコード

  • form_template.dart
    • ページに表示させるwidgetを定義したテンプレートファイル
    • 各widgetの値やイベントは引数として受け取る
  • form_props.dart
    • TextFieldとDropdownButtonの値だけStateNotifierで管理したいため、FormValuesPropsとして別クラスで定義
    • copyWithメソッドを使いたいためfreezedを利用
  • form_notifier.dart
    • StateNotifierを継承してFormValuesPropsの状態を管理するクラスと、そのproviderを定義
  • form_page.dart
    • provider経由でFormValuesPropsの状態を取得してtemplateに渡す
    • 各イベントの実装して、それぞれのイベントでそれぞれの値をprovider経由で更新する
  • form_page_test.dart
    • 表示されているwidgetと、TextFieldとDropdownButtonの値が無いことのチェック
    • TextFieldにだけ値があってもPostボタンが有効にならないことのチェック
    • DropdownButtonにだけ値があってもPostボタンが有効にならないことのチェック
    • TextFieldとDropdownButtonに値があればPostボタンが有効になることのチェック
    • TextFieldに一度値を入れて、その後値を削除した際にPostボタンが有効にならないことのチェック
    • Postボタンがタップされたら、TextFieldとDropdownButtonに値がなくなり、Postボタンが無効になることのチェック

主なコード

form_template.dart

class FormTemplate extends StatelessWidget {
  final FormProps props;
  final ValueChanged<String> onChangedTextField;
  final ValueChanged<FormDropdownValue?> onChangedDropdownValue;
  final VoidCallback onPressedPostButton;

  const FormTemplate({
    super.key,
    required this.props,
    required this.onChangedTextField,
    required this.onChangedDropdownValue,
    required this.onPressedPostButton,
  });
  
  ...
  
  Widget _buildTextField() {
    return ...
      child: TextField(
        controller: TextEditingController(text: props.formValues.textField),
	decoration: InputDecoration(
          labelText: props.textFieldLabel,
        ),
        onChanged: onChangedTextField,
      ),
    );
  }
  
  ...
  
  Widget _buildDropdownButton() {
    return ...
      child: DropdownButton<FormDropdownValue>(
        hint: Text(props.dropdownButtonHint),
        items: FormDropdownValue.values.map(_buildDropdownMenuItem).toList(),
        onChanged: onChangedDropdownValue,
        value: props.formValues.dropdown,
      ),
    );
  }
  
  ...
  
  Widget _buildPostButton() {
    return ...
        child: ElevatedButton(
	  // 有効な時以外はイベントをnullにする
	  // onPressedとonLongPressが両方nullだと、ボタンはdisabledになる
	  // @see https://api.flutter.dev/flutter/material/ElevatedButton-class.html
          onPressed: props.isEnabledPostButton() ? onPressedPostButton : null,
          child: Text(props.postButtonText),
        ),
      ),
    );
  }
}

form_props.dart

class FormProps {
  ...
  final FormValuesProps formValues;

  const FormProps({
    ...
    required this.formValues,
  });

  isEnabledPostButton() {
    return formValues.textField != null &&
        formValues.textField!.isNotEmpty &&
        formValues.dropdown != null;
  }
}

@freezed
class FormValuesProps with _$FormValuesProps {
  factory FormValuesProps({
    final String? textField,
    final FormDropdownValue? dropdown,
  }) = _FormValuesProps;
}

...

form_notifier.dart

class FormNotifier extends StateNotifier<FormValuesProps> {
  FormNotifier() : super(FormValuesProps());

  void onChangedTextField(String value) {
    state = state.copyWith(textField: value);
  }

  void onChangedDropdownValue(FormDropdownValue? value) {
    state = state.copyWith(dropdown: value);
  }

  void reset() {
    state = FormValuesProps();
  }
}

final formNotifierProvider =
    StateNotifierProvider<FormNotifier, FormValuesProps>(
  (ref) => FormNotifier(),
);

form_page.dart

class FormPage extends ConsumerWidget {
  ...

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final formValues = ref.watch(formNotifierProvider);
    return FormTemplate(
      props: FormProps(
        ...
        formValues: formValues,
      ),
      onChangedTextField: (value) => _onChangedTextField(value, ref),
      onChangedDropdownValue: (value) => _onChangedDropdownValue(value, ref),
      onPressedPostButton: () => _onPressedPostButton(ref),
    );
  }
  
  void _onChangedTextField(String value, WidgetRef ref) {
    final notifier = ref.watch(formNotifierProvider.notifier);
    notifier.onChangedTextField(value);
  }

  void _onChangedDropdownValue(FormDropdownValue? value, WidgetRef ref) {
    final notifier = ref.watch(formNotifierProvider.notifier);
    notifier.onChangedDropdownValue(value);
  }

  void _onPressedPostButton(WidgetRef ref) {
    log('state : ${ref.read(formNotifierProvider)}');
    final notifier = ref.watch(formNotifierProvider.notifier);
    notifier.reset();
  }
}

テストコード

void main() {
  final textFieldFinder = find.byKey(const Key('textFieldDemo'));
  final dropdownFinder = find.byKey(const Key('dropdownButtonDemo'));
  final postButtonFinder = find.byKey(const Key('postButtonDemo'));

  testWidgets('''
    When Page is initialized,
    Then there are 4 widgets:
      - Text (having title)
      - TextField (empty)
      - DropdownButton (no selection)
      - PostButton (disabled)
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    expect(find.text(FormPage.title), findsOneWidget);

    expect(textFieldFinder, findsOneWidget);
    _assertTextFieldIsEmpty(tester, textFieldFinder);

    expect(dropdownFinder, findsOneWidget);
    _assertDropdownButtonValueIsNull(tester, dropdownFinder);

    expect(postButtonFinder, findsOneWidget);
    _assertButtonEnabled(tester, postButtonFinder, isFalse);
  });

  testWidgets('''
    When only TextField has value,
    Then PostButton is disabled.
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    await _inputText(tester, textFieldFinder, 'test');

    _assertButtonEnabled(tester, postButtonFinder, isFalse);
  });

  testWidgets('''
    When only DropdownButton has value,
    Then PostButton is disabled.
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    await _selectDropdown(tester, dropdownFinder, FormDropdownValue.work.name);

    _assertButtonEnabled(tester, postButtonFinder, isFalse);
  });

  testWidgets('''
    When TextField and DropdownButton have value,
    Then PostButton is enabled.
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    await _inputText(tester, textFieldFinder, 'test');
    await _selectDropdown(tester, dropdownFinder, FormDropdownValue.work.name);

    _assertButtonEnabled(tester, postButtonFinder, isTrue);
  });

  testWidgets('''
    When DropdownButton have value and TextField value is removed,
    Then PostButton is disabled.
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    await _inputText(tester, textFieldFinder, 'test');
    await _selectDropdown(tester, dropdownFinder, FormDropdownValue.work.name);
    await _inputText(tester, textFieldFinder, '');

    _assertButtonEnabled(tester, postButtonFinder, isFalse);
  });

  testWidgets('''
    When PostButton is tapped,
    Then TextField and Dropdown is cleared.
  ''', (tester) async {
    await tester.pumpWidget(_buildTestWidget());

    await _inputText(tester, textFieldFinder, 'test');
    await _selectDropdown(tester, dropdownFinder, FormDropdownValue.work.name);

    await tester.tap(postButtonFinder);
    await tester.pumpAndSettle();

    _assertTextFieldIsEmpty(tester, textFieldFinder);
    _assertDropdownButtonValueIsNull(tester, dropdownFinder);
    _assertButtonEnabled(tester, postButtonFinder, isFalse);
  });
}

Widget _buildTestWidget() {
  return const ProviderScope(child: MaterialApp(home: FormPage()));
}

Future<void> _inputText(WidgetTester tester, Finder finder, String text) async {
  await tester.enterText(finder, text);
  await tester.pumpAndSettle();
}

Future<void> _selectDropdown(
  WidgetTester tester,
  Finder finder,
  String selectedValue,
) async {
  // DropdownButtonをタップして選択肢を開く
  await tester.tap(finder);
  await tester.pumpAndSettle();

  // DropdownButtonの値を選択
  // 注意点 : find.textの結果が複数返ってくるので`last`をつけている
  // 参照  : https://stackoverflow.com/questions/69012695/flutter-how-to-select-dropdownbutton-item-in-widget-test/69017359#69017359
  await tester.tap(find.text(selectedValue).last);
  await tester.pumpAndSettle();
}

void _assertTextFieldIsEmpty(WidgetTester tester, Finder finder) {
  final textField = tester.widget<TextField>(finder);
  expect(textField.controller?.text, isEmpty);
}

void _assertDropdownButtonValueIsNull(WidgetTester tester, Finder finder) {
  final dropdownButton = tester.widget<DropdownButton<FormDropdownValue>>(
    finder,
  );
  expect(dropdownButton.value, isNull);
}

void _assertButtonEnabled(WidgetTester tester, Finder finder, Matcher matcher) {
  final button = tester.widget<ElevatedButton>(finder);
  expect(button.enabled, matcher);
}

参考

If onPressed and onLongPress callbacks are null, then the button will be disabled.

when we tap the drop down one was already selected and other one is not selected. So you can test that item using the text value.

Discussion