69
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Systemi(株式会社システムアイ)Advent Calendar 2023

Day 7

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

Last updated at Posted at 2023-12-06

はじめに

本稿は、株式会社システムアイのAdvent Calendar 2023 の 7日目の記事となります。

概要

Material Design Blog にてマテリアルデザイン3(以降、M3)の検索バーが新しくリリースされたことを知り、お試しで Google風の検索バーを実装してみたら良い感じでした、というお話です。
M3の検索バーについても簡単に紹介しています。

なぜ検索バーを実装したのか

M3 には、様々な Components のデザインが定義されています。

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

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

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

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

検索バーの種類

以下の2つのクラスが提供されています。

  • SearchBar
  • DockedSearchBar

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

m3_search_bar.png

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

m3_docked_search_bar.png

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

定義

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

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

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

検索バーを実装したアプリを作りました

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

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

開発環境

  • Android Studio Giraffe | 2022.3.1 Patch 2

検索動作の仕様

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

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

依存関係

M3の Componentsを使うには、以下の依存関係を build.gradleに追記する必要があります。

build.gradle
implementation "androidx.compose.material3:material3:$material3_version"

その他のライブラリ Kotlin × Compose × Material3

AndroidStudioでプロジェクト作成後、M3やViewmodel、Lifecycle関連を少し足しています。

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

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 となる状態を保持しますが、今回は主な利用用途はありません。例えば、選択時に詳細画面へ遷移するようにしたい場合に利用できるかなと思います。
searchHistorypokemonListは、アクティブな検索バーの下部に表示するリスト情報です。pokemonListは、非アクティブな検索バーの下に表示するLIST情報になります。

UI Event

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()
}

検索バーのイベントは以下の4つにしました。BackCancel は「←」アイコンと「×」アイコンのイベントですが、挙動が分かれるのでイベントも分けました。

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

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

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関数を渡しています。

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で宣言しておけば大丈夫です。

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 をかなり参考にしました。

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

おわりに

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

参考

69
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
69
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?