tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 2116e8bd2a15b943a490abef7f55bd16d7dd8999
parent dd7a1f811f17626408d602104f168fc8de7a4539
Author: Cathy Lu <calu@mozilla.com>
Date:   Mon,  8 Dec 2025 22:27:27 +0000

Bug 1994276 - Implement reusable Search Bar component r=android-reviewers,007

Differential Revision: https://phabricator.services.mozilla.com/D274181

Diffstat:
Amobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/searchbar/SearchBar.kt | 447+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 447 insertions(+), 0 deletions(-)

diff --git a/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/searchbar/SearchBar.kt b/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/searchbar/SearchBar.kt @@ -0,0 +1,447 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.compose.base.searchbar + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.SearchBarScrollBehavior +import androidx.compose.material3.SearchBarState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSearchBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import mozilla.components.compose.base.R +import mozilla.components.compose.base.theme.AcornTheme +import androidx.compose.material3.SearchBar as M3SearchBar +import androidx.compose.material3.TopSearchBar as M3TopSearchBar +import mozilla.components.ui.icons.R as iconsR + +/** + * A wrapper around the Material3 [SearchBar] that exposes a query-based API and uses + * [SearchBarInputField] to handle input behavior. This version adds support for dynamic + * trailing-icon behavior, allowing icons to update based on the current query state. + * + * @param query the current text entered in the search field. + * @param onQueryChange called when the query text is changed by the user. + * @param onSearch called when the user submits the current [query] (e.g. IME action search). + * @param expanded whether this search bar is expanded and showing search results. + * @param onExpandedChange called when this search bar's expanded state changes. + * @param modifier the [Modifier] to be applied to this search bar. + * @param enabled whether the search bar is enabled. + * @param placeholder optional placeholder composable displayed in the input field when [query] is empty. + * @param leadingIcon optional leading icon displayed at the start of the input field. + * @param trailingIcon trailing icon displayed at the end of the input field when there is + * no text or when a clear icon is not being shown. + * @param shape the shape of this search bar when it is not expanded. When expanded, the shape will + * always be [SearchBarDefaults.fullScreenShape]. + * @param colors [SearchBarColors] used to resolve the colors for this search bar in different + * states. See [SearchBarDefaults.colors]. + * @param tonalElevation when [SearchBarColors.containerColor] is surface, a translucent + * primary color overlay is applied on top of the container. A higher tonal elevation + * value will result in a darker color in light theme and a lighter color in dark theme. + * @param shadowElevation the elevation for the shadow below this search bar. + * @param windowInsets the window insets that this search bar will respect. + * @param interactionSource the [MutableInteractionSource] representing the stream of interactions + * for this search bar. You can use this to observe focus, press, and other interactions. + * @param content the content of this search bar to display search results below the input field + * when [expanded] is true. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + expanded: Boolean, + onExpandedChange: (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.TonalElevation, + shadowElevation: Dp = SearchBarDefaults.ShadowElevation, + windowInsets: WindowInsets = SearchBarDefaults.windowInsets, + interactionSource: MutableInteractionSource? = null, + content: @Composable ColumnScope.() -> Unit, +) { + M3SearchBar( + inputField = { + SearchBarInputField( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + expanded = expanded, + onExpandedChange = onExpandedChange, + enabled = enabled, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + colors = colors, + interactionSource = interactionSource, + ) + }, + expanded = expanded, + onExpandedChange = onExpandedChange, + modifier = modifier, + shape = shape, + colors = colors, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + windowInsets = windowInsets, + content = content, + ) +} + +/** + * A Material3 [SearchBar] that places the search bar at the top of the layout and uses + * [SearchBarInputField] to handle input behavior. This adds support for dynamic + * trailing-icon behavior and automatically updates icons based on the current query state. + * + * @param state the state of the search bar. + * @param modifier optional [Modifier] for customizing the layout or behavior of the search bar. + * @param query the current query text displayed in the input field. + * @param onQueryChange invoked when the query text changes. + * @param onSearch invoked when the user submits the current query. + * @param expanded whether the search bar is currently expanded and showing search results. + * @param onExpandedChange invoked when the expanded state changes. + * @param enabled whether the search bar is interactive. + * @param placeholder optional placeholder composable shown when the query is empty. + * @param leadingIcon optional composable displayed at the start of the input field. + * @param trailingIcon trailing icon displayed when the bar is not expanded, or when + * the implementation determines no dynamic icon should replace it. + * @param shape the shape of the search bar when collapsed; expanded shape is always + * [SearchBarDefaults.fullScreenShape]. + * @param colors the [SearchBarColors] used to resolve container, content, and indicator colors. + * @param tonalElevation the tonal elevation applied to the container surface. + * @param shadowElevation the elevation of the search bar's shadow. + * @param windowInsets the window insets that this search bar should respect. + * @param interactionSource an optional [MutableInteractionSource] for observing focus and + * press interactions within the input field. + * @param scrollBehavior optional [SearchBarScrollBehavior] controlling how the search bar + * responds to scroll events (pinned, enter-always, etc.). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopSearchBar( + state: SearchBarState, + modifier: Modifier = Modifier, + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + 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.TonalElevation, + shadowElevation: Dp = SearchBarDefaults.ShadowElevation, + windowInsets: WindowInsets = SearchBarDefaults.windowInsets, + interactionSource: MutableInteractionSource? = null, + scrollBehavior: SearchBarScrollBehavior? = null, +) { + M3TopSearchBar( + state = state, + inputField = { + SearchBarInputField( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + expanded = expanded, + onExpandedChange = onExpandedChange, + enabled = enabled, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + colors = colors, + interactionSource = interactionSource, + ) + }, + modifier = modifier, + shape = shape, + colors = colors, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + windowInsets = windowInsets, + scrollBehavior = scrollBehavior, + ) +} + +@Suppress("LongParameterList") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchBarInputField( + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + enabled: Boolean, + placeholder: @Composable (() -> Unit)?, + leadingIcon: @Composable (() -> Unit)?, + trailingIcon: (@Composable () -> Unit)?, + colors: SearchBarColors, + interactionSource: MutableInteractionSource?, +) { + CompositionLocalProvider( + LocalTextStyle provides AcornTheme.typography.body1, + ) { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + expanded = expanded, + onExpandedChange = onExpandedChange, + enabled = enabled, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + when { + !expanded -> trailingIcon?.invoke() + expanded && query.isEmpty() -> Unit + expanded && query.isNotEmpty() -> { + IconButton(onClick = { onQueryChange("") }) { + Icon( + painterResource(iconsR.drawable.mozac_ic_cross_circle_fill_24), + contentDescription = stringResource( + R.string.text_field_cross_trailing_icon_default_content_description, + ), + ) + } + } + } + } + } + }, + colors = colors.inputFieldColors, + interactionSource = interactionSource, + ) + } +} + +/** + * A preview state for the SearchBar. + * + * @param showTrailing Whether or not to show trailing icon. + * @param showSecondaryTrailing Whether or not to show secondary trailing icon. + */ +private data class SearchBarPreviewState( + val showTrailing: Boolean = false, + val showSecondaryTrailing: Boolean = false, +) + +/** + * Provides a sequence of [SearchBarPreviewState] configurations for Compose previews to render + * the SearchBar in multiple visual states without duplicating preview functions. + */ +private class SearchBarIconsPreviewProvider : + PreviewParameterProvider<SearchBarPreviewState> { + override val values = sequenceOf( + SearchBarPreviewState(), + SearchBarPreviewState( + showTrailing = true, + ), + SearchBarPreviewState( + showTrailing = true, + showSecondaryTrailing = true, + ), + ) +} + +private val PreviewSearchItems = listOf( + "Mozilla", + "Google", + "Google Maps", +) + +@Composable +private fun rememberFilteredItems( + query: String, + items: List<String> = PreviewSearchItems, +): List<String> { + return remember(query, items) { + val trimmed = query.trim() + if (trimmed.isEmpty()) { + items + } else { + items.filter { it.contains(trimmed, ignoreCase = true) } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@PreviewLightDark +@Composable +private fun SearchBarPreview( + @PreviewParameter(SearchBarIconsPreviewProvider::class) + previewState: SearchBarPreviewState, +) { + var query by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val filteredItems = rememberFilteredItems(query) + + AcornTheme { + SearchBar( + query = query, + onQueryChange = { query = it }, + onSearch = {}, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = { Text("Search...") }, + leadingIcon = { + Icon( + painter = painterResource(id = iconsR.drawable.mozac_ic_search_24), + contentDescription = "content description", + ) + }, + trailingIcon = + if (previewState.showTrailing || previewState.showSecondaryTrailing) { + { + Row { + if (previewState.showTrailing) { + IconButton( + onClick = {}, + ) { + Icon( + painter = painterResource(id = iconsR.drawable.mozac_ic_qr_code_24), + contentDescription = "content description", + ) + } + } + if (previewState.showSecondaryTrailing) { + IconButton( + onClick = {}, + ) { + Icon( + painter = painterResource(id = iconsR.drawable.mozac_ic_microphone_24), + contentDescription = "content description", + ) + } + } + } + } + } else { + null + }, + ) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface, + ) { + filteredItems.forEach { item -> + Text( + text = item, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@PreviewLightDark +@Composable +private fun TopSearchBarPreview() { + val state = rememberSearchBarState() + var query by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val filteredItems = rememberFilteredItems(query) + + AcornTheme { + Scaffold( + topBar = { + TopSearchBar( + state = state, + modifier = Modifier, + query = query, + onQueryChange = { query = it }, + onSearch = {}, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = { Text("Search...") }, + leadingIcon = { + IconButton( + onClick = {}, + ) { + Icon( + painter = painterResource(id = iconsR.drawable.mozac_ic_search_24), + contentDescription = "content description", + ) + } + }, + trailingIcon = { + Row { + IconButton( + onClick = {}, + ) { + Icon( + painter = painterResource(id = iconsR.drawable.mozac_ic_qr_code_24), + contentDescription = "content description", + ) + } + IconButton( + onClick = {}, + ) { + Icon( + painter = painterResource(id = iconsR.drawable.mozac_ic_microphone_24), + contentDescription = "content description", + ) + } + } + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding), + ) { + items(filteredItems) { item -> + Text( + text = item, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + } + } +}