feat: enhance AutocompleteTextField with focus management and dropdown behavior

This commit is contained in:
Kingkor Roy Tirtho 2026-07-03 16:57:40 +06:00
parent 7c01b2c7c3
commit 15790d63a6
2 changed files with 90 additions and 61 deletions

View File

@ -29,6 +29,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -64,10 +65,14 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
@ -95,8 +100,6 @@ fun <T> AutocompleteTextField(
items: List<T>,
itemContent: @Composable (item: T, isSelected: Boolean) -> Unit,
modifier: Modifier = Modifier,
expanded: Boolean = false,
onExpandedChange: (Boolean) -> Unit = {},
onItemSelected: (T) -> Unit = {},
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
@ -125,29 +128,40 @@ fun <T> AutocompleteTextField(
val isFocused by interactionSource.collectIsFocusedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
var isMenuOpen by remember { mutableStateOf(false) }
var isMovingFocusToPopup by remember { mutableStateOf(false) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
val menuListState = rememberLazyListState()
val popupFocusRequester = remember { FocusRequester() }
val textFieldFocusRequester = remember { FocusRequester() }
val effectiveExpanded = expanded && items.isNotEmpty()
val menuExpanded = isMenuOpen && items.isNotEmpty()
LaunchedEffect(isFocused) {
if (isFocused) {
isMenuOpen = true
isMovingFocusToPopup = false
}
}
LaunchedEffect(value) {
if (selectedIndex != -1) selectedIndex = -1
}
LaunchedEffect(selectedIndex) {
if (selectedIndex >= 0 && effectiveExpanded) {
if (selectedIndex >= 0 && menuExpanded) {
menuListState.scrollToItem(selectedIndex)
}
}
val wrappedKeyboardActions = remember(keyboardActions, effectiveExpanded, selectedIndex, items) {
val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) {
KeyboardActions(
onSearch = {
if (effectiveExpanded && selectedIndex in items.indices) {
if (menuExpanded && selectedIndex in items.indices) {
onItemSelected(items[selectedIndex])
selectedIndex = -1
onExpandedChange(false)
isMenuOpen = false
} else {
keyboardActions.onSearch?.invoke(this)
}
@ -231,33 +245,46 @@ fun <T> AutocompleteTextField(
interactionSource = interactionSource,
enabled = enabled,
)
.onKeyEvent { event ->
if (onKeyEvent?.invoke(event) == true) {
return@onKeyEvent true
.focusRequester(textFieldFocusRequester)
.onFocusChanged { state ->
if (!state.isFocused && !isMovingFocusToPopup) {
isMenuOpen = false
selectedIndex = -1
}
if (!effectiveExpanded) return@onKeyEvent false
}
.onPreviewKeyEvent { event ->
if (onKeyEvent?.invoke(event) == true) {
return@onPreviewKeyEvent true
}
if (!menuExpanded) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionDown -> {
navigate(1); true
isMovingFocusToPopup = true
popupFocusRequester.requestFocus()
navigate(1)
true
}
Key.DirectionUp -> {
navigate(-1); true
if (selectedIndex >= 0) {
navigate(-1)
true
} else false
}
Key.Enter -> {
if (selectedIndex in items.indices) {
onItemSelected(items[selectedIndex])
selectedIndex = -1
onExpandedChange(false)
return@onKeyEvent true
isMenuOpen = false
return@onPreviewKeyEvent true
}
false
}
Key.Escape -> {
selectedIndex = -1
onExpandedChange(false)
isMenuOpen = false
true
}
@ -295,7 +322,7 @@ fun <T> AutocompleteTextField(
}
}
if (effectiveExpanded && textFieldSize.width > 0) {
if (menuExpanded && textFieldSize.width > 0) {
val textFieldHeightPx = textFieldSize.height
val menuOffsetPx = with(density) { menuOffset.roundToPx() }
val textFieldWidthDp = with(density) { textFieldSize.width.toDp() }
@ -306,7 +333,7 @@ fun <T> AutocompleteTextField(
properties = PopupProperties(focusable = true),
onDismissRequest = {
selectedIndex = -1
onExpandedChange(false)
isMenuOpen = false
},
) {
Box(
@ -315,7 +342,45 @@ fun <T> AutocompleteTextField(
.heightIn(max = menuMaxHeight)
.shadow(menuShadowElevation, menuShape)
.background(menuContainerColor, menuShape)
.clip(menuShape),
.clip(menuShape)
.focusRequester(popupFocusRequester)
.focusable()
.onFocusChanged { state ->
if (state.isFocused) {
isMovingFocusToPopup = false
}
}
.onKeyEvent { event ->
when (event.key) {
Key.DirectionDown -> {
navigate(1); true
}
Key.DirectionUp -> {
navigate(-1); true
}
Key.Enter -> {
if (selectedIndex in items.indices) {
onItemSelected(items[selectedIndex])
selectedIndex = -1
isMenuOpen = false
textFieldFocusRequester.requestFocus()
return@onKeyEvent true
}
false
}
Key.Escape -> {
selectedIndex = -1
isMenuOpen = false
textFieldFocusRequester.requestFocus()
true
}
else -> false
}
},
) {
LazyColumn(
state = menuListState,
@ -329,7 +394,8 @@ fun <T> AutocompleteTextField(
.clickable {
onItemSelected(items[index])
selectedIndex = -1
onExpandedChange(false)
isMenuOpen = false
textFieldFocusRequester.requestFocus()
},
) {
itemContent(items[index], selectedIndex == index)
@ -362,23 +428,18 @@ private fun AutocompleteTextFieldPreview() {
)
}
var value by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
AutocompleteTextField(
value = value,
onValueChange = {
value = it
expanded = it.isNotEmpty()
},
items = artists.filter {
it.contains(value, ignoreCase = true) && value.isNotEmpty()
},
onItemSelected = { selected ->
value = selected
expanded = false
},
expanded = expanded,
onExpandedChange = { expanded = it },
placeholder = { Text("Search artists...") },
leadingIcon = {
androidx.compose.material3.Icon(

View File

@ -57,18 +57,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
@ -125,9 +120,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
var isSearchFocused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
fun playSingleTrack(track: MetadataTrack) {
scope.launch {
@ -231,9 +224,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}
}
val showDropdown =
isSearchFocused && state.query.isBlank() && state.recentSearches.isNotEmpty()
Scaffold(
topBar = { ApplicationMainBar(backButton = false) }
) { innerPadding ->
@ -249,24 +239,17 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
query = state.query,
onQueryChange = viewModel::onQueryChange,
onClear = viewModel::clearQuery,
isFocused = isSearchFocused,
onFocusChanged = { isSearchFocused = it },
focusRequester = focusRequester,
onSearch = {
focusManager.clearFocus()
isSearchFocused = false
},
showDropdown = showDropdown,
recentSearches = state.recentSearches,
onRecentSearchClick = { search ->
viewModel.applyRecentSearch(search)
isSearchFocused = false
focusManager.clearFocus()
},
onRecentSearchRemove = viewModel::removeRecentSearch,
onClearAllRecentSearches = {
viewModel.clearAllRecentSearches()
isSearchFocused = false
focusManager.clearFocus()
},
)
@ -393,19 +376,16 @@ private fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onClear: () -> Unit,
isFocused: Boolean,
onFocusChanged: (Boolean) -> Unit,
focusRequester: FocusRequester,
onSearch: () -> Unit,
showDropdown: Boolean,
recentSearches: List<String>,
onRecentSearchClick: (String) -> Unit,
onRecentSearchRemove: (String) -> Unit,
onClearAllRecentSearches: () -> Unit,
) {
val dropdownItems = remember(showDropdown, recentSearches) {
val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty()
val dropdownItems = remember(showRecentSearches, recentSearches) {
buildList {
if (showDropdown) {
if (showRecentSearches) {
add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches))
recentSearches.forEach { search ->
add(
@ -420,8 +400,6 @@ private fun SearchBar(
}
}
val focusManager = LocalFocusManager.current
Box(
modifier = Modifier
.fillMaxWidth()
@ -433,13 +411,6 @@ private fun SearchBar(
onValueChange = onQueryChange,
items = dropdownItems,
onItemSelected = { entry -> entry.onClick() },
expanded = showDropdown,
onExpandedChange = { expanded ->
if (!expanded) {
onFocusChanged(false)
focusManager.clearFocus()
}
},
placeholder = {
Text(
"Search songs, artists, albums...",
@ -470,10 +441,7 @@ private fun SearchBar(
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { onFocusChanged(it.isFocused) }
.focusRequester(focusRequester),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
itemContent = { entry, isSelected ->