【Flutter】検索機能の実装方法

##  初めに
この記事ではコンテンツを検索する際の簡単な検索機能を実装します。
検索機能はSNSなどをはじめ、様々なアプリに実装されており、自身で実装する機会もあるかと思います。ここでその実装方法を学習しましょう！

## 記事の対象者
+ アプリに検索機能を実装したい方
+ TextField の活用方法を学びたい方
+ Riverpod を用いた動的な機能の実装方法を学びたい方

## 完成イメージ
![search_example.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/fcf8b9c3-a31e-d037-c103-a5d258f2d7e0.gif)
上のように、検索している時としていない時で AppBar のアイコンや ListView の表示する項目を変更しています。
また、 ListView のタイルを押すと次のページへ遷移するようにしています。

## 全体コード
全体のコードは以下の通りです。

```dart: search_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:widget_example/widget_tools/search/search_detail_page.dart';

final List<String> wordList = [
  "Hello",
  "Good morning",
  "Good afternoon",
  "Good evening",
  "Good night",
  "Good bye",
  "Bye",
  "See you later",
];

final onSearchProvider = StateProvider((ref) => false);
final StateProvider<List<int>> searchIndexListProvider =
    StateProvider((ref) => []);

class SearchPage extends ConsumerWidget {
  const SearchPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final onSearchNotifier = ref.watch(onSearchProvider.notifier);
    final onSearch = ref.watch(onSearchProvider);
    final searchIndexListNotifier = ref.watch(searchIndexListProvider.notifier);

    return Scaffold(
      appBar: AppBar(
          title: onSearch
              ? _searchTextField(ref)
              : const Text("Search"),
          actions: onSearch
              ? [
                  IconButton(
                      onPressed: () {
                        onSearchNotifier.state = false;
                      },
                      icon: const Icon(Icons.clear)),
                ]
              : [
                  IconButton(
                      onPressed: () {
                        onSearchNotifier.state = true;
                        searchIndexListNotifier.state = [];
                      },
                      icon: const Icon(Icons.search)),
                ]),
      body: onSearch ? _searchListView(ref) : _defaultListView(),
    );
  }

  Widget _searchTextField(WidgetRef ref) {
    final searchIndexListNotifier = ref.watch(searchIndexListProvider.notifier);
    final List<int> searchIndexList = ref.watch(searchIndexListProvider);
    return TextField(
      autofocus: true,
      onChanged: (String text) {
        searchIndexListNotifier.state = [];
        for (int i = 0; i < wordList.length; i++) {
          if (wordList[i].contains(text)) {
            searchIndexListNotifier.state.add(i); // 今回の問題はここ！！！
          }
        }
      },
    );
  }

  Widget _searchListView(WidgetRef ref) {
    final searchIndexListNotifier = ref.watch(searchIndexListProvider.notifier);
    final searchIndexList = ref.watch(searchIndexListProvider);
    return ListView.builder(
        itemCount: searchIndexList.length,
        itemBuilder: (context, int index) {
          index = searchIndexListNotifier.state[index];
          return Card(
            child: ListTile(
              onTap: () {
                Navigator.push(
                    context,
                    MaterialPageRoute(
                        builder: (context) =>
                            SearchDetailPage(title: wordList[index])));
              },
              title: Text(wordList[index]),
            ),
          );
        });
  }

  Widget _defaultListView() {
    return ListView.builder(
      itemCount: wordList.length,
      itemBuilder: (context, index) {
        return Card(
          child: ListTile(
            onTap: () {
              Navigator.push(
                  context,
                  MaterialPageRoute(
                      builder: (context) =>
                          SearchDetailPage(title: wordList[index])));
            },
            title: Text(wordList[index]),
          ),
        );
      },
    );
  }
}
```

```dart: search_detail_page.dart
import 'package:flutter/material.dart';

class SearchDetailPage extends StatelessWidget {
  const SearchDetailPage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.waving_hand,color: Colors.amber,size: 50,),
            Text(title, style: const TextStyle(fontSize: 20),),
          ],
        ),
      ),
    );
  }
}
```

## search_page
それぞれのページについて解説していきます。

### 変数の定義

```dart: search_page.dart
final List<String> wordList = [
  "Hello",
  "Good morning",
  "Good afternoon",
  "Good evening",
  "Good night",
  "Good bye",
  "Bye",
  "See you later",
];

final onSearchProvider = StateProvider((ref) => false);
final StateProvider<List<int>> searchIndexListProvider =
    StateProvider((ref) => []);
```
このコードでは、まずリストとして表示させる要素のタイトルとなるテキストをリストに入れ、 `wordList` という変数に代入しています。 
本来であれば、Firebase からデータを取得して、そのデータのリストを作成したりするのですが、今回は Firebase 連携は行わないため、直接データを定義しています。

