【Flutter】Autocomplete応用編

  • 2024年10月23日
  • 2024年10月23日
  • 小物

対象者

  • FlutterでAutocompleteウィジェットを深く理解したい方
  • カスタマイズされた入力フォームを作成したい開発者
  • ユーザー体験を向上させるためのサジェスト機能に興味があるエンジニア

はじめに

以前、FlutterのAutocompleteウィジェットについてブログを書きました。改めて使ってみると、つまずく点がありました。特に、サジェストリストの消し方や、送信ボタンを押したときの挙動など、基本的な使い方から一歩進んだ応用的な部分での課題が見つかりました。今回は、その経験を踏まえて、Autocompleteウィジェットの応用編として解説していきます。

Autocompleteの基本

デザイン方法などは以下を参照してください。今回は主に、ボタンなど他のWidgetとの連携がメインになります

【Flutter】Autocompleteで検索効率をアップ!

Autocompleteウィジェットの応用実装

サジェストリストにユーザー入力を含める

サジェストリストに、あらかじめ用意したオプションだけでなく、ユーザーが直前に入力・送信した文字列も含めたい場合があります。そのために、_fetchOptions関数を以下のように変更しました。

Future<List<String>> _fetchOptions(String value) async {
  await Future.delayed(const Duration(milliseconds: 300));
  return [if (_submittedText.isNotEmpty) _submittedText, ..._options]
      .where((e) => e.toLowerCase().contains(value.toLowerCase()))
      .toList();
}

これにより、送信された文字列_submittedTextがサジェストリストの先頭に追加されます。実際のアプリでは、ユーザ履歴をもう少し多めに取り、重複があれば削除しています。

サジェストリストに「履歴」と表示する

送信済みの文字列をサジェストリストに表示する際、それが履歴であることをユーザーに示すために、displayStringForOptionをカスタマイズします。

displayStringForOption: (value) =>
    (value == _submittedText ? '[履歴]' : '') + value,

これにより、送信済みの文字列には[履歴]が付加されて表示されます。ユーザが入力したものかどうかを区別できるようになります。

このメソッドを知ったことで、AutocompleteStringだけでなく任意のクラスをサジェストに使用でき、ユーザーに見せる項目を設定できると気づきました。

onSelectedの役割

onSelectedは、ユーザーがサジェストリストからアイテムを選択したときに呼び出されるコールバックです。選択された値を受け取り、必要な処理を行います。

onSelected: (String selection) => _onSelected(selection),

ここでは、選択された値を_selectedTextにセットしています。

fieldViewBuilderの使い方

fieldViewBuilderは、カスタムの入力フィールドを構築するためのウィジェットを返します。Autocompleteウィジェットに独自のテキストフィールドを組み込みたい場合に使用します。

fieldViewBuilder: (
  BuildContext context,
  TextEditingController fieldTextEditingController,
  FocusNode fieldFocusNode,
  VoidCallback onFieldSubmitted,
) {
  return Row(
    children: [
      Expanded(
        child: TextField(
          controller: fieldTextEditingController,
          focusNode: fieldFocusNode,
          decoration: const InputDecoration(
            labelText: 'フルーツを入力',
          ),
          onSubmitted: (value) => _onSubmitted(value),
          onChanged: (String value) {
            setState(() {
              _selectedText = value;
              _isSubmitted = false;
            });
          },
        ),
      ),
      const SizedBox(width: 8),
      ElevatedButton(
        onPressed: () {
          final text = fieldTextEditingController.text;
          _onSubmitted(text);
          fieldTextEditingController.text = text;
          // onFieldSubmitted(); 
        },
        child: const Text('送信'),
      ),
    ],
  );
},

このコードでは、TextField送信ボタンを横並びに配置し、ユーザーが入力や送信を行えるようにしています。

サジェストリストを消す方法

送信ボタンを押した後、サジェストリストを消したい場合があります。しかし、一般的な実装ではテキストフィールドをクリアするため、今回は入力した単語をそのまま残す必要があります。そこで、optionsBuilderで空のリストを返すことでサジェストリストを非表示にします。

optionsBuilder: (TextEditingValue textEditingValue) {
  if (textEditingValue.text.isEmpty || _isSubmitted) {
    return const Iterable<String>.empty();
  }
  return _fetchOptions(textEditingValue.text);
},

_isSubmittedtrueの場合、空のリストを返してサジェストリストを消します。最初はフォーカスが外れたら自動的に消えると考えていたため、戸惑いました。そうではなく、空リストをサジェストさせる処理が必要でした。

optionsBuilderを再度実行するための工夫

サジェストワードを消すには、optionsBuilderが空リストになる条件を作った上で、optionsBuilderを再度実行してサジェストリストを更新する必要があります。そのためには、以下のいずれかの方法でonChangedを発火させます。

  • fieldTextEditingController.textを更新する
  • fieldTextEditingController.clear()を呼び出す
  • onFieldSubmitted()を実行する
  • ただし、
onPressed: () {
  final text = fieldTextEditingController.text;
  _onSubmitted(text);
  fieldTextEditingController.text = text; // テキストを再設定
  onFieldSubmitted(); // または、fieldTextEditingController.clear();
},

これにより、optionsBuilderが再度実行され、サジェストリストが更新されます。ユースケースに応じて使い分けてください。

