Let SearchBar/SearchField use TextFieldState

This commit is contained in:
ganfra 2026-01-22 16:34:22 +01:00
parent 87619e50e8
commit fa1b32f0ba
48 changed files with 197 additions and 298 deletions

View file

@ -17,6 +17,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarColors
@ -25,9 +28,7 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@ -51,8 +52,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> SearchBar(
query: String,
onQueryChange: (String) -> Unit,
queryState: TextFieldState,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
placeHolderTitle: String,
@ -72,10 +72,9 @@ fun <T> SearchBar(
) {
val focusManager = LocalFocusManager.current
val colors = if (active) activeBarColors else inactiveBarColors
val updatedOnQueryChange by rememberUpdatedState(onQueryChange)
LaunchedEffect(active) {
if (!active) {
updatedOnQueryChange("")
queryState.clearText()
focusManager.clearFocus()
}
}
@ -83,8 +82,7 @@ fun <T> SearchBar(
SearchBar(
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = updatedOnQueryChange,
state = queryState,
onSearch = { focusManager.clearFocus() },
expanded = active,
onExpandedChange = onActiveChange,
@ -98,9 +96,9 @@ fun <T> SearchBar(
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
active && queryState.text.isNotEmpty() -> {
{
IconButton(onClick = { onQueryChange("") }) {
IconButton(onClick = { queryState.clearText() }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear),
@ -221,7 +219,7 @@ internal fun SearchBarInactivePreview() = ElementThemedPreview { ContentToPrevie
@Composable
internal fun SearchBarActiveNoneQueryPreview() = ElementThemedPreview {
ContentToPreview(
query = "",
initialQuery = "",
active = true,
)
}
@ -230,7 +228,7 @@ internal fun SearchBarActiveNoneQueryPreview() = ElementThemedPreview {
@Composable
internal fun SearchBarActiveWithQueryPreview() = ElementThemedPreview {
ContentToPreview(
query = "search term",
initialQuery = "search term",
active = true,
)
}
@ -239,7 +237,7 @@ internal fun SearchBarActiveWithQueryPreview() = ElementThemedPreview {
@Composable
internal fun SearchBarActiveWithQueryNoBackButtonPreview() = ElementThemedPreview {
ContentToPreview(
query = "search term",
initialQuery = "search term",
active = true,
showBackButton = false,
)
@ -249,7 +247,7 @@ internal fun SearchBarActiveWithQueryNoBackButtonPreview() = ElementThemedPrevie
@Composable
internal fun SearchBarActiveWithNoResultsPreview() = ElementThemedPreview {
ContentToPreview(
query = "search term",
initialQuery = "search term",
active = true,
resultState = SearchBarResultState.NoResultsFound<String>(),
)
@ -259,7 +257,7 @@ internal fun SearchBarActiveWithNoResultsPreview() = ElementThemedPreview {
@Composable
internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
ContentToPreview(
query = "search term",
initialQuery = "search term",
active = true,
resultState = SearchBarResultState.Results("result!"),
contentPrefix = {
@ -292,7 +290,7 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
@Composable
@ExcludeFromCoverage
private fun ContentToPreview(
query: String = "",
initialQuery: String = "",
active: Boolean = false,
showBackButton: Boolean = true,
resultState: SearchBarResultState<String> = SearchBarResultState.Initial(),
@ -300,13 +298,13 @@ private fun ContentToPreview(
contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(String) -> Unit = {},
) {
val queryState = rememberTextFieldState(initialText = initialQuery)
SearchBar(
modifier = Modifier.heightIn(max = 200.dp),
query = query,
queryState = queryState,
active = active,
resultState = resultState,
showBackButton = showBackButton,
onQueryChange = {},
onActiveChange = {},
placeHolderTitle = "Search for things",
contentPrefix = contentPrefix,

View file

@ -22,8 +22,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -34,7 +36,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@ -50,8 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
*/
@Composable
fun SearchField(
value: String,
onValueChange: (String) -> Unit,
state: TextFieldState,
modifier: Modifier = Modifier,
placeholder: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
@ -59,67 +59,28 @@ fun SearchField(
val focusManager = LocalFocusManager.current
val isFocused by interactionSource.collectIsFocusedAsState()
BasicTextField(
value = value,
onValueChange = onValueChange,
state = state,
modifier = modifier,
textStyle = textFieldStyle(),
singleLine = true,
lineLimits = TextFieldLineLimits.SingleLine,
interactionSource = interactionSource,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
}
),
onKeyboardAction = {
focusManager.clearFocus()
},
cursorBrush = SolidColor(ElementTheme.colors.textActionAccent),
) { innerTextField ->
DecorationBox(
isFocused = isFocused,
placeholder = placeholder,
isTextEmpty = value.isEmpty(),
innerTextField = innerTextField,
onClear = { onValueChange("") },
)
}
}
@Composable
fun SearchField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusManager = LocalFocusManager.current
val isFocused by interactionSource.collectIsFocusedAsState()
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
textStyle = textFieldStyle(),
singleLine = true,
interactionSource = interactionSource,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
}
),
cursorBrush = SolidColor(ElementTheme.colors.textActionAccent),
) { innerTextField ->
DecorationBox(
isFocused = isFocused,
placeholder = placeholder,
isTextEmpty = value.text.isEmpty(),
innerTextField = innerTextField,
onClear = { TextFieldValue() }
)
}
decorator = { innerTextField ->
DecorationBox(
isFocused = isFocused,
placeholder = placeholder,
isTextEmpty = state.text.isEmpty(),
innerTextField = innerTextField,
onClear = { state.clearText() },
)
}
)
}
@Composable
@ -211,14 +172,12 @@ private fun ContentToPreview() {
verticalArrangement = spacedBy(8.dp)
) {
SearchField(
onValueChange = {},
placeholder = "Search",
value = "",
state = TextFieldState(""),
)
SearchField(
onValueChange = {},
placeholder = "Search",
value = "Search term",
state = TextFieldState("Search term"),
)
}
}

View file

@ -16,5 +16,4 @@ sealed interface RoomSelectEvents {
// TODO remove to restore multi-selection
data object RemoveSelectedRoom : RoomSelectEvents
data object ToggleSearchActive : RoomSelectEvents
data class UpdateQuery(val query: String) : RoomSelectEvents
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
@ -42,12 +43,13 @@ class RoomSelectPresenter(
@Composable
override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
var searchQuery by remember { mutableStateOf("") }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
}
@ -77,7 +79,6 @@ class RoomSelectPresenter(
// }
}
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
is RoomSelectEvents.UpdateQuery -> searchQuery = event.query
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
}
}
@ -85,7 +86,7 @@ class RoomSelectPresenter(
return RoomSelectState(
mode = mode,
resultState = searchResults,
query = searchQuery,
searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = ::handleEvent,

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
@ -16,7 +17,7 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomSelectState(
val mode: RoomSelectMode,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val query: String,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val eventSink: (RoomSelectEvents) -> Unit

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomAlias
@ -22,16 +23,16 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
override val values: Sequence<RoomSelectState>
get() = sequenceOf(
aRoomSelectState(),
aRoomSelectState(query = "Test", isSearchActive = true),
aRoomSelectState(searchQuery = "Test", isSearchActive = true),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
searchQuery = "Test",
isSearchActive = true,
),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
searchQuery = "Test",
isSearchActive = true,
selectedRooms = aRoomSelectRoomList().subList(0, 1),
),
@ -45,13 +46,13 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
private fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
query: String = "",
searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
) = RoomSelectState(
mode = mode,
resultState = resultState,
query = query,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = {}

View file

@ -132,8 +132,7 @@ fun RoomSelectView(
SearchBar(
modifier = Modifier.fillMaxWidth(),
placeHolderTitle = stringResource(CommonStrings.action_search),
query = state.query,
onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) },
queryState = state.searchQuery,
active = state.isSearchActive,
onActiveChange = { state.eventSink(RoomSelectEvents.ToggleSearchActive) },
resultState = state.resultState,

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
@ -78,13 +79,13 @@ class RoomSelectPresenterTest {
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
skipItems(1)
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("string not contained")
)
assertThat(awaitItem().query).isEqualTo("string not contained")
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("string not contained")
roomListService.postAllRooms(
emptyList()
)