`onSearchProvider` は検索機能がオンになっているか、オフになっているかを監視するための StateProvider です。詳しくは後述しますが、この値が切り替わることで、 AppBar のアイコンが変更されたり、表示される ListView が変更されたりします。
初期状態では検索機能はオフになっているため、 false になります。

`searchIndexListProvider` は検索されている項目のインデックスを監視するための StateProvider です。この値が切り替わることで検索されている項目のみに絞って表示させられるようになります。
初期状態では検索されている項目はないため、空のリストになります。

### StateProvider の読み取り
```dart: search_page.dart
final onSearchNotifier = ref.watch(onSearchProvider.notifier);
final onSearch = ref.watch(onSearchProvider);
final searchIndexListNotifier = ref.watch(searchIndexListProvider.notifier);
```
このコードでは先ほど作成した `onSearchProvider` `searchIndexListProvider` を ConsumerWidget 内で使用するために、それぞれの StateProvider の読み取った値を変数に代入しています。

### appBar
```dart: search_page.dart
appBar: AppBar(
  title: onSearch
    ? _searchTextField(ref)
    : const Text("Search"),
```
このコードでは、 AppBar の title を `onSearch` の値によって切り替えています。
`onSearch` は boolean 型の変数であり、上のコードでは true の時には _searchTextField() を表示させ、 false の時にはテキストを表示させています。
※ 上のコードで `onSearch` は条件分岐に使用されており、値は変更されないため、 `searchIndexListNotifier` ではなく、 `onSearch` を使用しています。

### actions
```dart: search_page.dart
actions: onSearch
  ? [
    IconButton(
      onPressed: () {
        onSearchNotifier.state = false;
      },
      icon: const Icon(Icons.clear)),
     ]
  : [
    IconButton(
      onPressed: () {
        onSearchNotifier.state = true;
        searchIndexListNotifier.state = [];
      },
      icon: const Icon(Icons.search)),
]),
```
このコードでは、先程の AppBar 同様に、`onSearch` の値によって表示内容を変更しています。

`onSearch` が true の時は、バツのボタンを表示させ、ボタンが押された時には `onSearchNotifier.state` の値を false に切り替えています。

`onSearch` が false の時は、虫眼鏡のボタンを表示させ、ボタンが押された時には `onSearchNotifier.state` の値を true に切り替えています。

`onSearch` が切り替わることで以下のような見た目になります。
![toggle_button.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/df30c28c-5f30-6ffc-b467-fc3fecf24018.gif)

### body
```dart: search_page.dart
body: onSearch ? _searchListView(ref) : _defaultListView(),
```
このコードでは `onSearch` の値によって body の表示内容を変更しています。
`_searchListView()` `_defaultListView()` については後述します。

### _searchTextField( )
```dart: search_page.dart
Widget _searchTextField(WidgetRef ref) {
    final searchIndexListNotifier = ref.watch(searchIndexListProvider.notifier);
    final List<int> searchIndexList = ref.watch(searchIndexListProvider);
    return TextField(
      autofocus: true,
      onChanged: (String text) {
        searchIndexListNotifier.state = [];
        for (int i = 0; i < wordList.length; i++) {
          if (wordList[i].contains(text)) {
            searchIndexListNotifier.state.add(i); // 今回の問題はここ！！！
          }
        }
      },
    );
  }
```
このコードでは AppBar に表示される TextField を作成しています。
それぞれ細かくみていきましょう。
<br>

```dart: search_page.dart
final searchIndexListNotifier = ref.watch(searchIndexListProvider.notifier);
final List<int> searchIndexList = ref.watch(searchIndexListProvider);
```
このコードで `searchIndexListProvider` の読み取った値を Widget 内で使用できるように変数を定義しています。
<br>

```dart: search_page.dart
onChanged: (String text) {
  searchIndexListNotifier.state = [];
  for (int i = 0; i < wordList.length; i++) {
    if (wordList[i].contains(text)) {
      searchIndexListNotifier.state.add(i);
    }
  }
},
```
このコードで、 TextField の入力の値が変更された際の処理を記述しています。

まずは `searchIndexListNotifier.state` の値を空のリストにすることで、何も検索されていない初期の状態にします。

次に、`contains()` メソッド を使って、ユーザが入力した文字が `wordList` に含まれている場合のみ、 `searchIndexListNotifier.state` にインデックスを加えます。

そしてそれを for 文で `wordList` の要素の数だけ繰り返します。

※ `contains()` メソッド は以下のようなとき、A に B が含まれる場合は true、含まれない場合は false を返すメソッドです。
```
A.contains( B )
```
<br>

