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.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(
|
||||||
|
|||||||
@ -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 ->
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user