【Android】Jetpack Compose で Google風 検索バーを実装してみる

# はじめに
本稿は、[株式会社システムアイのAdvent Calendar 2023](https://qiita.com/advent-calendar/2023/systemi23) の 7日目の記事となります。

# 概要
[Material Design Blog](https://material.io/blog/material-3-compose-1-1) にてマテリアルデザイン３（以降、M3）の検索バーが新しくリリースされたことを知り、お試しで Google風の検索バーを実装してみたら良い感じでした、というお話です。
M3の検索バーについても簡単に紹介しています。

https://material.io/blog/material-3-compose-1-1

# なぜ検索バーを実装したのか
M3 には、様々な Components のデザインが定義されています。

https://m3.material.io/components

Android Jetpack Compose（以降、Compose）では、M3に対応した Components の実装が提供されており、少ない実装で M3で設計された外観、インタラクションをもつUIが構築できるように ↓ にて様々な部品が提供されています。

https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary

この Compose における M3の実装は、今年 バージョン 1.1がリリースされています。
バージョン 1.1 のいくつかの更新の中でも特に「検索バー」に目を引かれました。
今までありそうでなかった部品です。

検索バーを自前でゼロから実装しようとすると、フォーカスの制御や入力イベントの実装、UIへの結果の反映など、完成度の高いものを作成するにはそれなりにコードを書かないといけないとはず。
更に検索の利便性を考えたら、過去の検索履歴からの選択など、Google検索のようなインタラクションも追加したくなります。

そこで、この検索バーです。
M3のデザインが適用されたリッチな UIの検索バーが 低コストで実装できるかどうか、実際に試してみたいと思いました。


# 検索バーの種類
以下の２つのクラスが提供されています。
- SearchBar
- DockedSearchBar

SearchBarは、入力したキーワードや語句に関連した内容を表示するフローティングフィールドです。
アクティブになると Viewが画面全体に展開される為、動的な候補を表示する為に利用できます。

![m3_search_bar.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3384055/42eb05ea-86bd-ddac-cfc7-4fd1227f7db1.png)

DockedSearchBarは、全体ではなく、入力フィールドの下に動的な候補が展開されます。
Tabletなど大きな画面など、全画面サイズになるのが好ましくない場合に SearchBarの代替となる目的で用意されています。

![m3_docked_search_bar.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3384055/5e9aa987-0e2b-e21f-a6b3-6685a3fcca2c.png)


この２つのクラスは、互いに入替えが可能なように定義やパラメータは同じになるように設計されているようです。
TextFieldクラスなどの入力フィールドと同様に UIの任意の場所に配置して使います。


## 定義
パラメータの **active** に true を指定すると、検索バーが展開されます。 展開された部分に表示する内容は **content** に Composable を配置します。

```kotlin:SearchBar.kt
@ExperimentalMaterial3Api
@Composable
fun SearchBar(
    query: String,
    onQueryChange: (String) -> Unit,
    onSearch: (String) -> Unit,
    active: Boolean,
    onActiveChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    placeholder: @Composable (() -> Unit)? = null,
    leadingIcon: @Composable (() -> Unit)? = null,
    trailingIcon: @Composable (() -> Unit)? = null,
    shape: Shape = SearchBarDefaults.inputFieldShape,
    colors: SearchBarColors = SearchBarDefaults.colors(),
    tonalElevation: Dp = SearchBarDefaults.Elevation,
    windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    content: @Composable ColumnScope.() -> Unit,
)
```

### parameters
| パラメータ名 | 説明 |
|:-:|:-|
| query | 検索バーの入力フィールドに表示されるクエリテキスト |
| onQueryChange | 入力サービスがクエリを更新するときに呼び出されるコールバック。 更新されたテキストはコールバックのパラメータとして提供されます。 |
| onSearch | 入力サービスが ImeAction.Search アクションをトリガーするときに呼び出されるコールバック。現在のクエリはコールバックのパラメータとして提供されます。 |
| active | この検索バーがアクティブかどうか |
| onActiveChange | この検索バーのアクティブ状態が変更されたときに呼び出されるコールバック |
| modifier | この検索バーに適用される修飾子 |
| enabled | この検索バーの有効状態を制御します。<br>false の場合、このコンポーネントはユーザー入力に応答せず、視覚的に無効になっているように見え、アクセシビリティサービスに対して無効になっています。 |
| placeholder | 検索バーのクエリが空の場合に表示されるプレースホルダー |
| leadingIcon | 検索バーコンテナの先頭に表示される先頭のアイコン |
| trailingIcon | 検索バーコンテナの最後に表示される末尾のアイコン |
| shap | この検索バーがアクティブでないときの形状。 <br>アクティブな場合、形状は常に SearchBarDefaults.fullScreenShape になります。 |
| Colors | さまざまな状態でこの検索バーに使用される色を解決するために使用される SearchBarColors。 SearchBarDefaults.colors を参照してください。 |
| tonalElevation | SearchBarColors.containerColor が ColorScheme.surface の場合、半透明の原色のオーバーレイがコンテナーの上部に適用されます。 トーンエレベーション値を高くすると、明るいテーマではより暗い色になり、暗いテーマではより明るい色になります。 Surface も参照してください。 |
| windowInsets | 検索バーが考慮するウィンドウインセット |
| interactionSource | この検索バーのインタラクションのストリームを表す MutableInteractionSource。 独自の記憶されたインスタンスを作成して渡して、インタラクションを観察し、さまざまな状態でのこの検索バーの外観/動作をカスタマイズできます。 |
| content | 入力フィールドの下に表示されるこの検索バーのコンテンツ

上述したように SearchBar と DockedSearchBar の定義はクラス名以外は同じですので、DockedSearchBar については省略します。


# 検索バーを実装したアプリを作りました
ここからは作成した Google風 検索バーの実装について説明します。
ソースコードは ↓ あります。

https://github.com/iwata-at-develop/M3-searchbar-sample

実装にあたっては検索バーが使われているサンプルアプリ [compose-samples/Replay](https://github.com/android/compose-samples/tree/main/Reply) を参考にしました。Android Developersや Blogでもリンクされているサンプルなので、いろいろと勉強にもなります。

## 開発環境
- Android Studio Giraffe | 2022.3.1 Patch 2


## 検索動作の仕様
作成したアプリの検索バーの仕様については、以下のように規定しています。
データは私の好きなポケモン達を使わせて頂きました。

- 検索バーが非アクティブの場合、検索バーの下に検索結果の一覧を表示します。（初期状態は全件を表示）
- 検索バーがアクティブの場合、検索バーが展開されて、以下を表示します。
  - 検索条件を未入力時、検索履歴
  - 検索条件を入力時、条件で絞り込んだ検索結果
- 検索履歴と検索結果は、それぞれ Googleと同様にアイコン（矢印の時計、虫眼鏡）が切替わります。
- 検索バーがアクティブの場合、バーの左端に「←」アイコンを表示。検索バーを非アクティブにします。
- 検索条件入力時、バーの右端に「×」アイコンを表示します。クリックすると条件をクリアし、検索バーを非アクティブにします。


| 初期状態 | 条件入力（空欄） | 条件入力 | 検索結果 |
|:-:|:-:|:-:|:-:|
| active=**false** | active=**true** | active=**true** | active=**false** |
| <img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3384055/35b1b5f6-81e3-3cdd-36bd-982135b48c91.png">  | <img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3384055/423c5dc4-4cee-aab2-e084-68d1b8cb196e.png"> | <img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3384055/0a361590-a228-6eaa-e665-d41681b8cee7.png"> |  <img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3384055/f8b5d481-ad9b-a04c-76f2-c29143f24a3c.png"> |




## 依存関係 
### 
M3の Componentsを使うには、以下の依存関係を build.gradleに追記する必要があります。
```kotlin:build.gradle
implementation "androidx.compose.material3:material3:$material3_version"
```

### その他のライブラリ Kotlin × Compose × Material3
AndroidStudioでプロジェクト作成後、M3やViewmodel、Lifecycle関連を少し足しています。
```kotlin:build.gradle
dependencies {

    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
    implementation("androidx.activity:activity-compose:1.8.1")
    implementation(platform("androidx.compose:compose-bom:2023.10.01"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.material:material-icons-extended")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0-rc01")
    implementation("io.coil-kt:coil-compose:2.2.2")

```

### ViewModel
まず、ViewModel のUI Stateと検索バーのイベントの実装です。

#### UI State

```kotlin:SearchBarViewModel.kt
class SearchBarViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(UiState())
    val uiState = _uiState.asStateFlow()

}

data class UiState(
    val query: String = "",
    val isQuerying: Boolean = false,
    val selected: Pokemon? = null,
    val searchHistory: List<Pokemon> = _history,
    val pokemonList: List<Pokemon> = _pokemonList,
)
```
UI Stateは上記を保持するようにしました。
**isQuerying** は、条件入力で true にします。 アイコンの表示制御に利用します。
**selected** は、検索履歴や検索結果を選択したときに true となる状態を保持しますが、今回は主な利用用途はありません。例えば、選択時に詳細画面へ遷移するようにしたい場合に利用できるかなと思います。
**searchHistory**と**pokemonList**は、アクティブな検索バーの下部に表示するリスト情報です。**pokemonList**は、非アクティブな検索バーの下に表示するLIST情報になります。

#### UI Event

```kotlin:SearchBarViewModel.kt
sealed class SearchBarEvent {
    data class QueryChange(val query: String) : SearchBarEvent()
    data class Select(val pokemon: Pokemon) : SearchBarEvent()
    object Back : SearchBarEvent()
    object Cancel : SearchBarEvent()
}
```
検索バーのイベントは以下の４つにしました。**Back**と **Cancel** は「←」アイコンと「×」アイコンのイベントですが、挙動が分かれるのでイベントも分けました。

| Event | 概要 |
|:-:|-|
| QueryChange | 検索条件を更新するたびに検索処理を行い、**pokemonList**を更新します |
| Select | 検索結果を選択して、１件に絞り込み、検索履歴に積みます。 |
| Back | 検索バーを非アクティブにします。 |
| Cancel | 検索条件をクリアして、検索バーを非アクティブにします。 |

onEvent関数に UI Eventごとに必要な処理を実装します。
**SearchBarEvent.QueryChange** イベントはきちんと実装するなら、非同期処理にしたり、エラー処理を入れたりする必要がありますが、あくまで今回は検索バーのお試しなので、サンプルデータから名前でfilterするだけの簡単な実装になっています。あしからず。

```kotlin:SearchBarViewModel.kt
class SearchBarViewModel : ViewModel() {

    fun onEvent(event: SearchBarEvent) {
        when (event) {
            is SearchBarEvent.QueryChange -> {
                var isQuerying = false
                val pokemonList = mutableListOf<Pokemon>()
                viewModelScope.launch {
                    if (event.query.isNotEmpty()) {
                        isQuerying = true
                        pokemonList.addAll(
                            _pokemonList.filter {
                                it.name.startsWith(
                                    prefix = event.query,
                                    ignoreCase = true
                                ) || it.nameOfKana.startsWith(
                                    prefix = event.query,
                                    ignoreCase = true
                                )
                            }
                        )
                    } else {
                        pokemonList.addAll(_pokemonList)
                    }

                    _uiState.value =
                        _uiState.value.copy(
                            query = event.query,
                            isQuerying = isQuerying,
                            pokemonList = pokemonList
                        )
                }
            }

            is SearchBarEvent.Select -> {
                event.pokemon.let {
                    _history.remove(it)
                    _history.add(index = 0, it)
                }

                _uiState.value =
                    _uiState.value.copy(
                        query = event.pokemon.name,
                        isQuerying = true,
                        selected = event.pokemon,
                        searchHistory = _history,
                        pokemonList = listOf(event.pokemon)
                    )
            }

            is SearchBarEvent.Back -> {
                _uiState.value =
                    _uiState.value.copy(
                        selected = null
                    )
            }

            is SearchBarEvent.Cancel -> {
                _uiState.value =
                    _uiState.value.copy(
                        query = "",
                        isQuerying = false,
                        selected = null,
                        searchHistory = _history,
                        pokemonList = _pokemonList
                    )
            }
        }
    }
}
```

### Composable
次に、画面に Composable を配置していきます。
検索バーとなる SearchBarContent() には、引数で UI Stateと onEvent関数を渡しています。

```kotlin:SearchBarScreen.kt
@Composable
fun SearchBarScreen(modifier: Modifier = Modifier) {

    val viewModel = viewModel<SearchBarViewModel>()
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    val listLazyListState = rememberLazyListState()

    Box(modifier = modifier.fillMaxSize()) {
        Box(modifier = modifier.windowInsetsPadding(WindowInsets.statusBars)) {
            SearchBarContent(
                modifier = modifier
                    .fillMaxWidth()
                    .padding(top = 12.dp, start = 16.dp, end = 16.dp),
                uiState = uiState,
                onEvent = viewModel::onEvent
            )

            LazyColumn(
                modifier = modifier
                    .fillMaxWidth()
                    .padding(top = 80.dp),
                state = listLazyListState,
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                val pokemonList = uiState.pokemonList

                items(pokemonList) {
                    PokemonListItem(
                        pokemon = it,
                        modifier = Modifier
                            .fillMaxWidth()
                    )
                }
            }
        }
    }
}
```

SearchBarContent() の中で M3 の DockedSearchBar を使います。
そして、各イベントで UI Eventを指定して onEvent() を呼び出します。

初期状態は必ず非アクティブにするならば、この関数内で active状態を by rememberSaveableで宣言しておけば大丈夫です。


```kotlin:SearchBarScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBarContent(
    uiState: UiState,
    onEvent: (SearchBarEvent) -> Unit,
    modifier: Modifier = Modifier
) {

    var active by rememberSaveable { mutableStateOf(false) }

    val query = uiState.query
    val isQuerying = uiState.isQuerying
    val result: List<Pokemon> = if (isQuerying) {
        uiState.pokemonList
    } else {
        uiState.searchHistory
    }

    DockedSearchBar(
        modifier = modifier,
        query = query,
        onQueryChange = {
            onEvent(SearchBarEvent.QueryChange(it))
        },
        onSearch = { active = false },
        active = active,
        onActiveChange = {
            active = it
        },
        leadingIcon = {
            if (active) {
                Icon(
                    imageVector = Icons.Default.ArrowBack,
                    contentDescription = stringResource(id = R.string.back),
                    modifier = Modifier
                        .padding(start = 16.dp)
                        .clickable {
                            onEvent(SearchBarEvent.Back)
                            active = false
                        },
                )
            } else {
                Icon(
                    imageVector = Icons.Default.Search,
                    contentDescription = stringResource(id = R.string.search),
                    modifier = Modifier.padding(start = 16.dp),
                )
            }
        },
        trailingIcon = {
            if (isQuerying) {
                Icon(
                    imageVector = Icons.Default.Cancel,
                    contentDescription = stringResource(id = R.string.cancel),
                    modifier = Modifier
                        .padding(12.dp)
                        .size(32.dp)
                        .clickable {
                            onEvent(SearchBarEvent.Cancel)
                            active = false
                        },
                )
            }
        },
        placeholder = { Text(text = stringResource(id = R.string.search_pokemon_name)) },
    ) {
        // Search result shown when active
        if (result.isNotEmpty()) {
            LazyColumn(
                modifier = Modifier.fillMaxWidth(),
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(4.dp)
            ) {
                items(items = result) { pokemon ->
                    // Search result
                    ListItem(
                        headlineContent = { Text(text = pokemon.name) },
                        leadingContent = {
                            if(isQuerying) {
                                Icon(
                                    imageVector = Icons.Default.Search,
                                    contentDescription = stringResource(id = R.string.search),
                                )
                            } else {
                                Icon(
                                    imageVector = Icons.Default.History,
                                    contentDescription = stringResource(id = R.string.history),
                                )
                            }
                        },
                        modifier = Modifier.clickable {
                            onEvent(SearchBarEvent.Select(pokemon))
                            active = false
                        }
                    )
                }
            }
        } else {
            if (isQuerying) {
                Text(
                    text = stringResource(id = R.string.not_found),
                    modifier = Modifier.padding(16.dp)
                )
            } else {
                Text(
                    text = stringResource(id = R.string.no_search_history),
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}
```

この SearchBarContent()は、冒頭にも書いた [compose-samples/Replay](https://github.com/android/compose-samples/tree/main/Reply) をかなり参考にしました。

面倒なフォーカス制御などは DockedSearchBar() が自動的に行ってくれているため、自分で実装を行う必要がありません。
ViewModelのサンプルデータの実装は説明から割愛していますが、この点を除けば検索バーはこれだけの実装で動かせます。
コードも、めちゃシンプル！

# おわりに
SearchBar / DockedSearchBar なかなか良いですよね。
実装する機会があれば、積極的に採用したいと思いました。
M3 の Compose 1.1には、検索バー以外にも今回紹介しなかった Date Pickers などの便利な Componentsがリリースされていますので、そちらも試してみたいです。


# 参考
https://material.io/blog/material-3-compose-1-1

https://m3.material.io/components/search/overview

https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary

https://github.com/android/compose-samples/tree/main/Reply