エキサイト株式会社の@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; }
GIFのように、1操作ズレたUI反映になります。 これは、変更の流れが以下のようになるためです。
- テキストを入力して、optionsBuilder のコールバックが発火。
suggest()
で取得したデータを基に、UiStateを更新。- optionsBuilder 内で更新前のUiStateをreturnし、コールバックを終了。
- UiStateの変更を検知し、Widgetのbuild()が再実行され、UiStateが更新される。
つまり、UiStateの更新がoptionsBuilderのコールバックの終了後に実行されるため、optionsBuilder内のUiStateは前の状態のままとなります。
そのため、optionsBuilder内では、直接UiStateを参照しないと最新の状態を取得できません。
もし、検索候補ワードを状態管理しない設計であれば、optionsBuilder内でAPI取得し、returnするだけなので、この問題は関係ありません。
まとめ
FlutterのRawAutocompleteを使って、テキスト入力に応じて検索候補ワードを表示する検索フォームを実装しました。また、検索候補ワードの状態管理をする際の処理の流れについて説明しました。 殆どの場合、表示に使うデータは状態管理されることが多いので、今回のような処理になると思います。
どなたかの参考になれば幸いです。