コントローラとフォーカスノードの結びつけ

既存のテキストフィールドにAutocompleteを追加する場合、テキストフィールドのcontrollerfocusNodeAutocompleteから渡されるものと結びつける必要があります。

TextField(
  controller: fieldTextEditingController,
  focusNode: fieldFocusNode,
  // ...
),

これを怠ると、サジェストリストが正しく表示されなかったり、意図しない挙動を示すことがあります。

Riverpodとの連携

状態管理としてRiverpodを使っていました。そのため、Autocompleteを含むWidgetの引数として、外からRiverpodの値を渡していました。そして Autocomplete作成時のRiverpodの値が渡され、状態で差異が出ました。(Autocompleteを含むWuidgetを再利用したいため、内部にRiverpodのProviderを持ちたくない)

最終的にWidgetを作った時点の「値」ではなく、取得する「メソッド」を引数にすることで、optionsBuilder実行時のRiverpodの値が取得できるようになり、意図した動作になりました。

Q&A

Q1: サジェストリストが消えない場合の対処法は?

A1: optionsBuilderで空のリストを返すようにします。具体的には、送信後にフラグ(例:_isSubmitted)を立てて、optionsBuilder内でそのフラグをチェックし、空のリストを返します。

Q2: optionsBuilderを再度実行させるにはどうすればいいですか?

A2: 以下のいずれかの方法でoptionsBuilderを再度実行できます。

  1. fieldTextEditingController.textを更新する:テキストを再設定することで、onChangedが発火し、optionsBuilderが再度実行されます。
  2. fieldTextEditingController.clear()を呼び出す:テキストフィールドをクリアすることで、onChangedが発火します。
  3. onFieldSubmitted()を実行するfieldViewBuilderの第四引数であるonFieldSubmittedを呼び出すことで、optionsBuilderを再度実行できます。

Q3: 既存のテキストフィールドにAutocompleteを追加するときの注意点は?

A3: テキストフィールドのcontrollerfocusNodeを、Autocompleteから渡されるfieldTextEditingControllerfieldFocusNodeに設定する必要があります。これをしないと、サジェスト機能が正しく動作しません。

まとめ

今回は、Autocompleteウィジェットの応用的な使い方について解説しました。特に、ユーザーの入力履歴をサジェストに含めたり、サジェストリストの表示・非表示を制御する方法について詳しく説明しました。

これらの実装により、ユーザー体験をより向上させることができます。開発中につまずいた点や解決策も共有しましたので、同じような課題に直面した際の参考になれば幸いです。

ソース(main.dartにコピペして動作確認用)

import 'package:flutter/material.dart';

void main() => runApp(AutoCompleteSample());

class AutoCompleteSample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('AutoComplete Sample')),
        body: AutoCompleteWidget(),
      ),
    );
  }
}

class AutoCompleteWidget extends StatefulWidget {
  @override
  _AutoCompleteWidgetState createState() => _AutoCompleteWidgetState();
}

class _AutoCompleteWidgetState extends State<AutoCompleteWidget> {
  final List<String> _options = [
    'Apple',
    'Banana',
    'Cherry',
    'Date',
    'Grape',
    'Orange',
    'Pineapple',
  ];

  var _selectedText = '';
  var _submittedText = '';

  var _isSubmitted = false;

  void _onSelected(String value) {
    setState(() {
      _selectedText = value;
    });
  }

  void _onSubmitted(String value) {
    setState(() {
      _submittedText = value;
      _isSubmitted = true;
    });
  }

  Future<List<String>> _fetchOptions(String value) async {
    await Future.delayed(const Duration(milliseconds: 300));
    return [if (_submittedText.isNotEmpty) _submittedText, ..._options]
        .where((e) => e.toLowerCase().contains(value.toLowerCase()))
        .toList();
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Column(
        children: [
          Autocomplete<String>(
            optionsBuilder: (TextEditingValue textEditingValue) {
              if (textEditingValue.text.isEmpty || _isSubmitted) {
                return const Iterable<String>.empty();
              }

              return _fetchOptions(textEditingValue.text);
            },
            onSelected: (String selection) => _onSelected(selection),
            displayStringForOption: (value) =>
                (value == _submittedText ? '[履歴]' : '') + value,
            fieldViewBuilder: (
              BuildContext context,
              TextEditingController fieldTextEditingController,
              FocusNode fieldFocusNode,
              VoidCallback onFieldSubmitted,
            ) {
              return Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: fieldTextEditingController,
                      focusNode: fieldFocusNode,
                      decoration: const InputDecoration(
                        labelText: 'フルーツを入力',
                      ),
                      onSubmitted: (value) => _onSubmitted(value),
                      onChanged: (String value) {
                        setState(() {
                          _selectedText = value;
                          _isSubmitted = false;
                        });
                      },
                    ),
                  ),
                  const SizedBox(width: 8),
                  ElevatedButton(
                    onPressed: () {
                      final text = fieldTextEditingController.text;
                      _onSubmitted(text);

                      fieldTextEditingController.text = text;
                      // onFieldSubmitted();
                    },
                    child: const Text('送信'),
                  ),
                ],
              );
            },
          ),
          const Spacer(),
          Text('入力された文字列: $_selectedText'),
          Text('送信された文字列: $_submittedText'),
        ],
      ),
    );
  }
}