mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
feat: enhance AutocompleteTextField with focus management and dropdown behavior
This commit is contained in:
parent
7c01b2c7c3
commit
15790d63a6
@ -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(
|
||||
|
||||
@ -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 ->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user