Use TextFieldState for room list search (#5975)

* Add new `FilledTextField` variant using `TextFieldState`

* Use `TextFieldState` for `RoomListSearchState.query` - it seems like this is the best practice for this kind of data

* Bonus: fix the clear button being misaligned
This commit is contained in:
Jorge Martin Espinosa 2026-01-07 17:24:01 +01:00 committed by GitHub
parent ec43d7652b
commit d67aee23c3
7 changed files with 75 additions and 41 deletions

View file

@ -10,6 +10,5 @@ package io.element.android.features.home.impl.search
sealed interface RoomListSearchEvents { sealed interface RoomListSearchEvents {
data object ToggleSearchVisibility : RoomListSearchEvents data object ToggleSearchVisibility : RoomListSearchEvents
data class QueryChanged(val query: String) : RoomListSearchEvents
data object ClearQuery : RoomListSearchEvents data object ClearQuery : RoomListSearchEvents
} }

View file

@ -8,6 +8,8 @@
package io.element.android.features.home.impl.search package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -29,29 +31,24 @@ class RoomListSearchPresenter(
var isSearchActive by remember { var isSearchActive by remember {
mutableStateOf(false) mutableStateOf(false)
} }
var searchQuery by remember { val searchQuery = rememberTextFieldState()
mutableStateOf("")
}
LaunchedEffect(isSearchActive) { LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive) dataSource.setIsActive(isSearchActive)
} }
LaunchedEffect(searchQuery) { LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery) dataSource.setSearchQuery(searchQuery.text.toString())
} }
fun handleEvent(event: RoomListSearchEvents) { fun handleEvent(event: RoomListSearchEvents) {
when (event) { when (event) {
RoomListSearchEvents.ClearQuery -> { RoomListSearchEvents.ClearQuery -> {
searchQuery = "" searchQuery.clearText()
}
is RoomListSearchEvents.QueryChanged -> {
searchQuery = event.query
} }
RoomListSearchEvents.ToggleSearchVisibility -> { RoomListSearchEvents.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive isSearchActive = !isSearchActive
searchQuery = "" searchQuery.clearText()
} }
} }
} }

View file

@ -8,12 +8,13 @@
package io.element.android.features.home.impl.search package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class RoomListSearchState( data class RoomListSearchState(
val isSearchActive: Boolean, val isSearchActive: Boolean,
val query: String, val query: TextFieldState,
val results: ImmutableList<RoomListRoomSummary>, val results: ImmutableList<RoomListRoomSummary>,
val eventSink: (RoomListSearchEvents) -> Unit val eventSink: (RoomListSearchEvents) -> Unit
) )

View file

@ -8,6 +8,7 @@
package io.element.android.features.home.impl.search package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
@ -33,7 +34,7 @@ fun aRoomListSearchState(
eventSink: (RoomListSearchEvents) -> Unit = { }, eventSink: (RoomListSearchEvents) -> Unit = { },
) = RoomListSearchState( ) = RoomListSearchState(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
query = query, query = TextFieldState(initialText = query),
results = results, results = results,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -18,16 +18,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -35,7 +32,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@ -112,23 +108,14 @@ private fun RoomListSearchContent(
}, },
navigationIcon = { BackButton(onClick = ::onBackButtonClick) }, navigationIcon = { BackButton(onClick = ::onBackButtonClick) },
title = { title = {
// TODO replace `state.query` with TextFieldState when it's available for M3 TextField
// The stateSaver will keep the selection state when returning to this UI // The stateSaver will keep the selection state when returning to this UI
var value by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(state.query))
}
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
FilledTextField( FilledTextField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester), .focusRequester(focusRequester),
value = value, state = state.query,
singleLine = true, lineLimits = TextFieldLineLimits.SingleLine,
onValueChange = {
value = it
state.eventSink(RoomListSearchEvents.QueryChanged(it.text))
},
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
@ -138,20 +125,18 @@ private fun RoomListSearchContent(
disabledIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent,
), ),
trailingIcon = { trailingIcon = if (state.query.text.isNotEmpty()) {
if (value.text.isNotEmpty()) { @Composable {
IconButton(onClick = { IconButton(onClick = { state.eventSink(RoomListSearchEvents.ClearQuery) }) {
state.eventSink(RoomListSearchEvents.ClearQuery)
// Clear local state too
value = value.copy(text = "")
}) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel) contentDescription = stringResource(CommonStrings.action_cancel)
) )
} }
} }
} } else {
null
},
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View file

@ -33,7 +33,7 @@ class RoomListSearchPresenterTest {
}.test { }.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse() assertThat(state.isSearchActive).isFalse()
assertThat(state.query).isEmpty() assertThat(state.query.text.toString()).isEmpty()
assertThat(state.results).isEmpty() assertThat(state.results).isEmpty()
} }
} }
@ -72,10 +72,10 @@ class RoomListSearchPresenterTest {
).isEqualTo( ).isEqualTo(
RoomListFilter.None RoomListFilter.None
) )
state.eventSink(RoomListSearchEvents.QueryChanged("Search")) state.query.edit { append("Search") }
} }
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.query).isEqualTo("Search") assertThat(state.query.text).isEqualTo("Search")
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomListService.allRooms.currentFilter.value
).isEqualTo( ).isEqualTo(
@ -84,7 +84,7 @@ class RoomListSearchPresenterTest {
state.eventSink(RoomListSearchEvents.ClearQuery) state.eventSink(RoomListSearchEvents.ClearQuery)
} }
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.query).isEmpty() assertThat(state.query.text.toString()).isEmpty()
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomListService.allRooms.currentFilter.value
).isEqualTo( ).isEqualTo(

View file

@ -15,9 +15,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldLabelScope
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -135,6 +141,51 @@ fun FilledTextField(
) )
} }
@Composable
fun FilledTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (TextFieldLabelScope.() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActionHandler? = null,
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors()
) {
androidx.compose.material3.TextField(
state = state,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
supportingText = supportingText,
isError = isError,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
keyboardOptions = keyboardOptions,
onKeyboardAction = keyboardActions,
lineLimits = lineLimits,
interactionSource = interactionSource,
shape = shape,
colors = colors,
)
}
@Preview(group = PreviewGroup.TextFields) @Preview(group = PreviewGroup.TextFields)
@Composable @Composable
internal fun FilledTextFieldLightPreview() = internal fun FilledTextFieldLightPreview() =