From 40f8a02610ee247589fe0182d8b50b4d127e85b3 Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Wed, 18 May 2022 12:47:09 +0300 Subject: [PATCH 01/11] implement autocompleteDropDownMenu --- .../skyyo/samples/application/Destination.kt | 1 + .../application/activity/PopulatedNavHost.kt | 2 + .../AndroidViewTextFieldWithDropDownSample.kt | 39 + .../autoComplete/AutoCompleteScreen.kt | 59 ++ .../autoComplete/CustomExposedDropdownMenu.kt | 783 ++++++++++++++++++ .../CustomExposedDropdownMenuSample.kt | 87 ++ .../NativeExposedDropDownMenuSample.kt | 58 ++ .../sampleContainer/SampleContainerScreen.kt | 5 + .../SampleContainerViewModel.kt | 4 + app/src/main/res/layout/text_input_field.xml | 14 + 10 files changed, 1052 insertions(+) create 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/AutoCompleteScreen.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenu.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenuSample.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt create mode 100644 app/src/main/res/layout/text_input_field.xml diff --git a/app/src/main/java/com/skyyo/samples/application/Destination.kt b/app/src/main/java/com/skyyo/samples/application/Destination.kt index 791dd19e..08baaf3c 100644 --- a/app/src/main/java/com/skyyo/samples/application/Destination.kt +++ b/app/src/main/java/com/skyyo/samples/application/Destination.kt @@ -26,6 +26,7 @@ sealed class Destination(val route: String) { object Table : Destination("table") object CustomView : Destination("customViewScreen") object MarqueeText: Destination("marqueeText") + object AutoComplete: Destination("autoComplete") object DogFeed : Destination("dogFeed") object DogDetails : Destination("dogDetails/{dogId}") { fun createRoute(dogId: String) = "dogDetails/$dogId" diff --git a/app/src/main/java/com/skyyo/samples/application/activity/PopulatedNavHost.kt b/app/src/main/java/com/skyyo/samples/application/activity/PopulatedNavHost.kt index 5a57c281..6390e00c 100644 --- a/app/src/main/java/com/skyyo/samples/application/activity/PopulatedNavHost.kt +++ b/app/src/main/java/com/skyyo/samples/application/activity/PopulatedNavHost.kt @@ -16,6 +16,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi import com.skyyo.samples.application.Destination import com.skyyo.samples.application.ProfileGraph import com.skyyo.samples.features.appBarElevation.AppBarElevation +import com.skyyo.samples.features.autoComplete.AutoCompleteScreen import com.skyyo.samples.features.autoscroll.AutoScrollScreen import com.skyyo.samples.features.bottomSheets.BottomSheetScaffoldScreen import com.skyyo.samples.features.bottomSheets.BottomSheetScreen @@ -123,6 +124,7 @@ fun PopulatedNavHost( composable(Destination.ParallaxEffect.route) { ParallaxEffectScreen() } composable(Destination.CustomView.route) { CustomViewScreen() } composable(Destination.MarqueeText.route) { MarqueeTextScreen() } + composable(Destination.AutoComplete.route) { AutoCompleteScreen() } navigation( route = ProfileGraph.route, startDestination = ProfileGraph.Profile.route 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 new file mode 100644 index 00000000..f04cf5e9 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt @@ -0,0 +1,39 @@ +package com.skyyo.samples.features.autoComplete + +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.google.android.material.textfield.TextInputLayout +import com.skyyo.samples.R + +@Composable +fun AndroidViewTextFieldWithDropDownSample( + items: List, + selectedValue: String?, + modifier: Modifier = Modifier, + onSelect: (Int) -> Unit = {} +) { + AndroidView( + factory = { context -> + val textInputLayout = + TextInputLayout.inflate(context, R.layout.text_input_field, null) as TextInputLayout + + val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView + val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, items) + autoCompleteTextView?.setAdapter(adapter) + autoCompleteTextView?.setText(selectedValue, false) + autoCompleteTextView?.setOnItemClickListener { _, _, index, _ -> onSelect(index) } + textInputLayout + }, + update = { textInputLayout -> + val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView + val adapter = + ArrayAdapter(textInputLayout.context, android.R.layout.simple_list_item_1, items) + autoCompleteTextView?.setAdapter(adapter) + autoCompleteTextView?.setText(selectedValue, false) + }, + modifier = modifier + ) +} 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 new file mode 100644 index 00000000..6be58c6e --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteScreen.kt @@ -0,0 +1,59 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.util.* + +fun provideCountries(): List { + val locales = Locale.getAvailableLocales() + val countries = ArrayList() + for (locale in locales) { + val country: String = locale.displayCountry + if (country.trim { it <= ' ' }.isNotEmpty() && !countries.contains(country)) { + countries.add(country) + } + } + countries.sort() + + return countries +} + +@Composable +fun AutoCompleteScreen() { + val initialList = remember { provideCountries() } + + Column( + Modifier + .fillMaxSize() + .padding(top = 32.dp, start = 16.dp, end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "Custom exposed dropdown menu") + CustomExposedDropdownMenuSample(modifier = Modifier.fillMaxWidth(), initialList = initialList) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "AndroidView") + AndroidViewTextFieldWithDropDownSample( + modifier = Modifier.fillMaxWidth(), + items = provideCountries(), + selectedValue = "", + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "Native exposed dropdown menu") + NativeExposedDropDownMenuSample(modifier = Modifier.fillMaxWidth(), initialList = initialList) + Spacer(modifier = Modifier.height(60.dp)) + Text(text = "Custom exposed dropdown menu") + CustomExposedDropdownMenuSample(modifier = Modifier.fillMaxWidth(), initialList = initialList) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "AndroidView") + AndroidViewTextFieldWithDropDownSample( + modifier = Modifier.fillMaxWidth(), + items = provideCountries(), + selectedValue = "", + ) + } +} 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 new file mode 100644 index 00000000..dad9bb1c --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenu.kt @@ -0,0 +1,783 @@ +package com.skyyo.samples.features.autoComplete + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Outline +import android.graphics.PixelFormat +import android.graphics.Rect +import android.view.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.* +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.* +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.popup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.* +import androidx.compose.ui.util.fastAll +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.window.PopupPositionProvider +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.lifecycle.ViewTreeViewModelStoreOwner +import androidx.savedstate.ViewTreeSavedStateRegistryOwner +import com.skyyo.samples.R +import kotlinx.coroutines.coroutineScope +import java.util.* +import kotlin.math.roundToInt + +private const val InTransitionDuration = 120 +private const val OutTransitionDuration = 75 +private val MenuElevation = 8.dp +private val DropdownMenuVerticalPadding = 8.dp + +@Composable +fun CustomDropdownMenuContent( + modifier: Modifier = Modifier, + scrollState: LazyListState, + expandedStates: MutableTransitionState, + transformOriginState: MutableState, + content: LazyListScope.() -> Unit +) { + val transition = updateTransition(expandedStates, "DropDownMenu") + val scale by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + tween( + durationMillis = InTransitionDuration, + easing = LinearOutSlowInEasing + ) + } else { + tween( + durationMillis = 1, + delayMillis = OutTransitionDuration - 1 + ) + } + }, + label = "FloatAnimation", + targetValueByState = { if (it) 1f else 0.8f } + ) + + val alpha by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + tween(durationMillis = 30) + } else { + tween(durationMillis = OutTransitionDuration) + } + }, + label = "FloatAnimation", + targetValueByState = { if (it) 1f else 0f } + ) + + Card( + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + transformOrigin = transformOriginState.value + }, + elevation = MenuElevation + ) { + LazyColumn( + modifier = modifier.padding(vertical = DropdownMenuVerticalPadding), + state = scrollState, + content = content, + ) + } +} + +interface CustomExposedDropdownMenuBoxScope { + fun Modifier.exposedDropdownSize( + matchTextFieldWidth: Boolean = true + ): Modifier + + fun scrollState(): LazyListState + + @Composable + fun CustomExposedDropdownMenu( + modifier: Modifier = Modifier, + scrollState: LazyListState = rememberLazyListState(), + expanded: Boolean, + onDismissRequest: () -> Unit, + content: LazyListScope.() -> Unit + ) { + val expandedStates = remember { MutableTransitionState(false) } + expandedStates.targetState = expanded + + if (expandedStates.currentState || expandedStates.targetState) { + val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } + val density = LocalDensity.current + val popupPositionProvider = CustomDropdownMenuPositionProvider( + DpOffset.Zero, + density + ) { parentBounds, menuBounds -> + transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds) + } + + CustomExposedDropdownMenuPopup( + onDismissRequest = onDismissRequest, + popupPositionProvider = popupPositionProvider + ) { + CustomDropdownMenuContent( + modifier = modifier.exposedDropdownSize(), + scrollState = scrollState(), + expandedStates = expandedStates, + transformOriginState = transformOriginState, + content = content + ) + } + } + } +} + +@Immutable +internal data class CustomDropdownMenuPositionProvider( + val contentOffset: DpOffset, + val density: Density, + val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> } +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + // 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 + + // Compute vertical position. + val toBottom = maxOf(anchorBounds.bottom + contentOffsetY) + val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height + val toCenter = anchorBounds.top - popupContentSize.height / 2 + val toDisplayBottom = windowSize.height - popupContentSize.height + val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull { + it + popupContentSize.height <= windowSize.height + } ?: toTop + + onPositionCalculated( + anchorBounds, + IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height) + ) + return IntOffset(x, y) + } +} + +internal fun calculateTransformOrigin( + parentBounds: IntRect, + menuBounds: IntRect +): TransformOrigin { + val pivotX = when { + menuBounds.left >= parentBounds.right -> 0f + menuBounds.right <= parentBounds.left -> 1f + menuBounds.width == 0 -> 0f + else -> { + val intersectionCenter = (kotlin.math.max( + parentBounds.left, + menuBounds.left + ) + kotlin.math.min(parentBounds.right, menuBounds.right)) / 2 + (intersectionCenter - menuBounds.left).toFloat() / menuBounds.width + } + } + val pivotY = when { + menuBounds.top >= parentBounds.bottom -> 0f + menuBounds.bottom <= parentBounds.top -> 1f + menuBounds.height == 0 -> 0f + else -> { + val intersectionCenter = (kotlin.math.max( + parentBounds.top, + menuBounds.top + ) + kotlin.math.min(parentBounds.bottom, menuBounds.bottom)) / 2 + (intersectionCenter - menuBounds.top).toFloat() / menuBounds.height + } + } + return TransformOrigin(pivotX, pivotY) +} + +@ExperimentalMaterialApi +@Composable +fun CustomExposedDropdownMenuBox( + modifier: Modifier = Modifier, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + content: @Composable CustomExposedDropdownMenuBoxScope.() -> Unit, +) { + val density = LocalDensity.current + val view = LocalView.current + var width by remember { mutableStateOf(0) } + var menuHeight by remember { mutableStateOf(0) } + val coordinates = remember { Ref() } + var hasFocus by remember { mutableStateOf(false) } + val scrollState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + + val scope = remember(density, menuHeight, width) { + object : CustomExposedDropdownMenuBoxScope { + override fun Modifier.exposedDropdownSize(matchTextFieldWidth: Boolean): Modifier { + return with(density) { + heightIn(max = menuHeight.toDp()).let { + if (matchTextFieldWidth) { + it.width(width.toDp()) + } else it + } + } + } + override fun scrollState(): LazyListState = scrollState + } + } + + Box( + modifier + .onGloballyPositioned { + width = it.size.width + coordinates.value = it + updateHeight( + view.rootView, + coordinates.value + ) { newHeight -> + menuHeight = newHeight + } + } + .expandable( + onExpandedChange = { onExpandedChange(!expanded) }, + ) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + hasFocus = focusState.isFocused + } + ) { + scope.content() + } + + SideEffect { + if (expanded) focusRequester.requestFocus() + } + + LaunchedEffect(hasFocus) { + if (!hasFocus) scrollState.scrollToItem(0) + } + + DisposableEffect(view) { + val listener = OnGlobalLayoutListener(view) { + // We want to recalculate the menu height on relayout - e.g. when keyboard shows up. + updateHeight( + view.rootView, + coordinates.value + ) { newHeight -> + menuHeight = newHeight + } + } + onDispose { listener.dispose() } + } +} + +private fun updateHeight( + view: View, + coordinates: LayoutCoordinates?, + onHeightUpdate: (Int) -> Unit +) { + + coordinates ?: return + val visibleWindowBounds = Rect().let { + view.getWindowVisibleDisplayFrame(it) + it + } + + val heightAbove = coordinates.boundsInWindow().top - visibleWindowBounds.top + val heightBelow = visibleWindowBounds.bottom - visibleWindowBounds.top - coordinates.boundsInWindow().bottom + + onHeightUpdate(kotlin.math.max(heightAbove, heightBelow).toInt()) +} + +private fun Modifier.expandable( + onExpandedChange: () -> Unit +) = pointerInput(Unit) { + forEachGesture { + coroutineScope { + awaitPointerEventScope { + var event: PointerEvent + do { + event = awaitPointerEvent(PointerEventPass.Initial) + } while ( + !event.changes.fastAll { it.changedToUp() } + ) + onExpandedChange.invoke() + } + } + } +}.semantics { + onClick { + onExpandedChange.invoke() + true + } +} + +private class OnGlobalLayoutListener( + private val view: View, + private val onGlobalLayoutCallback: () -> Unit +) : View.OnAttachStateChangeListener, ViewTreeObserver.OnGlobalLayoutListener { + private var isListeningToGlobalLayout = false + + init { + view.addOnAttachStateChangeListener(this) + registerOnGlobalLayoutListener() + } + + override fun onViewAttachedToWindow(p0: View?) = registerOnGlobalLayoutListener() + + override fun onViewDetachedFromWindow(p0: View?) = unregisterOnGlobalLayoutListener() + + override fun onGlobalLayout() = onGlobalLayoutCallback() + + private fun registerOnGlobalLayoutListener() { + if (isListeningToGlobalLayout || !view.isAttachedToWindow) return + view.viewTreeObserver.addOnGlobalLayoutListener(this) + isListeningToGlobalLayout = true + } + + private fun unregisterOnGlobalLayoutListener() { + if (!isListeningToGlobalLayout) return + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + isListeningToGlobalLayout = false + } + + fun dispose() { + unregisterOnGlobalLayoutListener() + view.removeOnAttachStateChangeListener(this) + } +} + +internal val LocalPopupTestTag = compositionLocalOf { "DEFAULT_TEST_TAG" } + +@Suppress("NOTHING_TO_INLINE") +@Composable +private inline fun SimpleStack(modifier: Modifier, noinline content: @Composable () -> Unit) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + when (measurables.size) { + 0 -> layout(0, 0) {} + 1 -> { + val p = measurables[0].measure(constraints) + layout(p.width, p.height) { + p.placeRelative(0, 0) + } + } + else -> { + val placeables = measurables.fastMap { it.measure(constraints) } + var width = 0 + var height = 0 + for (i in 0..placeables.lastIndex) { + val p = placeables[i] + width = maxOf(width, p.width) + height = maxOf(height, p.height) + } + layout(width, height) { + for (i in 0..placeables.lastIndex) { + val p = placeables[i] + p.placeRelative(0, 0) + } + } + } + } + } +} + +@Composable +internal fun CustomExposedDropdownMenuPopup( + modifier: Modifier = Modifier, + onDismissRequest: (() -> Unit)? = null, + popupPositionProvider: PopupPositionProvider, + content: @Composable () -> Unit +) { + val view = LocalView.current + val density = LocalDensity.current + val testTag = LocalPopupTestTag.current + val layoutDirection = LocalLayoutDirection.current + val parentComposition = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + val popupId = rememberSaveable { UUID.randomUUID() } + val popupLayout = remember { + PopupLayout( + onDismissRequest = onDismissRequest, + testTag = testTag, + composeView = view, + density = density, + initialPositionProvider = popupPositionProvider, + popupId = popupId + ).apply { + setContent(parentComposition) { + SimpleStack( + modifier + .semantics { this.popup() } + // Get the size of the content + .onSizeChanged { + popupContentSize = it + updatePosition() + } + // Hide the popup while we can't position it correctly + .alpha(if (canCalculatePosition) 1f else 0f) + ) { + currentContent() + } + } + } + } + + DisposableEffect(popupLayout) { + popupLayout.show() + popupLayout.updateParameters( + onDismissRequest = onDismissRequest, + testTag = testTag, + layoutDirection = layoutDirection + ) + onDispose { + popupLayout.disposeComposition() + // Remove the window + popupLayout.dismiss() + } + } + + SideEffect { + popupLayout.updateParameters( + onDismissRequest = onDismissRequest, + testTag = testTag, + layoutDirection = layoutDirection + ) + } + + DisposableEffect(popupPositionProvider) { + popupLayout.positionProvider = popupPositionProvider + popupLayout.updatePosition() + onDispose {} + } + + // Look at module arrangement so that Box can be + // used instead of this custom Layout + // Get the parent's position, size and layout direction + Layout( + content = {}, + modifier = Modifier.onGloballyPositioned { childCoordinates -> + val coordinates = childCoordinates.parentLayoutCoordinates!! + val layoutSize = coordinates.size + val position = coordinates.positionInWindow() + val layoutPosition = IntOffset(position.x.roundToInt(), position.y.roundToInt()) + + popupLayout.parentBounds = IntRect(layoutPosition, layoutSize) + // Update the popup's position + popupLayout.updatePosition() + } + ) { _, _ -> + popupLayout.parentLayoutDirection = layoutDirection + layout(0, 0) {} + } +} + +@SuppressLint("ViewConstructor") +private class PopupLayout( + private var onDismissRequest: (() -> Unit)?, + var testTag: String, + private val composeView: View, + density: Density, + initialPositionProvider: PopupPositionProvider, + popupId: UUID +) : AbstractComposeView(composeView.context), + ViewRootForInspector, + ViewTreeObserver.OnGlobalLayoutListener { + private val windowManager = + composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private val params = createLayoutParams() + + /** The logic of positioning the popup relative to its parent. */ + var positionProvider = initialPositionProvider + + // Position params + var parentLayoutDirection: LayoutDirection = LayoutDirection.Ltr + var parentBounds: IntRect? by mutableStateOf(null) + var popupContentSize: IntSize? by mutableStateOf(null) + + // Track parent bounds and content size; only show popup once we have both + val canCalculatePosition by derivedStateOf { parentBounds != null && popupContentSize != null } + + private val maxSupportedElevation = 30.dp + + // The window visible frame used for the last popup position calculation. + private val previousWindowVisibleFrame = Rect() + private val tmpWindowVisibleFrame = Rect() + + override val subCompositionView: AbstractComposeView get() = this + + // Specific to exposed dropdown menus. + private val dismissOnOutsideClick = { offset: Offset?, bounds: IntRect -> + if (offset == null) false + else { + offset.x < bounds.left || offset.x > bounds.right || offset.y < bounds.top || offset.y > bounds.bottom + } + } + + init { + id = android.R.id.content + ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(composeView)) + ViewTreeViewModelStoreOwner.set(this, ViewTreeViewModelStoreOwner.get(composeView)) + ViewTreeSavedStateRegistryOwner.set(this, ViewTreeSavedStateRegistryOwner.get(composeView)) + composeView.viewTreeObserver.addOnGlobalLayoutListener(this) + // Set unique id for AbstractComposeView. This allows state restoration for the state + // defined inside the Popup via rememberSaveable() + setTag(R.id.compose_view_saveable_id_tag, "Popup:$popupId") + + // Enable children to draw their shadow by not clipping them + clipChildren = false + // Allocate space for elevation + with(density) { elevation = maxSupportedElevation.toPx() } + // Simple outline to force window manager to allocate space for shadow. + // Note that the outline affects clickable area for the dismiss listener. In case of shapes + // like circle the area for dismiss might be to small (rectangular outline consuming clicks + // outside of the circle). + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, result: Outline) { + result.setRect(0, 0, view.width, view.height) + // We set alpha to 0 to hide the view's shadow and let the composable to draw its + // own shadow. This still enables us to get the extra space needed in the surface. + result.alpha = 0f + } + } + } + + private var content: @Composable () -> Unit by mutableStateOf({}) + + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false + private set + + fun show() { + windowManager.addView(this, params) + } + + fun setContent(parent: CompositionContext, content: @Composable () -> Unit) { + setParentCompositionContext(parent) + this.content = content + shouldCreateCompositionOnAttachedToWindow = true + } + + @Composable + override fun Content() { + content() + } + + /** + * Taken from PopupWindow + */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + if (keyDispatcherState == null) { + return super.dispatchKeyEvent(event) + } + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + val state = keyDispatcherState + state?.startTracking(event, this) + return true + } else if (event.action == KeyEvent.ACTION_UP) { + val state = keyDispatcherState + if (state != null && state.isTracking(event) && !event.isCanceled) { + onDismissRequest?.invoke() + return true + } + } + } + return super.dispatchKeyEvent(event) + } + + fun updateParameters( + onDismissRequest: (() -> Unit)?, + testTag: String, + layoutDirection: LayoutDirection + ) { + this.onDismissRequest = onDismissRequest + this.testTag = testTag + superSetLayoutDirection(layoutDirection) + } + + /** + * Updates the position of the popup based on current position properties. + */ + fun updatePosition() { + val parentBounds = parentBounds ?: return + val popupContentSize = popupContentSize ?: return + + val windowSize = previousWindowVisibleFrame.let { + composeView.getWindowVisibleDisplayFrame(it) + val bounds = it.toIntBounds() + IntSize(width = bounds.width, height = bounds.height) + } + + val popupPosition = positionProvider.calculatePosition( + parentBounds, + windowSize, + parentLayoutDirection, + popupContentSize + ) + + params.width = parentBounds.width + params.x = popupPosition.x + params.y = popupPosition.y + + windowManager.updateViewLayout(this, params) + } + + /** + * Remove the view from the [WindowManager]. + */ + fun dismiss() { + ViewTreeLifecycleOwner.set(this, null) + composeView.viewTreeObserver.removeOnGlobalLayoutListener(this) + windowManager.removeViewImmediate(this) + } + + /** + * Handles touch screen motion events and calls [onDismissRequest] when the + * users clicks outside the popup. + */ + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + event ?: return super.onTouchEvent(event) + + // Note that this implementation is taken from PopupWindow. It actually does not seem to + // matter whether we return true or false as some upper layer decides on whether the + // event is propagated to other windows or not. So for focusable the event is consumed but + // for not focusable it is propagated to other windows. + if (((event.action == MotionEvent.ACTION_DOWN) && + ( + (event.x < 0) || + (event.x >= width) || + (event.y < 0) || + (event.y >= height) + ) + ) || + event.action == MotionEvent.ACTION_OUTSIDE + ) { + val parentBounds = parentBounds + val shouldDismiss = parentBounds == null || dismissOnOutsideClick( + if (event.x != 0f || event.y != 0f) { + Offset( + params.x + event.x, + params.y + event.y + ) + } else null, + parentBounds + ) + if (shouldDismiss) { + onDismissRequest?.invoke() + return true + } + } + return super.onTouchEvent(event) + } + + override fun setLayoutDirection(layoutDirection: Int) { + // Do nothing. ViewRootImpl will call this method attempting to set the layout direction + // from the context's locale, but we have one already from the parent composition. + } + + // Sets the "real" layout direction for our content that we obtain from the parent composition. + private fun superSetLayoutDirection(layoutDirection: LayoutDirection) { + val direction = when (layoutDirection) { + LayoutDirection.Ltr -> android.util.LayoutDirection.LTR + LayoutDirection.Rtl -> android.util.LayoutDirection.RTL + } + super.setLayoutDirection(direction) + } + + /** + * Initialize the LayoutParams specific to [android.widget.PopupWindow]. + */ + private fun createLayoutParams(): WindowManager.LayoutParams { + return WindowManager.LayoutParams().apply { + // Start to position the popup in the top left corner, a new position will be calculated + gravity = Gravity.START or Gravity.TOP + + // Flags specific to exposed dropdown menu. + flags = WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM + softInputMode = WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED + + type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL + + // Get the Window token from the parent view + token = composeView.applicationWindowToken + + // Wrap the frame layout which contains composable content + width = WindowManager.LayoutParams.WRAP_CONTENT + height = WindowManager.LayoutParams.WRAP_CONTENT + + format = PixelFormat.TRANSLUCENT + + // accessibilityTitle is not exposed as a public API therefore we set popup window + // title which is used as a fallback by a11y services + title = "title" + } + } + + private fun Rect.toIntBounds() = IntRect( + left = left, + top = top, + right = right, + bottom = bottom + ) + + override fun onGlobalLayout() { + // Update the position of the popup, in case getWindowVisibleDisplayFrame has changed. + composeView.getWindowVisibleDisplayFrame(tmpWindowVisibleFrame) + if (tmpWindowVisibleFrame != previousWindowVisibleFrame) { + updatePosition() + } + } +} 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 new file mode 100644 index 00000000..991be434 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenuSample.kt @@ -0,0 +1,87 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CustomExposedDropdownMenuSample(modifier: Modifier = Modifier, initialList: List) { + var expanded by remember { mutableStateOf(false) } + var query by remember { mutableStateOf(TextFieldValue("")) } + var options by remember { mutableStateOf(initialList) } + + CustomExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { + expanded = !expanded + }, + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = query, + onValueChange = { textFieldValue -> + if (textFieldValue.text == query.text) { + query = textFieldValue + } else { + query = textFieldValue + options = if (textFieldValue.text.isEmpty()) { + initialList.also { expanded = false } + } else { + val filteredList = initialList.filter { country -> + country + .lowercase() + .startsWith(query.text.lowercase()) && country != query.text + } + filteredList.also { expanded = true } + } + } + }, + label = { Text("Label") }, + trailingIcon = { + IconButton(onClick = {}, modifier = Modifier.clearAndSetSemantics { }) { + Icon( + modifier = Modifier.rotate(if (expanded) 180f else 360f), + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null + ) + } + }, + ) + + if (options.isNotEmpty()) { + CustomExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + items(options) { option -> + key(option) { + DropdownMenuItem( + onClick = { + query = TextFieldValue( + text = option, + selection = TextRange(option.length) + ) + expanded = false + } + ) { + Text(text = option) + } + } + } + } + } + } +} 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 new file mode 100644 index 00000000..4d1595c5 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt @@ -0,0 +1,58 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun NativeExposedDropDownMenuSample(modifier: Modifier = Modifier, initialList: List) { + var expanded by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + var options by remember { mutableStateOf(initialList) } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { + expanded = !expanded + } + ) { + TextField( + modifier = Modifier.fillMaxWidth(), + value = query, + onValueChange = { + query = it + options = if (it.isEmpty()) initialList + else { + val filteredList = initialList.filter { + it.lowercase().startsWith(query.lowercase()) && it != query + } + filteredList + } + }, + label = { Text("Label") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { selectionOption -> + DropdownMenuItem( + onClick = { + query = selectionOption + expanded = false + } + ) { + Text(text = selectionOption) + } + } + } + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerScreen.kt b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerScreen.kt index 93b5f829..8e641eb9 100644 --- a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerScreen.kt +++ b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerScreen.kt @@ -131,6 +131,11 @@ fun UIelements(viewModel: SampleContainerViewModel) { modifier = Modifier.fillMaxWidth(), onClick = viewModel::goMarqueeText ) { Text(text = "marquee text") } + Button( + modifier = Modifier.fillMaxWidth(), + onClick = viewModel::goAutoComplete + ) { Text(text = "autoComplete") } + Spacer(modifier = Modifier.height(16.dp)) } diff --git a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt index c47f5756..edb4e40d 100644 --- a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt +++ b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt @@ -113,6 +113,10 @@ class SampleContainerViewModel @Inject constructor( it.navigate(Destination.MarqueeText.route) } + fun goAutoComplete() = navigationDispatcher.emit { + it.navigate(Destination.AutoComplete.route) + } + fun goHiltComposeSharedViewModel() = navigationDispatcher.emit { it.navigate(ProfileGraph.Profile.route) } diff --git a/app/src/main/res/layout/text_input_field.xml b/app/src/main/res/layout/text_input_field.xml new file mode 100644 index 00000000..254a5fa3 --- /dev/null +++ b/app/src/main/res/layout/text_input_field.xml @@ -0,0 +1,14 @@ + + + + + + From 5da6470efc68ed68a46b6bc6a1da23b0f70b6e5c Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Wed, 18 May 2022 13:12:44 +0300 Subject: [PATCH 02/11] refactor autoCompleteScreen --- .../features/autoComplete/AutoCompleteScreen.kt | 4 ++-- .../CustomExposedDropdownMenuSample.kt | 15 +-------------- 2 files changed, 3 insertions(+), 16 deletions(-) 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 6be58c6e..26703289 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 @@ -47,11 +47,11 @@ fun AutoCompleteScreen() { NativeExposedDropDownMenuSample(modifier = Modifier.fillMaxWidth(), initialList = initialList) Spacer(modifier = Modifier.height(60.dp)) Text(text = "Custom exposed dropdown menu") - CustomExposedDropdownMenuSample(modifier = Modifier.fillMaxWidth(), initialList = initialList) + CustomExposedDropdownMenuSample(modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), initialList = initialList) Spacer(modifier = Modifier.height(16.dp)) Text(text = "AndroidView") AndroidViewTextFieldWithDropDownSample( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), items = provideCountries(), selectedValue = "", ) 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 991be434..07f3d6a9 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 @@ -2,16 +2,9 @@ package com.skyyo.samples.features.autoComplete import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -51,13 +44,7 @@ fun CustomExposedDropdownMenuSample(modifier: Modifier = Modifier, initialList: }, label = { Text("Label") }, trailingIcon = { - IconButton(onClick = {}, modifier = Modifier.clearAndSetSemantics { }) { - Icon( - modifier = Modifier.rotate(if (expanded) 180f else 360f), - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = null - ) - } + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, ) From 3eac721ee40de86cc23097ebcbacfa4274f782ea Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Mon, 23 May 2022 21:17:48 +0300 Subject: [PATCH 03/11] move logic into viewModel --- .../AndroidViewTextFieldWithDropDownSample.kt | 6 +- .../autoComplete/AutoCompleteScreen.kt | 54 ++++++++------- .../autoComplete/AutoCompleteViewModel.kt | 65 +++++++++++++++++++ .../autoComplete/CustomExposedDropdownMenu.kt | 12 ++-- .../CustomExposedDropdownMenuSample.kt | 61 ++++++++--------- .../NativeExposedDropDownMenuSample.kt | 12 ++-- 6 files changed, 139 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteViewModel.kt 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 index f04cf5e9..237590fe 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt @@ -10,7 +10,7 @@ import com.skyyo.samples.R @Composable fun AndroidViewTextFieldWithDropDownSample( - items: List, + suggestions: List, selectedValue: String?, modifier: Modifier = Modifier, onSelect: (Int) -> Unit = {} @@ -21,7 +21,7 @@ fun AndroidViewTextFieldWithDropDownSample( TextInputLayout.inflate(context, R.layout.text_input_field, null) as TextInputLayout val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView - val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, items) + val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, suggestions) autoCompleteTextView?.setAdapter(adapter) autoCompleteTextView?.setText(selectedValue, false) autoCompleteTextView?.setOnItemClickListener { _, _, index, _ -> onSelect(index) } @@ -30,7 +30,7 @@ fun AndroidViewTextFieldWithDropDownSample( update = { textInputLayout -> val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView val adapter = - ArrayAdapter(textInputLayout.context, android.R.layout.simple_list_item_1, items) + ArrayAdapter(textInputLayout.context, android.R.layout.simple_list_item_1, suggestions) autoCompleteTextView?.setAdapter(adapter) autoCompleteTextView?.setText(selectedValue, false) }, 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 26703289..c32d301f 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 @@ -3,29 +3,19 @@ package com.skyyo.samples.features.autoComplete import androidx.compose.foundation.layout.* import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import java.util.* - -fun provideCountries(): List { - val locales = Locale.getAvailableLocales() - val countries = ArrayList() - for (locale in locales) { - val country: String = locale.displayCountry - if (country.trim { it <= ' ' }.isNotEmpty() && !countries.contains(country)) { - countries.add(country) - } - } - countries.sort() - - return countries -} +import androidx.hilt.navigation.compose.hiltViewModel @Composable -fun AutoCompleteScreen() { - val initialList = remember { provideCountries() } +fun AutoCompleteScreen( + viewModel: AutoCompleteViewModel = hiltViewModel() +) { + val query = viewModel.query.collectAsState() + val isExpanded = viewModel.isExpanded.collectAsState() + val suggestions = viewModel.suggestions.collectAsState() Column( Modifier @@ -33,26 +23,40 @@ fun AutoCompleteScreen() { .padding(top = 32.dp, start = 16.dp, end = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = "Custom exposed dropdown menu") - CustomExposedDropdownMenuSample(modifier = Modifier.fillMaxWidth(), initialList = initialList) Spacer(modifier = Modifier.height(16.dp)) Text(text = "AndroidView") AndroidViewTextFieldWithDropDownSample( modifier = Modifier.fillMaxWidth(), - items = provideCountries(), + suggestions = viewModel.countries, selectedValue = "", ) Spacer(modifier = Modifier.height(16.dp)) Text(text = "Native exposed dropdown menu") - NativeExposedDropDownMenuSample(modifier = Modifier.fillMaxWidth(), initialList = initialList) + NativeExposedDropDownMenuSample( + modifier = Modifier.fillMaxWidth(), + countries = viewModel.countries + ) Spacer(modifier = Modifier.height(60.dp)) Text(text = "Custom exposed dropdown menu") - CustomExposedDropdownMenuSample(modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), initialList = initialList) + CustomExposedDropdownMenuSample( + 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, + ) Spacer(modifier = Modifier.height(16.dp)) Text(text = "AndroidView") AndroidViewTextFieldWithDropDownSample( - modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), - items = provideCountries(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + suggestions = viewModel.countries, selectedValue = "", ) } 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 new file mode 100644 index 00000000..cbb9e5fb --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteViewModel.kt @@ -0,0 +1,65 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.skyyo.samples.extensions.getStateFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.* +import javax.inject.Inject + +@HiltViewModel +class AutoCompleteViewModel @Inject constructor( + handle: SavedStateHandle +) : ViewModel() { + + val countries = provideCountries() + val query = handle.getStateFlow(viewModelScope, "query", "") + val suggestions = handle.getStateFlow(viewModelScope, "suggestions", countries) + val isExpanded = handle.getStateFlow(viewModelScope, "isExpanded", false) + + fun onCountryEntered(input: String) { + query.value = input + + viewModelScope.launch(Dispatchers.Default) { + suggestions.value = if (input.isEmpty()) { + countries.also { onExpandedChange(false) } + } else { + val filteredList = countries.filter { country -> + country + .lowercase() + .startsWith(input.lowercase()) && country != input + } + filteredList.also { onExpandedChange(true) } + } + } + } + + fun onExpandedChange(value: Boolean) { + isExpanded.value = value + } + + fun onCountrySelected(value: String) { + query.value = value + } + + fun onExpandedFieldClick() { + onExpandedChange(!isExpanded.value) + } + + private fun provideCountries(): List { + val locales = Locale.getAvailableLocales() + val countries = ArrayList() + for (locale in locales) { + val country: String = locale.displayCountry + if (country.trim { it <= ' ' }.isNotEmpty() && !countries.contains(country)) { + countries.add(country) + } + } + countries.sort() + + return countries + } +} 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 dad9bb1c..7616e4a4 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 @@ -249,7 +249,7 @@ internal fun calculateTransformOrigin( fun CustomExposedDropdownMenuBox( modifier: Modifier = Modifier, expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, + onClick: () -> Unit, content: @Composable CustomExposedDropdownMenuBoxScope.() -> Unit, ) { val density = LocalDensity.current @@ -288,9 +288,7 @@ fun CustomExposedDropdownMenuBox( menuHeight = newHeight } } - .expandable( - onExpandedChange = { onExpandedChange(!expanded) }, - ) + .expandable(onClick = onClick) .focusRequester(focusRequester) .onFocusChanged { focusState -> hasFocus = focusState.isFocused @@ -340,7 +338,7 @@ private fun updateHeight( } private fun Modifier.expandable( - onExpandedChange: () -> Unit + onClick: () -> Unit ) = pointerInput(Unit) { forEachGesture { coroutineScope { @@ -351,13 +349,13 @@ private fun Modifier.expandable( } while ( !event.changes.fastAll { it.changedToUp() } ) - onExpandedChange.invoke() + onClick.invoke() } } } }.semantics { onClick { - onExpandedChange.invoke() + onClick.invoke() true } } 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 07f3d6a9..6f64e400 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 @@ -3,44 +3,44 @@ package com.skyyo.samples.features.autoComplete import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.items import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.skyyo.samples.utils.OnClick +import com.skyyo.samples.utils.OnValueChange @OptIn(ExperimentalMaterialApi::class) @Composable -fun CustomExposedDropdownMenuSample(modifier: Modifier = Modifier, initialList: List) { - var expanded by remember { mutableStateOf(false) } - var query by remember { mutableStateOf(TextFieldValue("")) } - var options by remember { mutableStateOf(initialList) } +fun CustomExposedDropdownMenuSample( + modifier: Modifier = Modifier, + onExpandedChange: (Boolean) -> Unit, + onSuggestionSelected: (String) -> Unit, + onClick: OnClick, + onValueChange: OnValueChange, + suggestions: List, + expanded: Boolean, + query: String +) { + + val textFieldValue = remember { + mutableStateOf(TextFieldValue(query, TextRange(query.length))) + } CustomExposedDropdownMenuBox( modifier = modifier, expanded = expanded, - onExpandedChange = { - expanded = !expanded - }, + onClick = onClick, ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), - value = query, - onValueChange = { textFieldValue -> - if (textFieldValue.text == query.text) { - query = textFieldValue - } else { - query = textFieldValue - options = if (textFieldValue.text.isEmpty()) { - initialList.also { expanded = false } - } else { - val filteredList = initialList.filter { country -> - country - .lowercase() - .startsWith(query.text.lowercase()) && country != query.text - } - filteredList.also { expanded = true } - } - } + value = textFieldValue.value, + onValueChange = { value -> + textFieldValue.value = value + if (value.text != query) onValueChange.invoke(value.text) }, label = { Text("Label") }, trailingIcon = { @@ -48,20 +48,21 @@ fun CustomExposedDropdownMenuSample(modifier: Modifier = Modifier, initialList: }, ) - if (options.isNotEmpty()) { + if (suggestions.isNotEmpty() && expanded) { CustomExposedDropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false } + onDismissRequest = { onExpandedChange.invoke(false) } ) { - items(options) { option -> + items(suggestions) { option -> key(option) { DropdownMenuItem( onClick = { - query = TextFieldValue( + textFieldValue.value = TextFieldValue( text = option, selection = TextRange(option.length) ) - expanded = false + onSuggestionSelected.invoke(option) + onExpandedChange.invoke(false) } ) { Text(text = option) 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 index 4d1595c5..f298cfc3 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt @@ -7,10 +7,10 @@ import androidx.compose.ui.Modifier @OptIn(ExperimentalMaterialApi::class) @Composable -fun NativeExposedDropDownMenuSample(modifier: Modifier = Modifier, initialList: List) { +fun NativeExposedDropDownMenuSample(modifier: Modifier = Modifier, countries: List) { var expanded by remember { mutableStateOf(false) } var query by remember { mutableStateOf("") } - var options by remember { mutableStateOf(initialList) } + var suggestions by remember { mutableStateOf(countries) } ExposedDropdownMenuBox( modifier = modifier, @@ -24,10 +24,10 @@ fun NativeExposedDropDownMenuSample(modifier: Modifier = Modifier, initialList: value = query, onValueChange = { query = it - options = if (it.isEmpty()) initialList + suggestions = if (it.isEmpty()) countries else { - val filteredList = initialList.filter { - it.lowercase().startsWith(query.lowercase()) && it != query + val filteredList = countries.filter { country -> + country.lowercase().startsWith(query.lowercase()) && country != query } filteredList } @@ -43,7 +43,7 @@ fun NativeExposedDropDownMenuSample(modifier: Modifier = Modifier, initialList: expanded = expanded, onDismissRequest = { expanded = false } ) { - options.forEach { selectionOption -> + suggestions.forEach { selectionOption -> DropdownMenuItem( onClick = { query = selectionOption From a262a396db272a1c1a26e8c4f7c9048faa113b64 Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Tue, 14 Jun 2022 10:35:36 +0300 Subject: [PATCH 04/11] cleanup androidView autocomplete example --- .../AndroidViewTextFieldWithDropDownSample.kt | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) 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 index 237590fe..5ce3cfc8 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt @@ -3,37 +3,38 @@ 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 AndroidViewTextFieldWithDropDownSample( - suggestions: List, - selectedValue: String?, modifier: Modifier = Modifier, - onSelect: (Int) -> Unit = {} + suggestions: List, + selectedValue: String = "", + onSelect: (Int) -> Unit = {}, ) { - AndroidView( - factory = { context -> - val textInputLayout = - TextInputLayout.inflate(context, R.layout.text_input_field, null) as TextInputLayout + val context = LocalContext.current + val adapter = remember { + ArrayAdapter(context, android.R.layout.simple_list_item_1, suggestions) + } + val textInputLayout = remember { + TextInputLayout.inflate(context, R.layout.text_input_field, null) as TextInputLayout + } + val autoCompleteTextView = remember { textInputLayout.editText as? AutoCompleteTextView } - val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView - val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, suggestions) - autoCompleteTextView?.setAdapter(adapter) - autoCompleteTextView?.setText(selectedValue, false) - autoCompleteTextView?.setOnItemClickListener { _, _, index, _ -> onSelect(index) } + AndroidView( + factory = { + autoCompleteTextView?.apply { + setAdapter(adapter) + setText(selectedValue) + setOnItemClickListener { _, _, index, _ -> onSelect(index) } + } textInputLayout }, - update = { textInputLayout -> - val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView - val adapter = - ArrayAdapter(textInputLayout.context, android.R.layout.simple_list_item_1, suggestions) - autoCompleteTextView?.setAdapter(adapter) - autoCompleteTextView?.setText(selectedValue, false) - }, modifier = modifier ) } From b4607c27bc90dcb6c1e84dcaefb6ab5204b131df Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Tue, 14 Jun 2022 12:19:05 +0300 Subject: [PATCH 05/11] update data if initial suggestions changed --- .../AndroidViewTextFieldWithDropDownSample.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 5ce3cfc8..98cb67cf 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt @@ -18,7 +18,7 @@ fun AndroidViewTextFieldWithDropDownSample( onSelect: (Int) -> Unit = {}, ) { val context = LocalContext.current - val adapter = remember { + val adapter = remember(suggestions) { ArrayAdapter(context, android.R.layout.simple_list_item_1, suggestions) } val textInputLayout = remember { @@ -27,6 +27,7 @@ fun AndroidViewTextFieldWithDropDownSample( val autoCompleteTextView = remember { textInputLayout.editText as? AutoCompleteTextView } AndroidView( + modifier = modifier, factory = { autoCompleteTextView?.apply { setAdapter(adapter) @@ -35,6 +36,9 @@ fun AndroidViewTextFieldWithDropDownSample( } textInputLayout }, - modifier = modifier + update = { + autoCompleteTextView?.setAdapter(adapter) + autoCompleteTextView?.setText(selectedValue, false) + }, ) } From 3952bd9e48b9189271f2589ffa62080ab5db4e82 Mon Sep 17 00:00:00 2001 From: skyyo Date: Tue, 14 Jun 2022 20:46:04 +0300 Subject: [PATCH 06/11] AndroidViewTextFieldWithDropDownSample adjustments --- .../AndroidViewTextFieldWithDropDownSample.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) 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 index 98cb67cf..a4f5f100 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt @@ -22,20 +22,21 @@ fun AndroidViewTextFieldWithDropDownSample( ArrayAdapter(context, android.R.layout.simple_list_item_1, suggestions) } val textInputLayout = remember { - TextInputLayout.inflate(context, R.layout.text_input_field, null) as TextInputLayout + (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 = { - autoCompleteTextView?.apply { - setAdapter(adapter) - setText(selectedValue) - setOnItemClickListener { _, _, index, _ -> onSelect(index) } - } - textInputLayout - }, + factory = { textInputLayout }, update = { autoCompleteTextView?.setAdapter(adapter) autoCompleteTextView?.setText(selectedValue, false) From 584d338626eeb1b684108f84fff16731d906fd9c Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Tue, 19 Jul 2022 13:15:01 +0300 Subject: [PATCH 07/11] refactor native autocomplete & fix naming --- .../AndroidViewTextFieldWithDropDownSample.kt | 8 +-- .../autoComplete/AutoCompleteScreen.kt | 10 +-- .../autoComplete/AutoCompleteViewModel.kt | 21 ++++--- .../autoComplete/CustomExposedDropdownMenu.kt | 5 +- .../CustomExposedDropdownMenuSample.kt | 29 ++++++++- .../NativeExposedDropDownMenuSample.kt | 62 ++++++++++++++++--- 6 files changed, 102 insertions(+), 33 deletions(-) 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 index a4f5f100..fd8c663b 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt @@ -11,7 +11,7 @@ import com.google.android.material.textfield.TextInputLayout import com.skyyo.samples.R @Composable -fun AndroidViewTextFieldWithDropDownSample( +fun AndroidViewAutocompleteDropdownWithOutsideFiltering( modifier: Modifier = Modifier, suggestions: List, selectedValue: String = "", @@ -32,14 +32,14 @@ fun AndroidViewTextFieldWithDropDownSample( } } } - val autoCompleteTextView = remember { textInputLayout.editText as? AutoCompleteTextView } + val autoCompleteTextView = remember { textInputLayout.editText as AutoCompleteTextView } AndroidView( modifier = modifier, factory = { textInputLayout }, update = { - autoCompleteTextView?.setAdapter(adapter) - autoCompleteTextView?.setText(selectedValue, false) + autoCompleteTextView.setAdapter(adapter) + autoCompleteTextView.setText(selectedValue, false) }, ) } 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 c32d301f..91e622fc 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 @@ -25,20 +25,20 @@ fun AutoCompleteScreen( ) { Spacer(modifier = Modifier.height(16.dp)) Text(text = "AndroidView") - AndroidViewTextFieldWithDropDownSample( + AndroidViewAutocompleteDropdownWithOutsideFiltering( modifier = Modifier.fillMaxWidth(), suggestions = viewModel.countries, selectedValue = "", ) Spacer(modifier = Modifier.height(16.dp)) Text(text = "Native exposed dropdown menu") - NativeExposedDropDownMenuSample( + AutocompleteDropdownWithFilteringInside( modifier = Modifier.fillMaxWidth(), - countries = viewModel.countries + countries = viewModel.countries, ) Spacer(modifier = Modifier.height(60.dp)) Text(text = "Custom exposed dropdown menu") - CustomExposedDropdownMenuSample( + AutocompleteDropdownWithOutsideFiltering( modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp), @@ -52,7 +52,7 @@ fun AutoCompleteScreen( ) Spacer(modifier = Modifier.height(16.dp)) Text(text = "AndroidView") - AndroidViewTextFieldWithDropDownSample( + AndroidViewAutocompleteDropdownWithOutsideFiltering( modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp), 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 cbb9e5fb..2d8c980b 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 @@ -3,28 +3,31 @@ package com.skyyo.samples.features.autoComplete import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.skyyo.samples.extensions.getStateFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.* import javax.inject.Inject +private const val QUERY = "query" +private const val SUGGESTIONS = "suggestions" +private const val IS_EXPANDED = "isExpanded" + @HiltViewModel class AutoCompleteViewModel @Inject constructor( - handle: SavedStateHandle + private val handle: SavedStateHandle ) : ViewModel() { val countries = provideCountries() - val query = handle.getStateFlow(viewModelScope, "query", "") - val suggestions = handle.getStateFlow(viewModelScope, "suggestions", countries) - val isExpanded = handle.getStateFlow(viewModelScope, "isExpanded", false) + val query = handle.getStateFlow( QUERY, "") + val suggestions = handle.getStateFlow( SUGGESTIONS, countries) + val isExpanded = handle.getStateFlow( IS_EXPANDED, false) fun onCountryEntered(input: String) { - query.value = input + handle[QUERY] = input viewModelScope.launch(Dispatchers.Default) { - suggestions.value = if (input.isEmpty()) { + handle[SUGGESTIONS] = if (input.isEmpty()) { countries.also { onExpandedChange(false) } } else { val filteredList = countries.filter { country -> @@ -38,11 +41,11 @@ class AutoCompleteViewModel @Inject constructor( } fun onExpandedChange(value: Boolean) { - isExpanded.value = value + handle[IS_EXPANDED] = value } fun onCountrySelected(value: String) { - query.value = value + handle[QUERY] = value } fun onExpandedFieldClick() { 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 7616e4a4..76a392d3 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 @@ -44,7 +44,8 @@ import androidx.compose.ui.util.fastMap import androidx.compose.ui.window.PopupPositionProvider import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.lifecycle.ViewTreeViewModelStoreOwner -import androidx.savedstate.ViewTreeSavedStateRegistryOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.skyyo.samples.R import kotlinx.coroutines.coroutineScope import java.util.* @@ -565,7 +566,7 @@ private class PopupLayout( id = android.R.id.content ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(composeView)) ViewTreeViewModelStoreOwner.set(this, ViewTreeViewModelStoreOwner.get(composeView)) - ViewTreeSavedStateRegistryOwner.set(this, ViewTreeSavedStateRegistryOwner.get(composeView)) + setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner()) composeView.viewTreeObserver.addOnGlobalLayoutListener(this) // Set unique id for AbstractComposeView. This allows state restoration for the state // defined inside the Popup via rememberSaveable() 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 6f64e400..80411617 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 @@ -2,20 +2,27 @@ package com.skyyo.samples.features.autoComplete import androidx.compose.foundation.layout.fillMaxWidth 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.Composable import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import com.skyyo.samples.utils.OnClick import com.skyyo.samples.utils.OnValueChange -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable -fun CustomExposedDropdownMenuSample( +fun AutocompleteDropdownWithOutsideFiltering( modifier: Modifier = Modifier, onExpandedChange: (Boolean) -> Unit, onSuggestionSelected: (String) -> Unit, @@ -23,12 +30,26 @@ fun CustomExposedDropdownMenuSample( onValueChange: OnValueChange, suggestions: List, expanded: Boolean, - query: String + query: String, ) { val textFieldValue = remember { mutableStateOf(TextFieldValue(query, TextRange(query.length))) } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val keyboardOptions = remember { + KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next) + } + val keyboardActions = remember { + KeyboardActions( + onNext = { + onExpandedChange(false) + focusManager.clearFocus() + keyboardController?.hide() + } + ) + } CustomExposedDropdownMenuBox( modifier = modifier, @@ -46,6 +67,8 @@ fun CustomExposedDropdownMenuSample( trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions ) if (suggestions.isNotEmpty() && expanded) { 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 index f298cfc3..022a4631 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt @@ -1,16 +1,63 @@ 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.launch +import kotlinx.coroutines.withContext +import java.util.* -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable -fun NativeExposedDropDownMenuSample(modifier: Modifier = Modifier, countries: List) { +fun AutocompleteDropdownWithFilteringInside( + modifier: Modifier = Modifier, + countries: List, +) { + val coroutineScope = rememberCoroutineScope() 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) { + coroutineScope.launch { + 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, @@ -19,18 +66,11 @@ fun NativeExposedDropDownMenuSample(modifier: Modifier = Modifier, countries: Li expanded = !expanded } ) { - TextField( + OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = query, onValueChange = { query = it - suggestions = if (it.isEmpty()) countries - else { - val filteredList = countries.filter { country -> - country.lowercase().startsWith(query.lowercase()) && country != query - } - filteredList - } }, label = { Text("Label") }, trailingIcon = { @@ -38,6 +78,8 @@ fun NativeExposedDropDownMenuSample(modifier: Modifier = Modifier, countries: Li expanded = expanded ) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions ) ExposedDropdownMenu( expanded = expanded, From a27bccff14cb0b88a7ae154fc0a682ca4a39bcc6 Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Tue, 19 Jul 2022 15:44:10 +0300 Subject: [PATCH 08/11] fix detekt --- .../autoComplete/CustomExposedDropdownMenu.kt | 88 ++++++++++--------- 1 file changed, 48 insertions(+), 40 deletions(-) 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 76a392d3..c6d844ed 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 @@ -51,10 +51,10 @@ import kotlinx.coroutines.coroutineScope import java.util.* import kotlin.math.roundToInt -private const val InTransitionDuration = 120 -private const val OutTransitionDuration = 75 -private val MenuElevation = 8.dp -private val DropdownMenuVerticalPadding = 8.dp +private const val IN_TRANSITION_DURATION = 120 +private const val OUT_TRANSITION_DURATION = 75 +private val MENU_ELEVATION = 8.dp +private val DROPDOWN_MENU_VERTICAL_PADDING = 8.dp @Composable fun CustomDropdownMenuContent( @@ -69,13 +69,13 @@ fun CustomDropdownMenuContent( transitionSpec = { if (false isTransitioningTo true) { tween( - durationMillis = InTransitionDuration, + durationMillis = IN_TRANSITION_DURATION, easing = LinearOutSlowInEasing ) } else { tween( durationMillis = 1, - delayMillis = OutTransitionDuration - 1 + delayMillis = OUT_TRANSITION_DURATION - 1 ) } }, @@ -88,7 +88,7 @@ fun CustomDropdownMenuContent( if (false isTransitioningTo true) { tween(durationMillis = 30) } else { - tween(durationMillis = OutTransitionDuration) + tween(durationMillis = OUT_TRANSITION_DURATION) } }, label = "FloatAnimation", @@ -102,10 +102,10 @@ fun CustomDropdownMenuContent( this.alpha = alpha transformOrigin = transformOriginState.value }, - elevation = MenuElevation + elevation = MENU_ELEVATION ) { LazyColumn( - modifier = modifier.padding(vertical = DropdownMenuVerticalPadding), + modifier = modifier.padding(vertical = DROPDOWN_MENU_VERTICAL_PADDING), state = scrollState, content = content, ) @@ -203,7 +203,7 @@ internal data class CustomDropdownMenuPositionProvider( val toCenter = anchorBounds.top - popupContentSize.height / 2 val toDisplayBottom = windowSize.height - popupContentSize.height val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull { - it + popupContentSize.height <= windowSize.height + it + popupContentSize.height <= windowSize.height } ?: toTop onPositionCalculated( @@ -223,10 +223,12 @@ internal fun calculateTransformOrigin( menuBounds.right <= parentBounds.left -> 1f menuBounds.width == 0 -> 0f else -> { - val intersectionCenter = (kotlin.math.max( - parentBounds.left, - menuBounds.left - ) + kotlin.math.min(parentBounds.right, menuBounds.right)) / 2 + val intersectionCenter = ( + kotlin.math.max( + parentBounds.left, + menuBounds.left + ) + kotlin.math.min(parentBounds.right, menuBounds.right) + ) / 2 (intersectionCenter - menuBounds.left).toFloat() / menuBounds.width } } @@ -235,10 +237,12 @@ internal fun calculateTransformOrigin( menuBounds.bottom <= parentBounds.top -> 1f menuBounds.height == 0 -> 0f else -> { - val intersectionCenter = (kotlin.math.max( - parentBounds.top, - menuBounds.top - ) + kotlin.math.min(parentBounds.bottom, menuBounds.bottom)) / 2 + val intersectionCenter = ( + kotlin.math.max( + parentBounds.top, + menuBounds.top + ) + kotlin.math.min(parentBounds.bottom, menuBounds.bottom) + ) / 2 (intersectionCenter - menuBounds.top).toFloat() / menuBounds.height } } @@ -614,23 +618,29 @@ private class PopupLayout( * Taken from PopupWindow */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (event.keyCode == KeyEvent.KEYCODE_BACK) { - if (keyDispatcherState == null) { - return super.dispatchKeyEvent(event) - } - if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + return when (event.keyCode) { + KeyEvent.KEYCODE_BACK -> obtainKeyCodeBackEvent(event) + else -> super.dispatchKeyEvent(event) + } + } + + private fun obtainKeyCodeBackEvent(event: KeyEvent): Boolean { + return when { + keyDispatcherState == null -> super.dispatchKeyEvent(event) + event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 -> { val state = keyDispatcherState state?.startTracking(event, this) - return true - } else if (event.action == KeyEvent.ACTION_UP) { + true + } + event.action == KeyEvent.ACTION_UP -> { val state = keyDispatcherState if (state != null && state.isTracking(event) && !event.isCanceled) { onDismissRequest?.invoke() - return true - } + true + } else super.dispatchKeyEvent(event) } + else -> super.dispatchKeyEvent(event) } - return super.dispatchKeyEvent(event) } fun updateParameters( @@ -692,16 +702,7 @@ private class PopupLayout( // matter whether we return true or false as some upper layer decides on whether the // event is propagated to other windows or not. So for focusable the event is consumed but // for not focusable it is propagated to other windows. - if (((event.action == MotionEvent.ACTION_DOWN) && - ( - (event.x < 0) || - (event.x >= width) || - (event.y < 0) || - (event.y >= height) - ) - ) || - event.action == MotionEvent.ACTION_OUTSIDE - ) { + if (onTouchEventCondition(event)) { val parentBounds = parentBounds val shouldDismiss = parentBounds == null || dismissOnOutsideClick( if (event.x != 0f || event.y != 0f) { @@ -720,6 +721,13 @@ private class PopupLayout( return super.onTouchEvent(event) } + private fun onTouchEventCondition(event: MotionEvent): Boolean { + val isActionDown = event.action == MotionEvent.ACTION_DOWN + val isActionOutside = event.action == MotionEvent.ACTION_OUTSIDE + val isRightCoordinates = event.x < 0 || event.x >= width || event.y < 0 || event.y >= height + return isActionDown && isRightCoordinates || isActionOutside + } + override fun setLayoutDirection(layoutDirection: Int) { // Do nothing. ViewRootImpl will call this method attempting to set the layout direction // from the context's locale, but we have one already from the parent composition. @@ -744,8 +752,8 @@ private class PopupLayout( // Flags specific to exposed dropdown menu. flags = WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or - WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or - WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM softInputMode = WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL From 82709278ff9ad0319af40fa70923abe557f6f515 Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Tue, 19 Jul 2022 15:46:59 +0300 Subject: [PATCH 09/11] fix detekt --- .../com/skyyo/samples/application/Destination.kt | 6 +++--- .../AndroidViewTextFieldWithDropDownSample.kt | 12 +++++++----- .../features/autoComplete/AutoCompleteViewModel.kt | 6 +++--- .../sampleContainer/SampleContainerViewModel.kt | 4 ++-- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/skyyo/samples/application/Destination.kt b/app/src/main/java/com/skyyo/samples/application/Destination.kt index 4a70de80..26f20c5f 100644 --- a/app/src/main/java/com/skyyo/samples/application/Destination.kt +++ b/app/src/main/java/com/skyyo/samples/application/Destination.kt @@ -25,9 +25,9 @@ sealed class Destination(val route: String) { object Otp : Destination("otp") object Table : Destination("table") object CustomView : Destination("customViewScreen") - object MarqueeText: Destination("marqueeText") - object Autofill: Destination("autofill") - object AutoComplete: Destination("autoComplete") + object MarqueeText : Destination("marqueeText") + object Autofill : Destination("autofill") + object AutoComplete : Destination("autoComplete") object DogFeed : Destination("dogFeed") object DogDetails : Destination("dogDetails/{dogId}") { fun createRoute(dogId: String) = "dogDetails/$dogId" 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 index fd8c663b..9bd17dd3 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt @@ -22,11 +22,13 @@ fun AndroidViewAutocompleteDropdownWithOutsideFiltering( 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 -> + ( + TextInputLayout.inflate( + context, + R.layout.text_input_field, + null + ) as TextInputLayout + ).also { til -> (til.editText as AutoCompleteTextView).setOnItemClickListener { _, _, index, _ -> onSelect(index) } 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 2d8c980b..63574df7 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 @@ -19,9 +19,9 @@ class AutoCompleteViewModel @Inject constructor( ) : ViewModel() { val countries = provideCountries() - val query = handle.getStateFlow( QUERY, "") - val suggestions = handle.getStateFlow( SUGGESTIONS, countries) - val isExpanded = handle.getStateFlow( IS_EXPANDED, false) + val query = handle.getStateFlow(QUERY, "") + val suggestions = handle.getStateFlow(SUGGESTIONS, countries) + val isExpanded = handle.getStateFlow(IS_EXPANDED, false) fun onCountryEntered(input: String) { handle[QUERY] = input diff --git a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt index f61375fc..1dfc1768 100644 --- a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt +++ b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt @@ -111,11 +111,11 @@ class SampleContainerViewModel @Inject constructor( fun goMarqueeText() = navigationDispatcher.emit { it.navigate(Destination.MarqueeText.route) } - + fun goAutofill() = navigationDispatcher.emit { it.navigate(Destination.Autofill.route) } - + fun goAutoComplete() = navigationDispatcher.emit { it.navigate(Destination.AutoComplete.route) } From a710d93181b0b89f2cab7a375af19ab3650c954c Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Tue, 19 Jul 2022 15:51:03 +0300 Subject: [PATCH 10/11] fix ktlint --- .../features/pagination/common/CustomCard.kt | 37 +----------------- .../features/pagination/common/FadingFab.kt | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 35 deletions(-) rename "app/src/main/java/com/skyyo/samples/features/pagination/common/\320\241omposables.kt" => app/src/main/java/com/skyyo/samples/features/pagination/common/CustomCard.kt (58%) create mode 100644 app/src/main/java/com/skyyo/samples/features/pagination/common/FadingFab.kt diff --git "a/app/src/main/java/com/skyyo/samples/features/pagination/common/\320\241omposables.kt" b/app/src/main/java/com/skyyo/samples/features/pagination/common/CustomCard.kt similarity index 58% rename from "app/src/main/java/com/skyyo/samples/features/pagination/common/\320\241omposables.kt" rename to app/src/main/java/com/skyyo/samples/features/pagination/common/CustomCard.kt index 6c160be8..fb2ae36e 100644 --- "a/app/src/main/java/com/skyyo/samples/features/pagination/common/\320\241omposables.kt" +++ b/app/src/main/java/com/skyyo/samples/features/pagination/common/CustomCard.kt @@ -1,30 +1,21 @@ package com.skyyo.samples.features.pagination.common -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material.Card -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.Icon import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import coil.annotation.ExperimentalCoilApi import coil.compose.rememberImagePainter import coil.transform.CircleCropTransformation -import com.skyyo.samples.theme.DarkGray import com.skyyo.samples.theme.Purple500 import com.skyyo.samples.theme.Shapes -import com.skyyo.samples.theme.White -import com.skyyo.samples.utils.OnClick +@OptIn(ExperimentalCoilApi::class) @Composable fun CustomCard(catId: String) { Card( @@ -56,27 +47,3 @@ fun CustomCard(catId: String) { } } } - -@OptIn(ExperimentalAnimationApi::class) -@Composable -fun FadingFab( - modifier: Modifier = Modifier, - isListScrolled: Boolean, - onclick: OnClick -) { - AnimatedVisibility( - enter = fadeIn(), - exit = fadeOut(), - visible = isListScrolled, - modifier = modifier - - ) { - FloatingActionButton( - onClick = onclick, - modifier = Modifier.size(48.dp), - backgroundColor = DarkGray - ) { - Icon(Icons.Filled.ArrowUpward, contentDescription = null, tint = White) - } - } -} diff --git a/app/src/main/java/com/skyyo/samples/features/pagination/common/FadingFab.kt b/app/src/main/java/com/skyyo/samples/features/pagination/common/FadingFab.kt new file mode 100644 index 00000000..506d01e9 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/pagination/common/FadingFab.kt @@ -0,0 +1,39 @@ +package com.skyyo.samples.features.pagination.common + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.size +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.skyyo.samples.theme.DarkGray +import com.skyyo.samples.theme.White +import com.skyyo.samples.utils.OnClick + +@Composable +fun FadingFab( + modifier: Modifier = Modifier, + isListScrolled: Boolean, + onclick: OnClick +) { + AnimatedVisibility( + enter = fadeIn(), + exit = fadeOut(), + visible = isListScrolled, + modifier = modifier + + ) { + FloatingActionButton( + onClick = onclick, + modifier = Modifier.size(48.dp), + backgroundColor = DarkGray + ) { + Icon(Icons.Filled.ArrowUpward, contentDescription = null, tint = White) + } + } +} From 5e97bd7cfc9e88f1c41e452ffe3c7b212c552a6d Mon Sep 17 00:00:00 2001 From: Rostyslav Holub Date: Thu, 28 Jul 2022 18:14:48 +0300 Subject: [PATCH 11/11] remove one of AndroidViewAutocomplete sample and fix empty dropdown issue --- .../autoComplete/AutoCompleteScreen.kt | 11 +---- .../autoComplete/AutoCompleteViewModel.kt | 20 ++++----- .../NativeExposedDropDownMenuSample.kt | 42 +++++++++---------- 3 files changed, 31 insertions(+), 42 deletions(-) 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 91e622fc..53e878ea 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 @@ -20,7 +20,7 @@ fun AutoCompleteScreen( Column( Modifier .fillMaxSize() - .padding(top = 32.dp, start = 16.dp, end = 16.dp), + .padding(top = 50.dp, start = 16.dp, end = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(16.dp)) @@ -50,14 +50,5 @@ fun AutoCompleteScreen( expanded = isExpanded.value, query = query.value, ) - Spacer(modifier = Modifier.height(16.dp)) - Text(text = "AndroidView") - AndroidViewAutocompleteDropdownWithOutsideFiltering( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - suggestions = viewModel.countries, - selectedValue = "", - ) } } 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 63574df7..de62a8b2 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 @@ -15,7 +15,7 @@ private const val IS_EXPANDED = "isExpanded" @HiltViewModel class AutoCompleteViewModel @Inject constructor( - private val handle: SavedStateHandle + private val handle: SavedStateHandle, ) : ViewModel() { val countries = provideCountries() @@ -25,18 +25,18 @@ class AutoCompleteViewModel @Inject constructor( fun onCountryEntered(input: String) { handle[QUERY] = input - viewModelScope.launch(Dispatchers.Default) { - handle[SUGGESTIONS] = if (input.isEmpty()) { - countries.also { onExpandedChange(false) } - } else { - val filteredList = countries.filter { country -> - country - .lowercase() - .startsWith(input.lowercase()) && country != input + handle[SUGGESTIONS] = when { + input.isEmpty() -> countries + else -> { + countries.filter { country -> + country + .lowercase() + .startsWith(input.lowercase()) && country != input + } } - filteredList.also { onExpandedChange(true) } } + onExpandedChange(true) } } 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 index 022a4631..d2f82d57 100644 --- a/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt @@ -12,7 +12,6 @@ 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.launch import kotlinx.coroutines.withContext import java.util.* @@ -22,7 +21,6 @@ fun AutocompleteDropdownWithFilteringInside( modifier: Modifier = Modifier, countries: List, ) { - val coroutineScope = rememberCoroutineScope() var expanded by remember { mutableStateOf(false) } var query by remember { mutableStateOf("") } val defaultLocale = remember { Locale.getDefault() } @@ -44,18 +42,16 @@ fun AutocompleteDropdownWithFilteringInside( } LaunchedEffect(lowerCaseSearchQuery) { - coroutineScope.launch { - withContext(Dispatchers.Default) { - suggestions = when (lowerCaseSearchQuery.isEmpty()) { - true -> countries - false -> { - countries.filter { country -> - country.lowercase(defaultLocale).startsWith(lowerCaseSearchQuery) && country != query - } + withContext(Dispatchers.Default) { + suggestions = when (lowerCaseSearchQuery.isEmpty()) { + true -> countries + false -> { + countries.filter { country -> + country.lowercase(defaultLocale).startsWith(lowerCaseSearchQuery) && country != query } } - expanded = true } + expanded = true } } @@ -81,18 +77,20 @@ fun AutocompleteDropdownWithFilteringInside( keyboardOptions = keyboardOptions, keyboardActions = keyboardActions ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - suggestions.forEach { selectionOption -> - DropdownMenuItem( - onClick = { - query = selectionOption - expanded = false + if (suggestions.isNotEmpty() && expanded) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + suggestions.forEach { selectionOption -> + DropdownMenuItem( + onClick = { + query = selectionOption + expanded = false + } + ) { + Text(text = selectionOption) } - ) { - Text(text = selectionOption) } } }