【Flutter】RawAutocomplete 実装の検索フォームにおける候補ワードを取得する際の状態管理

エキサイト株式会社の@mthiroshiです。

FlutterでRawAutocompleteを使って、テキストの入力に応じて検索候補のワードを表示する、検索フォームを実装してみました。検索候補ワードは、APIから取得します。

下記が公式ドキュメントです。公式ドキュメントには、UIに関するいくつかのサンプルコードが載っています。 api.flutter.dev

実装

ここではViewとViewModel、UiStateのサンプルコードを紹介します。 Android開発におけるUIレイヤーの設計に倣っています。

まずは、UiStateです。 モデル定義のため Freezed を使っています。

part 'demo_search_app_bar_ui_state.freezed.dart';

@freezed
class DemoSearchAppBarUiState with _$DemoSearchAppBarUiState {
  factory DemoSearchAppBarUiState({
    @JsonKey(defaultValue: '') required String inputText,
    required List<String> suggestWords,
  }) = _DemoSearchAppBarUiState;

  const DemoSearchAppBarUiState._();

  factory DemoSearchAppBarUiState.blank() =>
      DemoSearchAppBarUiState(inputText: '', suggestWords: []);
}

UiStateでは、入力テキストの inputText と検索候補ワードリストの suggestWords を持ちます。

次に、ViewModelについてです。データの取得ロジックと状態管理はここで行います。状態管理ライブラリにRivperpodを使います。

part 'demo_search_app_bar_view_model_provider.g.dart';

@riverpod
class DemoSearchAppBarViewModel extends _$DemoSearchAppBarViewModel {
  @override
  Future<DemoSearchAppBarUiState> build() async {
    return DemoSearchAppBarUiState.blank();
  }

  Future<void> suggest(String inputText) async {
    final stateValue = state.requireValue;

    if (inputText.isEmpty) {
      state = await AsyncValue.guard<DemoSearchAppBarUiState>(() async {
        return DemoSearchAppBarUiState.blank();
      });
      return;
    }

    if (inputText == stateValue.inputText) {
      return;
    }

    final suggestWords = await ref
        .read(searchRepositoryProvider)
        .getSuggestKeyword(inputText: inputText);

    state = await AsyncValue.guard<DemoSearchAppBarUiState>(() async {
      return DemoSearchAppBarUiState(
        inputText: inputText,
        suggestWords: suggestWords,
      );
    });
  }
}

VIewModelの suggest()では、Repositoryを呼び出してAPIリクエストをします。 その後、取得したデータをUiStateに保持します。

続いて、Viewのサンプルコードです。

class DemoSearchAppBar extends ConsumerWidget with PreferredSizeWidget {
  DemoSearchAppBar({this.onSubmit, this.onTapTextField, super.key});

  final Future<void> Function(String selected)? onSubmit;

  final Future<void> Function()? onTapTextField;

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final uiState = ref.watch(searchAppBarViewModelProvider);

    return AppBar(
      backgroundColor: Colors.white,
      title: SizedBox(
        height: 50,
        child: uiState.when(
          data: (_) {
            return RawAutocomplete<String>(
              optionsBuilder: (TextEditingValue textEditingValue) async {
                await ref
                    .read(searchAppBarViewModelProvider.notifier)
                    .suggest(textEditingValue.text);

                return ref
                    .read(searchAppBarViewModelProvider)
                    .requireValue
                    .suggestWords;
              },
              optionsViewBuilder: (
                BuildContext context,
                AutocompleteOnSelected<String> onSelected,
                Iterable<String> options,
              ) {
                return Align(
                  alignment: Alignment.topLeft,
                  child: Material(
                    elevation: 4,
                    child: ListView.builder(
                      shrinkWrap: true,
                      padding: const EdgeInsets.all(8),
                      itemCount: options.length,
                      itemBuilder: (BuildContext context, int index) {
                        final String option = options.elementAt(index);
                        return GestureDetector(
                          onTap: () {
                            onSelected(option);
                          },
                          child: PlatformListTile(
                            title: Text(option),
                          ),
                        );
                      },
                    ),
                  ),
                );
              },
              fieldViewBuilder: (
                BuildContext context,
                TextEditingController textEditingController,
                FocusNode focusNode,
                VoidCallback _,
              ) {
                return TextFormField(
                  onTap: () {
                    onTapTextField?.call();
                  },
                  controller: textEditingController,
                  focusNode: focusNode,
                  onFieldSubmitted: (String searchText) async {
                    focusNode.unfocus();
                    await onSubmit?.call(searchText);
                  },
                  decoration: InputDecoration(
                    hintText: '検索ワード',
                    contentPadding: const EdgeInsets.all(8),
                    filled: true,
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(8),
                      borderSide: BorderSide.none,
                    ),
                  ),
                );
              },
              onSelected: (String selection) async {
                final FocusScopeNode currentScope = FocusScope.of(context);
                if (!currentScope.hasPrimaryFocus && currentScope.hasFocus) {
                  FocusManager.instance.primaryFocus!.unfocus();
                }
                await onSubmit?.call(selection);
              },
            );
          },
          error: (_, __) {
            return null;
          },
          loading: () {
            return null;
          },
        ),
      ),
    );
  }
}

最初に、ViewModelProviderをref.watch()して、ViewModelの変更を監視します。

そして、optionsBuilder で検索候補のワードを設定します。 optionsBuilder内のコールバックは、TextFieldに入力がある度に実行されます。 ここで入力中のテキストを受け取り、ViewModelの suggest() を呼び出します。そして、ViewModelのUiStateをref.read()で参照して、optionsに設定しています。

テキスト入力に応じて候補ワードを出す検索フォーム

optionsBuilderのUI反映のタイミングに注意

optionsBuilder内で下記のように監視したUiStateの値を返却値とする場合、更新前の状態がoptionsに反映されてしまいます。

        child: uiState.when(
          data: (state) {
            return RawAutocomplete<String>(
              optionsBuilder: (TextEditingValue textEditingValue) async {
                await ref
                    .read(searchAppBarViewModelProvider.notifier)
                    .suggest(textEditingValue.text);
                
                return state.suggestWords;
             }

UiStateの反映タイミングの遅れ

GIFのように、1操作ズレたUI反映になります。 これは、変更の流れが以下のようになるためです。

  1. テキストを入力して、optionsBuilder のコールバックが発火。
  2. suggest()で取得したデータを基に、UiStateを更新。
  3. optionsBuilder 内で更新前のUiStateをreturnし、コールバックを終了。
  4. UiStateの変更を検知し、Widgetのbuild()が再実行され、UiStateが更新される。

つまり、UiStateの更新がoptionsBuilderのコールバックの終了後に実行されるため、optionsBuilder内のUiStateは前の状態のままとなります。

そのため、optionsBuilder内では、直接UiStateを参照しないと最新の状態を取得できません。

もし、検索候補ワードを状態管理しない設計であれば、optionsBuilder内でAPI取得し、returnするだけなので、この問題は関係ありません。

まとめ

FlutterのRawAutocompleteを使って、テキスト入力に応じて検索候補ワードを表示する検索フォームを実装しました。また、検索候補ワードの状態管理をする際の処理の流れについて説明しました。 殆どの場合、表示に使うデータは状態管理されることが多いので、今回のような処理になると思います。

どなたかの参考になれば幸いです。

参考記事

pub.dev

riverpod.dev

developer.android.com