条件分岐を図にすると以下のようなイメージです。
![スクリーンショット 2022-09-06 19.10.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/d55349ab-273f-1fac-298b-2ef8409adfaa.png)
for 文を通して変更された `searchIndexListNotifier.state` は、「ユーザが入力した文字が含まれている要素のみのインデックのリスト」になります。

### _searchListView( )
```dart: search_page.dart
Widget _searchListView(WidgetRef ref) {
    final searchIndexListNotifier = ref.watch(searchIndexListProvider.notifier);
    final searchIndexList = ref.watch(searchIndexListProvider);
    return ListView.builder(
        itemCount: searchIndexList.length,
        itemBuilder: (context, int index) {
          index = searchIndexListNotifier.state[index];
          return Card(
            child: ListTile(
              onTap: () {
                Navigator.push(
                    context,
                    MaterialPageRoute(
                        builder: (context) =>
                            SearchDetailPage(title: wordList[index])));
              },
              title: Text(wordList[index]),
            ),
          );
        });
  }
```
このコードでは `onSearch` が true の時、つまり、ユーザが検索しているときに body に表示させる ListView を作成しています。
細かくみていきましょう。
<br>

```dart: search_page.dart
final searchIndexListNotifier = ref.watch(searchIndexListProvider.notifier);
final searchIndexList = ref.watch(searchIndexListProvider);
```
このコードでは自身で作成する `_searchListView( )` メソッド内で `searchIndexListProvider` の読み取った値を使うために、変数に代入しています。

`searchIndexListNotifier`  には notifier を代入し、値の変更が必要な際に使用します。
`searchIndexList` には int のリストが代入されており、値の変更が必要ない時に使用します。
<br>

```dart: search_page.dart
itemCount: searchIndexList.length,
itemBuilder: (context, int index) {
  index = searchIndexList[index];
```
`itemCount` を `searchIndexList.length` にすることで、 ListView で表示されるアイテムの数が`searchIndexList` のリストの要素の数に指定されます。

`index` に `searchIndexList` の int を代入することで、`_searchTextField()` の `onChanged` で追加したインデックスのリストが `index` に代入されます。

つまり、先述したとおり、ユーザが入力した文字を含む単語のみのインデックスのリストが `index` に代入されるのです。
<br>

```dart: search_page.dart
onTap: () {
  Navigator.push(
    context,
    MaterialPageRoute(
      builder: (context) =>
        SearchDetailPage(title: wordList[index])));
},
title: Text(wordList[index]),
```
このコードでは、これから作成する、`SearchDetailPage` に遷移するとともに、それぞれの `wordList` の単語を引数として渡しています。
例えば、「Hello」 という単語の Card がタップされた時には 「Hello」 が `SearchDetailPage` に渡されます。

`title` にテキストとして、それぞれ `wordList` の単語を指定することで、Card のタイトルが、それぞれの単語になります。
こうすることで、 `wordList` の全ての単語の Card が作成されます。

### _defaultListView( )
```dart: search_page.dart
Widget _defaultListView() {
    return ListView.builder(
      itemCount: wordList.length,
      itemBuilder: (context, index) {
        return Card(
          child: ListTile(
            onTap: () {
              Navigator.push(
                  context,
                  MaterialPageRoute(
                      builder: (context) =>
                          SearchDetailPage(title: wordList[index])));
            },
            title: Text(wordList[index]),
          ),
        );
      },
    );
  }
```
このコードでは、ユーザが検索していない時に表示される ListView を作成しています。
`_searchListView()` との違いは、`itemCount` が `wordList.length` に指定されている点です。
`wordList` の要素の数にすることで、検索していない状態では、リストにある全ての単語を表示させることができます。

## search_detail_page
次は search_detail_page です。

```dart:search_detail_page.dart
  const SearchDetailPage({Key? key, required this.title}) : super(key: key);

  final String title;
```
このコードで、先ほど `SearchPage` から受け取った `title` を引数として受け取っています。
こうすることで、このページでも `title` が使用できるようになります。
<br>

```dart:search_detail_page.dart
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    const Icon(Icons.waving_hand,color: Colors.amber,size: 50,),
    Text(title, style: const TextStyle(fontSize: 20),),
  ],
),
```
このコードでは、 `Column` を使って `Icon` `Text` を縦に並べています。
`Text` の内容は、前のページから受け取った `title` の単語になります。

これで初めに提示した完成イメージと同じ表示になったと思います。

以上です。

## あとがき
最後まで読んでいただきありがとうございました。
機会があれば、「ユーザが文字を入力して数秒後に絞り込みをする」など、別の方法も試してみたいと思います。

参考にしていただければ幸いです。
誤っている箇所があればご指摘いただければ幸いです。

### 参考にしたサイト
https://enoiu.com/app-develop/flutter-search-function/