From 890f0c8a23336d106b651cae9e9170bfe973461f Mon Sep 17 00:00:00 2001 From: Andrii Hamula Date: Tue, 1 Nov 2022 15:31:03 +0200 Subject: [PATCH] add implementation of dropdown from puff project with next improvements: - hide dropdown when scroll started - add ability to type into auto complete text field - fix potential issue with focus lost when typing in exposed dropdown menu on samsung devices --- .../autoComplete/AndroidViewDropdown.kt | 114 +++++++++ .../AndroidViewTextFieldWithDropDownSample.kt | 47 ---- .../autoComplete/AutoCompleteDropdown.kt | 162 ++++++++++++ .../autoComplete/AutoCompleteScreen.kt | 138 +++++++--- .../autoComplete/AutoCompleteViewModel.kt | 58 ++++- .../autoComplete/CustomExposedDropdownMenu.kt | 1 + .../CustomExposedDropdownMenuSample.kt | 1 + .../samples/features/autoComplete/Dropdown.kt | 237 ++++++++++++++++++ .../NativeExposedDropDownMenuSample.kt | 98 -------- .../autoComplete/TextFieldsWithValueSwitch.kt | 110 ++++++++ app/src/main/res/layout/text_input_field.xml | 2 +- 11 files changed, 788 insertions(+), 180 deletions(-) create mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewDropdown.kt delete mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteDropdown.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/Dropdown.kt delete mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/TextFieldsWithValueSwitch.kt diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewDropdown.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewDropdown.kt new file mode 100644 index 00000000..6ac81ea9 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewDropdown.kt @@ -0,0 +1,114 @@ +package com.skyyo.samples.features.autoComplete + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.annotation.LayoutRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.google.android.material.textfield.TextInputLayout +import com.skyyo.samples.R + +@Composable +fun AndroidViewDropdown( + modifier: Modifier = Modifier, + countries: List, + selectedValue: String = "", + filter: (String) -> List, + onSelect: (Int) -> Unit = {}, +) { + val context = LocalContext.current + val textInputLayout = remember(countries) { + (View.inflate(context, R.layout.text_input_field, null) as TextInputLayout).also { + (it.editText as AutoCompleteTextView).apply { + val adapter = FilterableAdapter(countries, filter, context) + setOnItemClickListener { _, _, index, _ -> + onSelect(index) + } + setAdapter(adapter) + } + } + } + + AndroidView( + modifier = modifier, + factory = { textInputLayout }, + update = { + (textInputLayout.editText as AutoCompleteTextView).setText(selectedValue) + }, + ) +} + +private class FilterableAdapter( + private val items: List, + private val filterFun: (String) -> List, + context: Context, + @LayoutRes private val layoutId: Int = android.R.layout.simple_list_item_1 +) : BaseAdapter(), Filterable { + + private val inflater = LayoutInflater.from(context) + private var allItems: List? = null + private var filteredItems: List = items + private val filterLock = Any() + + private val adapterFilter: Filter = object : Filter() { + override fun performFiltering(prefix: CharSequence?): FilterResults { + if (allItems == null) { + synchronized(filterLock) { allItems = items } + } + val results: List + if (prefix.isNullOrEmpty()) { + synchronized(filterLock) { + results = allItems!! + } + } else { + synchronized(filterLock) { + results = filterFun(prefix.toString()) + } + } + return FilterResults().apply { + values = results + count = results.size + } + } + + @Suppress("UNCHECKED_CAST") + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + filteredItems = results.values as List + if (results.count > 0) { + notifyDataSetChanged() + } else { + notifyDataSetInvalidated() + } + } + } + + override fun getCount(): Int = filteredItems.size + + override fun getItem(position: Int): Any = filteredItems[position] + + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + return createViewFromResource(inflater, position, convertView, parent, layoutId) + } + + private fun createViewFromResource( + inflater: LayoutInflater, + position: Int, + convertView: View?, + parent: ViewGroup, + layoutId: Int + ): View { + val view: TextView = (convertView ?: inflater.inflate(layoutId, parent, false)) as TextView + view.text = getItem(position) as String + return view + } + + override fun getFilter(): Filter = adapterFilter +} diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt deleted file mode 100644 index 9bd17dd3..00000000 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.skyyo.samples.features.autoComplete - -import android.widget.ArrayAdapter -import android.widget.AutoCompleteTextView -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidView -import com.google.android.material.textfield.TextInputLayout -import com.skyyo.samples.R - -@Composable -fun AndroidViewAutocompleteDropdownWithOutsideFiltering( - modifier: Modifier = Modifier, - suggestions: List, - selectedValue: String = "", - onSelect: (Int) -> Unit = {}, -) { - val context = LocalContext.current - val adapter = remember(suggestions) { - ArrayAdapter(context, android.R.layout.simple_list_item_1, suggestions) - } - val textInputLayout = remember { - ( - TextInputLayout.inflate( - context, - R.layout.text_input_field, - null - ) as TextInputLayout - ).also { til -> - (til.editText as AutoCompleteTextView).setOnItemClickListener { _, _, index, _ -> - onSelect(index) - } - } - } - val autoCompleteTextView = remember { textInputLayout.editText as AutoCompleteTextView } - - AndroidView( - modifier = modifier, - factory = { textInputLayout }, - update = { - autoCompleteTextView.setAdapter(adapter) - autoCompleteTextView.setText(selectedValue, false) - }, - ) -} diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteDropdown.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteDropdown.kt new file mode 100644 index 00000000..c489f131 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteDropdown.kt @@ -0,0 +1,162 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAll +import com.skyyo.samples.utils.OnValueChange +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.abs + +private const val WAITING_TIME_BETWEEN_DISMISS_REQUEST_AND_TAP_EVENT = 500L + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AutoCompleteDropdown( + modifier: Modifier, + scrollInteractionSource: InteractionSource, + items: List, + query: String, + isDropdownVisible: Boolean, + onQueryChanged: OnValueChange, + onItemSelected: OnValueChange, +) { + var isDropdownTemporaryHidden by rememberSaveable { mutableStateOf(false) } + val queryInteractionSource = remember { MutableInteractionSource() } + val isFocused by queryInteractionSource.collectIsFocusedAsState() + val isAutoCompleteDropdownVisible = remember(isDropdownVisible, isDropdownTemporaryHidden) { + isDropdownVisible && !isDropdownTemporaryHidden + } + val coroutineScope = rememberCoroutineScope() + val dismissDropdownJob = remember { Ref() } + val textInputModifier: Modifier = remember { + Modifier + .fillMaxWidth() + .whenTapped { + // toggle dropdown visibility when user tapped on text field + dismissDropdownJob.value?.cancel().also { dismissDropdownJob.value = null } + isDropdownTemporaryHidden = !isDropdownTemporaryHidden + } + } + val focusManager = LocalFocusManager.current + val queryKeyboardActions = remember { + KeyboardActions(onAny = { focusManager.moveFocus(FocusDirection.Next) }) + } + + if (isFocused) { + LaunchedEffect(Unit) { + scrollInteractionSource.interactions.collect { + // hide dropdown when it's focused and scroll started + if (it is DragInteraction.Start) { + isDropdownTemporaryHidden = true + } + } + } + } + + Dropdown( + modifier = modifier, + expanded = isAutoCompleteDropdownVisible, + onDismissRequest = { + dismissDropdownJob.value = coroutineScope.launch(Dispatchers.IO) { + delay(WAITING_TIME_BETWEEN_DISMISS_REQUEST_AND_TAP_EVENT) + isDropdownTemporaryHidden = true + } + }, + anchor = { + OutlinedTextFieldWithValueSwitch( + value = query, + modifier = textInputModifier, + interactionSource = queryInteractionSource, + onSelectionChange = { + dismissDropdownJob.value?.cancel().also { dismissDropdownJob.value = null } + }, + onValueChange = { + dismissDropdownJob.value?.cancel().also { dismissDropdownJob.value = null } + isDropdownTemporaryHidden = false + onQueryChanged(it) + }, + label = { Text("Label") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(isAutoCompleteDropdownVisible) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, autoCorrect = false), + keyboardActions = queryKeyboardActions + ) + }, + content = { contentModifier -> + Items( + modifier = contentModifier, + items = items, + onNewItemSelected = { newSelectedItem -> + onItemSelected(newSelectedItem) + if (isFocused) focusManager.moveFocus(FocusDirection.Next) + } + ) + } + ) +} + +@Composable +private fun Items(modifier: Modifier, items: List, onNewItemSelected: OnValueChange) { + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.Center + ) { + this.items(items = items, key = { item -> item }) { item -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .heightIn(min = 48.dp) + .fillMaxWidth() + .clickable { onNewItemSelected(item) } + .padding(horizontal = 16.dp) + ) { + Text(text = item) + } + } + } +} + +private fun Modifier.whenTapped(action: () -> Unit) = pointerInput(Unit) { + forEachGesture { + awaitPointerEventScope { + var event: PointerEvent + val touchSlop = viewConfiguration.touchSlop + var scrolledDistance = 0f + var isTapAction = true + do { + event = awaitPointerEvent(PointerEventPass.Initial) + // if more than one pointer on screen, then it's not tap action + if (event.changes.size > 1) isTapAction = false + scrolledDistance += event.changes.firstOrNull()?.positionChange()?.getDistance() + ?: 0f + // if scrolled more than touch slop, then it's not tap action + if (abs(scrolledDistance) > touchSlop) isTapAction = false + } while (!event.changes.fastAll { it.changedToUp() }) + if (isTapAction) action() + } + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteScreen.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteScreen.kt index 53e878ea..3bb93bc0 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteScreen.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteScreen.kt @@ -1,54 +1,128 @@ package com.skyyo.samples.features.autoComplete +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +private const val ITEMS_ABOVE_AND_BELOW_DROPDOWN_ANCHOR = 10 + @Composable fun AutoCompleteScreen( viewModel: AutoCompleteViewModel = hiltViewModel() ) { - val query = viewModel.query.collectAsState() - val isExpanded = viewModel.isExpanded.collectAsState() - val suggestions = viewModel.suggestions.collectAsState() + val suggestions by viewModel.suggestions.collectAsState() + val isExpanded by viewModel.isExpanded.collectAsState() + val query by viewModel.query.collectAsState() + val isDropdownVisible by viewModel.isDropdownVisible.collectAsState() + val filteredCountries by viewModel.filteredCountries.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + Box { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + AutoCompleteContentColumn( + title = "AndroidView", + modifier = Modifier.weight(1f).imePadding() + ) { + AndroidViewDropdown( + modifier = Modifier.fillMaxWidth(), + countries = viewModel.countries, + selectedValue = "", + filter = viewModel.filter + ) + } + AutoCompleteContentColumn( + title = "Old menu", + modifier = Modifier.weight(1f).imePadding() + ) { + AutocompleteDropdownWithOutsideFiltering( + modifier = Modifier.fillMaxWidth(), + onSuggestionSelected = viewModel::onCountrySelected, + onExpandedChange = viewModel::onExpandedChange, + onValueChange = viewModel::onCountryEntered, + onClick = viewModel::onExpandedFieldClick, + suggestions = suggestions, + expanded = isExpanded, + query = query, + ) + } + AutoCompleteContentColumn( + title = "New menu", + modifier = Modifier.weight(1f).imePadding() + ) { + AutoCompleteDropdown( + modifier = Modifier.fillMaxWidth(), + scrollInteractionSource = it.interactionSource, + items = filteredCountries, + query = query, + isDropdownVisible = isDropdownVisible, + onQueryChanged = viewModel::onCountryEnteredNew, + onItemSelected = viewModel::onCountrySelectedNew + ) + } + } + if (isLoading) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun AutoCompleteContentColumn( + modifier: Modifier, + title: String, + dropdownContent: @Composable (ScrollState) -> Unit +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val keyboardActions = remember { + KeyboardActions { focusManager.moveFocus(FocusDirection.Next) } + } + val scrollState = rememberScrollState() Column( - Modifier - .fillMaxSize() - .padding(top = 50.dp, start = 16.dp, end = 16.dp), + modifier = modifier + .padding(top = 50.dp) + .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(16.dp)) - Text(text = "AndroidView") - AndroidViewAutocompleteDropdownWithOutsideFiltering( - modifier = Modifier.fillMaxWidth(), - suggestions = viewModel.countries, - selectedValue = "", - ) - Spacer(modifier = Modifier.height(16.dp)) - Text(text = "Native exposed dropdown menu") - AutocompleteDropdownWithFilteringInside( - modifier = Modifier.fillMaxWidth(), - countries = viewModel.countries, - ) - Spacer(modifier = Modifier.height(60.dp)) - Text(text = "Custom exposed dropdown menu") - AutocompleteDropdownWithOutsideFiltering( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - onSuggestionSelected = viewModel::onCountrySelected, - onExpandedChange = viewModel::onExpandedChange, - onValueChange = viewModel::onCountryEntered, - onClick = viewModel::onExpandedFieldClick, - suggestions = suggestions.value, - expanded = isExpanded.value, - query = query.value, + Text(text = title) + repeat(ITEMS_ABOVE_AND_BELOW_DROPDOWN_ANCHOR) { + TextFieldWithValueSwitch( + value = "text $it", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = keyboardActions + ) + } + dropdownContent(scrollState) + repeat(ITEMS_ABOVE_AND_BELOW_DROPDOWN_ANCHOR) { + TextFieldWithValueSwitch( + value = "text ${it + ITEMS_ABOVE_AND_BELOW_DROPDOWN_ANCHOR}", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = keyboardActions + ) + } + TextFieldWithValueSwitch( + value = "text 21", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = remember { + KeyboardActions { keyboardController?.hide() } + } ) } } diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteViewModel.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteViewModel.kt index de62a8b2..016c9425 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteViewModel.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteViewModel.kt @@ -4,14 +4,18 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import java.util.* import javax.inject.Inject private const val QUERY = "query" private const val SUGGESTIONS = "suggestions" private const val IS_EXPANDED = "isExpanded" +private const val CURRENT_COUNTRY = "country" +private const val LOAD_COUNTRIES_DEBOUNCE = 300L +private const val KEEP_STATE_WHILE_IN_BACKGROUND_TIME = 5000L +private const val FILTER_COUNTRIES_DELAY = 30L @HiltViewModel class AutoCompleteViewModel @Inject constructor( @@ -20,9 +24,50 @@ class AutoCompleteViewModel @Inject constructor( val countries = provideCountries() val query = handle.getStateFlow(QUERY, "") + // DEPRECATED, use filteredCountries instead val suggestions = handle.getStateFlow(SUGGESTIONS, countries) + // DEPRECATED, use rememberSavable in compose instead val isExpanded = handle.getStateFlow(IS_EXPANDED, false) + val isLoading = MutableStateFlow(false) + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + val filteredCountries = query + .map { it } + .onEach { isLoading.value = true } + .debounce(LOAD_COUNTRIES_DEBOUNCE) + .mapLatest { query -> getFilteredCountries(query) } + .onEach { isLoading.value = false } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(KEEP_STATE_WHILE_IN_BACKGROUND_TIME), + initialValue = emptyList() + ) + val filter: (String) -> List = { query -> + countries.filter { it.lowercase().startsWith(query.lowercase()) } + } + private val selectedCountry = handle.getStateFlow(CURRENT_COUNTRY, null) + val isDropdownVisible = combine( + isLoading, + selectedCountry, + query + ) { areCitiesLoading, selectedCountry, citiesQuery -> + when { + areCitiesLoading -> false + filteredCountries.value.isEmpty() -> false + citiesQuery == selectedCountry -> false + else -> true + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(KEEP_STATE_WHILE_IN_BACKGROUND_TIME), + initialValue = false + ) + + private suspend fun getFilteredCountries(query: String): List { + delay(FILTER_COUNTRIES_DELAY) + return filter(query) + } + fun onCountryEntered(input: String) { handle[QUERY] = input viewModelScope.launch(Dispatchers.Default) { @@ -40,6 +85,10 @@ class AutoCompleteViewModel @Inject constructor( } } + fun onCountryEnteredNew(query: String) { + handle[QUERY] = query + } + fun onExpandedChange(value: Boolean) { handle[IS_EXPANDED] = value } @@ -52,6 +101,11 @@ class AutoCompleteViewModel @Inject constructor( onExpandedChange(!isExpanded.value) } + fun onCountrySelectedNew(currentCountry: String) { + handle[CURRENT_COUNTRY] = currentCountry + onCountryEnteredNew(currentCountry) + } + private fun provideCountries(): List { val locales = Locale.getAvailableLocales() val countries = ArrayList() diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenu.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenu.kt index 73eb0f37..c67918ae 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenu.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenu.kt @@ -56,6 +56,7 @@ private const val OUT_TRANSITION_DURATION = 75 private val MENU_ELEVATION = 8.dp private val DROPDOWN_MENU_VERTICAL_PADDING = 8.dp +// DEPRECATED, use AutoCompleteDropdown instead @Composable fun CustomDropdownMenuContent( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenuSample.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenuSample.kt index 80411617..0ba97c36 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenuSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenuSample.kt @@ -22,6 +22,7 @@ import com.skyyo.samples.utils.OnValueChange @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable +// DEPRECATED, use AutoCompleteDropdown instead fun AutocompleteDropdownWithOutsideFiltering( modifier: Modifier = Modifier, onExpandedChange: (Boolean) -> Unit, diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/Dropdown.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/Dropdown.kt new file mode 100644 index 00000000..db6bf73d --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/Dropdown.kt @@ -0,0 +1,237 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import kotlin.math.max +import kotlin.math.min + +private val MENU_VERTICAL_MARGIN = 48.dp +private val MENU_ELEVATION = 8.dp +private const val IN_TRANSITION_DURATION = 120 +private const val OUT_TRANSITION_DURATION = 75 + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun Dropdown( + modifier: Modifier = Modifier, + expanded: Boolean, + onDismissRequest: () -> Unit, + anchor: @Composable () -> Unit, + content: @Composable (Modifier) -> Unit +) { + ExposedDropdownMenuBox( + modifier = modifier, + expanded = false, + onExpandedChange = { } + ) { + anchor() + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + content(Modifier.exposedDropdownSize().clip(RoundedCornerShape(8.dp))) + } + } +} + +@Composable +private fun DropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + content: @Composable () -> Unit +) { + val expandedStates = remember { MutableTransitionState(false) } + expandedStates.targetState = expanded + + if (expandedStates.currentState || expandedStates.targetState) { + var contentTransformOrigin by remember { mutableStateOf(TransformOrigin.Center) } + val density = LocalDensity.current + val popupPositionProvider = DropdownMenuPositionProvider( + contentOffset = DpOffset(0.dp, 8.dp), + density = density + ) { anchorBounds, popupBounds -> + contentTransformOrigin = calculateTransformOriginNew(anchorBounds, popupBounds) + } + + Popup( + onDismissRequest = onDismissRequest, + popupPositionProvider = popupPositionProvider, + properties = PopupProperties() + ) { + DropdownMenuContent( + expandedStates = expandedStates, + contentTransformOrigin = contentTransformOrigin, + content = content + ) + } + } +} + +@Composable +private fun DropdownMenuContent( + expandedStates: MutableTransitionState, + contentTransformOrigin: TransformOrigin, + content: @Composable () -> Unit +) { + // Menu open/close animation. + val transition = updateTransition(expandedStates, "DropDownMenu") + + val contentScale by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + // Dismissed to expanded + tween(durationMillis = IN_TRANSITION_DURATION, easing = LinearOutSlowInEasing) + } else { + // Expanded to dismissed. + tween(durationMillis = 1, delayMillis = OUT_TRANSITION_DURATION - 1) + } + }, + label = "dropdownContentScale" + ) { expanded -> if (expanded) 1f else 0.8f } + + val contentAlpha by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + // Dismissed to expanded + tween(durationMillis = 30) + } else { + // Expanded to dismissed. + tween(durationMillis = OUT_TRANSITION_DURATION) + } + }, + label = "dropdownContentAlpha" + ) { expanded -> if (expanded) 1f else 0f } + + Card( + modifier = Modifier + .graphicsLayer { + scaleX = contentScale + scaleY = contentScale + alpha = contentAlpha + transformOrigin = contentTransformOrigin + }, + elevation = MENU_ELEVATION + ) { + content() + } +} + +private fun calculateTransformOriginNew( + anchorBounds: IntRect, + popupBounds: IntRect +): TransformOrigin { + val pivotX = when { + popupBounds.left >= anchorBounds.right -> 0f + popupBounds.right <= anchorBounds.left -> 1f + popupBounds.width == 0 -> 0f + else -> { + val intersectionCenter = + ( + max(anchorBounds.left, popupBounds.left) + + min(anchorBounds.right, popupBounds.right) + ) / 2 + (intersectionCenter - popupBounds.left).toFloat() / popupBounds.width + } + } + val pivotY = when { + popupBounds.top >= anchorBounds.bottom -> 0f + popupBounds.bottom <= anchorBounds.top -> 1f + popupBounds.height == 0 -> 0f + else -> { + val intersectionCenter = + ( + max(anchorBounds.top, popupBounds.top) + + min(anchorBounds.bottom, popupBounds.bottom) + ) / 2 + (intersectionCenter - popupBounds.top).toFloat() / popupBounds.height + } + } + return TransformOrigin(pivotX, pivotY) +} + +@Immutable +private data class DropdownMenuPositionProvider( + val contentOffset: DpOffset, + val density: Density, + val onPositionCalculated: (anchorBounds: IntRect, popupBounds: IntRect) -> Unit = { _, _ -> } +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + // The min margin above and below the menu, relative to the screen. + val verticalMargin = with(density) { MENU_VERTICAL_MARGIN.roundToPx() } + // The content offset specified using the dropdown offset parameter. + val contentOffsetX = with(density) { contentOffset.x.roundToPx() } + val contentOffsetY = with(density) { contentOffset.y.roundToPx() } + + // Compute horizontal position. + val toRight = anchorBounds.left + contentOffsetX + val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width + val toDisplayRight = windowSize.width - popupContentSize.width + val toDisplayLeft = 0 + val x = if (layoutDirection == LayoutDirection.Ltr) { + sequenceOf( + toRight, + toLeft, + // If the anchor gets outside of the window on the left, we want to position + // toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight. + if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft + ) + } else { + sequenceOf( + toLeft, + toRight, + // If the anchor gets outside of the window on the right, we want to position + // toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft. + if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight + ) + }.firstOrNull { + it >= 0 && it + popupContentSize.width <= windowSize.width + } ?: toLeft + + val heightAboveAnchor = anchorBounds.top - contentOffsetY - verticalMargin + val heightBelowAnchor = windowSize.height - verticalMargin - anchorBounds.bottom - contentOffsetY + val y: Int + val height: Int + + when { + // anchor is too big to fit into window with verticalMargin step below and above popup + heightAboveAnchor < 0 && heightBelowAnchor < 0 -> { + height = popupContentSize.height + y = anchorBounds.bottom + contentOffsetY + } + heightAboveAnchor > heightBelowAnchor -> { + height = minOf(heightAboveAnchor, popupContentSize.height) + y = anchorBounds.top - contentOffsetY - height + } + else -> { + height = minOf(heightBelowAnchor, popupContentSize.height) + y = anchorBounds.bottom + contentOffsetY + } + } + + onPositionCalculated( + anchorBounds, + IntRect(x, y, x + popupContentSize.width, y + height) + ) + + return IntOffset(x, y) + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt deleted file mode 100644 index d2f82d57..00000000 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.skyyo.samples.features.autoComplete - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.* - -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) -@Composable -fun AutocompleteDropdownWithFilteringInside( - modifier: Modifier = Modifier, - countries: List, -) { - var expanded by remember { mutableStateOf(false) } - var query by remember { mutableStateOf("") } - val defaultLocale = remember { Locale.getDefault() } - val lowerCaseSearchQuery = remember(query) { query.lowercase(defaultLocale) } - var suggestions by remember { mutableStateOf(countries) } - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val keyboardOptions = remember { - KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next) - } - val keyboardActions = remember { - KeyboardActions( - onNext = { - expanded = false - focusManager.clearFocus() - keyboardController?.hide() - } - ) - } - - LaunchedEffect(lowerCaseSearchQuery) { - withContext(Dispatchers.Default) { - suggestions = when (lowerCaseSearchQuery.isEmpty()) { - true -> countries - false -> { - countries.filter { country -> - country.lowercase(defaultLocale).startsWith(lowerCaseSearchQuery) && country != query - } - } - } - expanded = true - } - } - - ExposedDropdownMenuBox( - modifier = modifier, - expanded = expanded, - onExpandedChange = { - expanded = !expanded - } - ) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = query, - onValueChange = { - query = it - }, - label = { Text("Label") }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon( - expanded = expanded - ) - }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions - ) - if (suggestions.isNotEmpty() && expanded) { - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - suggestions.forEach { selectionOption -> - DropdownMenuItem( - onClick = { - query = selectionOption - expanded = false - } - ) { - Text(text = selectionOption) - } - } - } - } - } -} diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/TextFieldsWithValueSwitch.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/TextFieldsWithValueSwitch.kt new file mode 100644 index 00000000..0820ee52 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/TextFieldsWithValueSwitch.kt @@ -0,0 +1,110 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.TextField +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.skyyo.samples.utils.OnValueChange + +@Composable +fun OutlinedTextFieldWithValueSwitch( + value: String, + modifier: Modifier = Modifier, + onValueChange: OnValueChange = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), + label: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + onSelectionChange: (TextRange) -> Unit = {}, +) { + // Holds the latest internal TextFieldValue state. We need to keep it to have the correct value + // of the composition. + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } + // Last String value that either text field was recomposed with or updated in the onValueChange + // callback. We keep track of it to prevent calling onValueChange(String) for same String when + // CoreTextField's onValueChange is called multiple times without recomposition in between. + var lastTextValue by remember(value) { mutableStateOf(value) } + val valueSwitched = remember(textFieldValueState, value) { textFieldValueState.text != value } + + // Holds the latest TextFieldValue that BasicTextField was recomposed with. We couldn't simply + // pass `TextFieldValue(text = value)` to the CoreTextField because we need to preserve the + // composition. + val textFieldValue = when { + valueSwitched -> textFieldValueState.copy(text = value, TextRange(value.length)) + else -> textFieldValueState.copy(text = value) + } + + OutlinedTextField( + interactionSource = interactionSource, + modifier = modifier, + value = textFieldValue, + onValueChange = { newTextFieldValue -> + if (textFieldValueState.selection != newTextFieldValue.selection) { + onSelectionChange(newTextFieldValue.selection) + } + textFieldValueState = newTextFieldValue + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValue.text + lastTextValue = newTextFieldValue.text + if (stringChangedSinceLastInvocation) onValueChange(newTextFieldValue.text) + }, + label = label, + trailingIcon = trailingIcon, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) +} + +@Composable +fun TextFieldWithValueSwitch( + value: String, + modifier: Modifier = Modifier, + onValueChange: OnValueChange = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), + label: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + onSelectionChange: (TextRange) -> Unit = {}, +) { + // Holds the latest internal TextFieldValue state. We need to keep it to have the correct value + // of the composition. + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } + // Last String value that either text field was recomposed with or updated in the onValueChange + // callback. We keep track of it to prevent calling onValueChange(String) for same String when + // CoreTextField's onValueChange is called multiple times without recomposition in between. + var lastTextValue by remember(value) { mutableStateOf(value) } + val valueSwitched = remember(textFieldValueState, value) { textFieldValueState.text != value } + + // Holds the latest TextFieldValue that BasicTextField was recomposed with. We couldn't simply + // pass `TextFieldValue(text = value)` to the CoreTextField because we need to preserve the + // composition. + val textFieldValue = when { + valueSwitched -> textFieldValueState.copy(text = value, TextRange(value.length)) + else -> textFieldValueState.copy(text = value) + } + + TextField( + interactionSource = interactionSource, + modifier = modifier, + value = textFieldValue, + onValueChange = { newTextFieldValue -> + if (textFieldValueState.selection != newTextFieldValue.selection) { + onSelectionChange(newTextFieldValue.selection) + } + textFieldValueState = newTextFieldValue + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValue.text + lastTextValue = newTextFieldValue.text + if (stringChangedSinceLastInvocation) onValueChange(newTextFieldValue.text) + }, + label = label, + trailingIcon = trailingIcon, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) +} diff --git a/app/src/main/res/layout/text_input_field.xml b/app/src/main/res/layout/text_input_field.xml index 254a5fa3..eb230179 100644 --- a/app/src/main/res/layout/text_input_field.xml +++ b/app/src/main/res/layout/text_input_field.xml @@ -8,7 +8,7 @@