Flutter リアルタイム絞り込み検索(インクリメンタルサーチ)で入力候補を表示する

リアルタイム絞り込み検索(インクリメンタルサーチ)を2パターンのユースケースで実装したので紹介します。
`Autocomplete`という超便利なWidgetがあったので、ネイティブと比較してもかなり簡単に実装できました！
※ 今回の記事ではリモートから取得はせず、Mockデータを使います


## ① 候補欄にTextのみ表示したいとき
もし、下記のようなテキストだけを候補として表示したいのなら[公式の実装](https://api.flutter.dev/flutter/material/Autocomplete-class.html)をそのままコピペするだけでほぼできちゃいます！
![Videotogif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670405/1cea4647-3cee-237d-160c-2743ff0ed487.gif)


### 実装コード
```basic.dart
class AutocompleteBasicApp extends StatelessWidget {
  const AutocompleteBasicApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const AutocompleteBasic();
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Autocomplete<String>(
      optionsBuilder: (TextEditingValue textEditingValue) {
        if (textEditingValue.text == '') {
          return const Iterable<String>.empty();
        }
        return _names.where((String option) {
          return option.contains(textEditingValue.text.toLowerCase());
        });
      },
      onSelected: (String selection) {
        print('選択されたよ $selection');
      },
    );
  }

  static const List<String> _names = [
    'John Doe',
    'Jane Smith',
    'Alex Johnson',
    'Emily Davis',
    'Michael Wilson',
    'Olivia Brown',
    'William Miller',
    'Sophia Anderson',
    'James Thompson',
    'Emma Davis',
  ];
}
```

https://api.flutter.dev/flutter/material/Autocomplete-class.html

Stringのリストを渡すだけなのでとてもシンプルですね

## ② 候補欄にウィジェットを表示したいとき

Textだけじゃなくてアイコンも表示したい！
StringだけじゃなくてModelごと渡したい！
という欲求があるときは自身でカスタマイズする必要があります。

今回は下記のようなカスタム検索ビューをつくってみました
![Videotogif (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670405/902ef551-0bc6-97ce-1b46-bf51a3f24f97.gif)


### 実装コード

```custom.dart
class AutocompleteCustomApp extends StatelessWidget {
  const AutocompleteCustomApp({super.key});

  @override
  Widget build(BuildContext context) {
    return AutocompleteCustom();
  }
}

class AutocompleteCustom extends StatelessWidget {
  AutocompleteCustom({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        return Autocomplete<Person>(
          // 入力候補として表示したいリストを指定
          optionsBuilder: (TextEditingValue textEditingValue) {
            if (textEditingValue.text == '') {
              // 空文字の場合は入力候補を表示しない
              return const Iterable<Person>.empty();
            }
            // 実際の運用ではここでAPI通信をしてデータを取得することになりそう
            return _persons.where((Person person) {
              // ここの条件を変更することで、入力候補の絞り込みを行うことができる
              return person.name.contains(textEditingValue.text.toLowerCase());
            });
          },
          // 入力候補から選択したときにフィールドに表示される文字列を指定
          displayStringForOption: (person) {
            return person.name;
          },
          onSelected: (Person selection) {
            print(selection.name);
            // optionsViewBuilder内でもonTapを指定しているので、タップ時に実行したい処理はどちらかで実装すればよい
          },
          // 入力候補を表示するときのビューを指定
          optionsViewBuilder: (
            BuildContext context,
            AutocompleteOnSelected<Person> onSelected,
            Iterable<Person> persons,
          ) {
            return Align(
              alignment: Alignment.topLeft,
              child: Material(
                color: Colors.blueGrey.withOpacity(0.3),
                child: ConstrainedBox(
                  constraints: BoxConstraints(
                    maxWidth: constraints.maxWidth,  // 親の幅に合わせるためconstraintsを使う
                    maxHeight: personItemHeight * 4,  // １度に表示される入力候補は4つに留める
                  ),
                  child: ListView.builder(
                    padding: EdgeInsets.zero,
                    itemCount: persons.length,
                    itemBuilder: (context, index) {
                      final person = persons.elementAt(index);
                      // 入力候補のビューをカスタマイズするときは、タップ時の処理も自分で実装する必要がある
                      return InkWell(
                        onTap: () {
                          // onSelectedを呼び出すことで、選択した入力候補が入力フォームに表示される
                          onSelected(person);
                        },
                        child: SizedBox(
                          height: personItemHeight,
                          child: Row(
                            children: [
                              Icon(
                                person.gender == Gender.male
                                    ? Icons.man
                                    : Icons.woman,
                              ),
                              const SizedBox(width: 4),
                              Text(person.name),
                            ],
                          ),
                        ),
                      );
                    },
                  ),
                ),
              ),
            );
          },
          // 入力フォームのビューを指定
          fieldViewBuilder: (
            BuildContext context,
            TextEditingController textEditingController,
            FocusNode focusNode,
            VoidCallback onFieldSubmitted,
          ) {
            return TextFormField(
              controller: textEditingController,
              decoration: const InputDecoration(
                hintText: '名前を入力してください',
                enabledBorder: OutlineInputBorder(),
                focusedBorder: OutlineInputBorder(),
                contentPadding: EdgeInsets.symmetric(
                  horizontal: 16,
                ),
              ),
              focusNode: focusNode,
              keyboardType: TextInputType.name,
              maxLines: 1,
              onFieldSubmitted: (String value) {
                onFieldSubmitted();
              },
            );
          },
        );
      },
    );
  }

  static const personItemHeight = 40.0;

  final List<Person> _persons = [
    Person(id: '1', name: 'John Doe', gender: Gender.male),
    Person(id: '2', name: 'Jane Smith', gender: Gender.female),
    Person(id: '3', name: 'Alex Johnson', gender: Gender.male),
    Person(id: '4', name: 'Emily Davis', gender: Gender.female),
    Person(id: '5', name: 'Michael Wilson', gender: Gender.male),
    Person(id: '6', name: 'Olivia Brown', gender: Gender.female),
    Person(id: '7', name: 'William Miller', gender: Gender.male),
    Person(id: '8', name: 'Sophia Anderson', gender: Gender.female),
    Person(id: '9', name: 'James Thompson', gender: Gender.male),
    Person(id: '10', name: 'Emma Davis', gender: Gender.female),
  ];
}

class Person {
  String id;
  String name;
  Gender gender;

  Person({required this.id, required this.name, required this.gender});
}

enum Gender {
  male,
  female,
}
```


### コード解説
`Autocomplete`の抑えるべき５つのプロパティを解説します。

#### `AutocompleteOptionsBuilder<T>` optionsBuilder

* 入力候補として表示したいリストを指定するために使用します。
* 入力候補を表示したくないときは、空のリストを返すようにします。

```dart
  optionsBuilder: (TextEditingValue textEditingValue) {
    if (textEditingValue.text == '') {
      // 空文字の場合は入力候補を表示しない
      return const Iterable<Person>.empty();
    }

    // 実際の運用ではここでAPI通信をしてデータを取得することになりそう
    return _persons.where((Person person) {
      // ここの条件を変更することで、入力候補の絞り込みを行うことができる
      return person.name.contains(textEditingValue.text.toLowerCase());
    });
  },
```

#### `AutocompleteOptionToString<T>` displayStringForOption
* 入力候補から選択された場合に、フィールドに表示される文字列を指定します。
* もし、このプロパティを指定しなかった場合は、デフォルトでオブジェクトのtoString()メソッドが適用されます。
    指定しなかった場合、今回の例では `Instance of Person`と表示されてしまいます。
```dart
  displayStringForOption: (person) {
    return person.name;
  },
```

#### `AutocompleteOnSelected<T>?` onSelected
* 表示されている候補が選択されたときに発火させたい処理を指定します。
* 候補が選択されると、選択されたオブジェクトが引数として渡されます。

```dart
  onSelected: (Person selection) {
    print(selection.name);
    // タップ時に実行したい処理
  },
```

#### `AutocompleteOptionsBuilder<T>` optionsBuilder
* 入力候補を表示するときのビューをカスタマイズするために使用します。
  ※ カスタマイズするときは、タップ時の処理も自分で実装しないとタップ検知してくれないので注意が必要です
* デフォルトでは、Basicな候補ビューが表示されます。
```dart
  optionsViewBuilder: (
    BuildContext context,
    AutocompleteOnSelected<Person> onSelected,
    Iterable<Person> persons,
  ) {
    return Align(
      alignment: Alignment.topLeft,
      child: Material(
        color: Colors.blueGrey.withOpacity(0.3),
        child: ConstrainedBox(
          constraints: BoxConstraints(
            maxWidth: constraints.maxWidth,  // 親の幅に合わせるためconstraintsを使う
            maxHeight: personItemHeight * 4, // １度に表示される入力候補は4つに留める
          ),
          child: ListView.builder(
            padding: EdgeInsets.zero,
            itemCount: persons.length,
            itemBuilder: (context, index) {
              final person = persons.elementAt(index);
              // 入力候補のビューをカスタマイズするときは、タップ時の処理も自分で実装する必要がある
              return InkWell(
                onTap: () {
                  // onSelectedを呼び出すことで、選択した入力候補が入力フォームに表示される
                  onSelected(person);
                },
                child: SizedBox(
                  height: personItemHeight,
                  child: Row(
                    children: [
                      Icon(
                        person.gender == Gender.male
                            ? Icons.man
                            : Icons.woman,
                      ),
                      const SizedBox(width: 4),
                      Text(person.name),
                    ],
                  ),
                ),
              );
            },
          ),
        ),
      ),
    );
  },
```

#### `AutocompleteFieldViewBuilder` fieldViewBuilder
* 入力フォームのビューをカスタマイズするために使用します。
* デフォルトでは、Basicな入力フォームが表示されます。
 ```dart
    fieldViewBuilder: (
        BuildContext context,
        TextEditingController textEditingController,
        FocusNode focusNode,
        VoidCallback onFieldSubmitted,
      ) {
        return TextFormField(
          controller: textEditingController,
          decoration: const InputDecoration(
            hintText: '名前を入力してください',
            enabledBorder: OutlineInputBorder(),
            focusedBorder: OutlineInputBorder(),
            contentPadding: EdgeInsets.symmetric(
              horizontal: 16,
            ),
          ),
          focusNode: focusNode,
          keyboardType: TextInputType.name,
          maxLines: 1,
          onFieldSubmitted: (String value) {
            onFieldSubmitted();
          },
        );
      },
```


## 参考URL
https://qiita.com/KAZMA_WED/items/1485a25580788954c43f#3-%E5%83%95%E3%81%8C%E3%81%9F%E3%81%A9%E3%82%8A%E7%9D%80%E3%81%84%E3%81%9F%E6%AD%A3%E8%A7%A3%E3%81%AE%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB