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

View File

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