diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt index 43c3cce7..6d53f9a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt @@ -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 AutocompleteTextField( items: List, 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 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 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 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 AutocompleteTextField( properties = PopupProperties(focusable = true), onDismissRequest = { selectedIndex = -1 - onExpandedChange(false) + isMenuOpen = false }, ) { Box( @@ -315,7 +342,45 @@ fun 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 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( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 9cdbc658..751a26f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -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, 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 ->