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 79f7b060..26f20c5f 100644 --- a/app/src/main/java/com/skyyo/samples/application/Destination.kt +++ b/app/src/main/java/com/skyyo/samples/application/Destination.kt @@ -27,6 +27,7 @@ sealed class Destination(val route: String) { object CustomView : Destination("customViewScreen") 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/application/activity/PopulatedNavHost.kt b/app/src/main/java/com/skyyo/samples/application/activity/PopulatedNavHost.kt index a9b9763f..c07a63c3 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 @@ -17,6 +17,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.autofill.AutofillScreen import com.skyyo.samples.features.autoscroll.AutoScrollScreen import com.skyyo.samples.features.bottomSheets.BottomSheetScaffoldScreen @@ -139,6 +140,7 @@ fun PopulatedNavHost( composable(Destination.CustomView.route) { CustomViewScreen() } composable(Destination.MarqueeText.route) { MarqueeTextScreen() } composable(Destination.Autofill.route) { AutofillScreen() } + 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..9bd17dd3 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AndroidViewTextFieldWithDropDownSample.kt @@ -0,0 +1,47 @@ +package com.skyyo.samples.features.autoComplete + +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.google.android.material.textfield.TextInputLayout +import com.skyyo.samples.R + +@Composable +fun AndroidViewAutocompleteDropdownWithOutsideFiltering( + modifier: Modifier = Modifier, + suggestions: List, + selectedValue: String = "", + onSelect: (Int) -> Unit = {}, +) { + val context = LocalContext.current + val adapter = remember(suggestions) { + ArrayAdapter(context, android.R.layout.simple_list_item_1, suggestions) + } + val textInputLayout = remember { + ( + TextInputLayout.inflate( + context, + R.layout.text_input_field, + null + ) as TextInputLayout + ).also { til -> + (til.editText as AutoCompleteTextView).setOnItemClickListener { _, _, index, _ -> + onSelect(index) + } + } + } + val autoCompleteTextView = remember { textInputLayout.editText as AutoCompleteTextView } + + AndroidView( + modifier = modifier, + factory = { textInputLayout }, + update = { + autoCompleteTextView.setAdapter(adapter) + autoCompleteTextView.setText(selectedValue, false) + }, + ) +} diff --git a/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteScreen.kt b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteScreen.kt new file mode 100644 index 00000000..53e878ea --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteScreen.kt @@ -0,0 +1,54 @@ +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.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel + +@Composable +fun AutoCompleteScreen( + viewModel: AutoCompleteViewModel = hiltViewModel() +) { + val query = viewModel.query.collectAsState() + val isExpanded = viewModel.isExpanded.collectAsState() + val suggestions = viewModel.suggestions.collectAsState() + + Column( + Modifier + .fillMaxSize() + .padding(top = 50.dp, start = 16.dp, end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "AndroidView") + AndroidViewAutocompleteDropdownWithOutsideFiltering( + modifier = Modifier.fillMaxWidth(), + suggestions = viewModel.countries, + selectedValue = "", + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "Native exposed dropdown menu") + AutocompleteDropdownWithFilteringInside( + modifier = Modifier.fillMaxWidth(), + countries = viewModel.countries, + ) + Spacer(modifier = Modifier.height(60.dp)) + Text(text = "Custom exposed dropdown menu") + AutocompleteDropdownWithOutsideFiltering( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + onSuggestionSelected = viewModel::onCountrySelected, + onExpandedChange = viewModel::onExpandedChange, + onValueChange = viewModel::onCountryEntered, + onClick = viewModel::onExpandedFieldClick, + suggestions = suggestions.value, + expanded = isExpanded.value, + query = query.value, + ) + } +} 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..de62a8b2 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/AutoCompleteViewModel.kt @@ -0,0 +1,68 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import 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( + private val handle: SavedStateHandle, +) : ViewModel() { + + val countries = provideCountries() + 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 + viewModelScope.launch(Dispatchers.Default) { + handle[SUGGESTIONS] = when { + input.isEmpty() -> countries + else -> { + countries.filter { country -> + country + .lowercase() + .startsWith(input.lowercase()) && country != input + } + } + } + onExpandedChange(true) + } + } + + fun onExpandedChange(value: Boolean) { + handle[IS_EXPANDED] = value + } + + fun onCountrySelected(value: String) { + handle[QUERY] = 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 new file mode 100644 index 00000000..c6d844ed --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenu.kt @@ -0,0 +1,790 @@ +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.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.skyyo.samples.R +import kotlinx.coroutines.coroutineScope +import java.util.* +import kotlin.math.roundToInt + +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( + 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 = IN_TRANSITION_DURATION, + easing = LinearOutSlowInEasing + ) + } else { + tween( + durationMillis = 1, + delayMillis = OUT_TRANSITION_DURATION - 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 = OUT_TRANSITION_DURATION) + } + }, + label = "FloatAnimation", + targetValueByState = { if (it) 1f else 0f } + ) + + Card( + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + transformOrigin = transformOriginState.value + }, + elevation = MENU_ELEVATION + ) { + LazyColumn( + modifier = modifier.padding(vertical = DROPDOWN_MENU_VERTICAL_PADDING), + 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, + onClick: () -> 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(onClick = onClick) + .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( + onClick: () -> Unit +) = pointerInput(Unit) { + forEachGesture { + coroutineScope { + awaitPointerEventScope { + var event: PointerEvent + do { + event = awaitPointerEvent(PointerEventPass.Initial) + } while ( + !event.changes.fastAll { it.changedToUp() } + ) + onClick.invoke() + } + } + } +}.semantics { + onClick { + onClick.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)) + 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() + 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 { + 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) + true + } + event.action == KeyEvent.ACTION_UP -> { + val state = keyDispatcherState + if (state != null && state.isTracking(event) && !event.isCanceled) { + onDismissRequest?.invoke() + true + } else super.dispatchKeyEvent(event) + } + else -> 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 (onTouchEventCondition(event)) { + 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) + } + + 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. + } + + // 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..80411617 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/CustomExposedDropdownMenuSample.kt @@ -0,0 +1,98 @@ +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, ExperimentalComposeUiApi::class) +@Composable +fun AutocompleteDropdownWithOutsideFiltering( + 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))) + } + 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, + expanded = expanded, + onClick = onClick, + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = textFieldValue.value, + onValueChange = { value -> + textFieldValue.value = value + if (value.text != query) onValueChange.invoke(value.text) + }, + label = { Text("Label") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) + + if (suggestions.isNotEmpty() && expanded) { + CustomExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange.invoke(false) } + ) { + items(suggestions) { option -> + key(option) { + DropdownMenuItem( + onClick = { + textFieldValue.value = TextFieldValue( + text = option, + selection = TextRange(option.length) + ) + 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 new file mode 100644 index 00000000..d2f82d57 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/autoComplete/NativeExposedDropDownMenuSample.kt @@ -0,0 +1,98 @@ +package com.skyyo.samples.features.autoComplete + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.* + +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@Composable +fun AutocompleteDropdownWithFilteringInside( + modifier: Modifier = Modifier, + countries: List, +) { + var expanded by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + val defaultLocale = remember { Locale.getDefault() } + val lowerCaseSearchQuery = remember(query) { query.lowercase(defaultLocale) } + var suggestions by remember { mutableStateOf(countries) } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val keyboardOptions = remember { + KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next) + } + val keyboardActions = remember { + KeyboardActions( + onNext = { + expanded = false + focusManager.clearFocus() + keyboardController?.hide() + } + ) + } + + LaunchedEffect(lowerCaseSearchQuery) { + withContext(Dispatchers.Default) { + suggestions = when (lowerCaseSearchQuery.isEmpty()) { + true -> countries + false -> { + countries.filter { country -> + country.lowercase(defaultLocale).startsWith(lowerCaseSearchQuery) && country != query + } + } + } + expanded = true + } + } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { + expanded = !expanded + } + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = query, + onValueChange = { + query = it + }, + label = { Text("Label") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) + if (suggestions.isNotEmpty() && expanded) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + suggestions.forEach { selectionOption -> + DropdownMenuItem( + onClick = { + query = selectionOption + expanded = false + } + ) { + Text(text = selectionOption) + } + } + } + } + } +} diff --git "a/app/src/main/java/com/skyyo/samples/features/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) + } + } +} 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 a318f56d..857c742a 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 @@ -145,6 +145,10 @@ fun UIelements(viewModel: SampleContainerViewModel) { modifier = Modifier.fillMaxWidth(), onClick = viewModel::goAutofill ) { Text(text = "autofill") } + 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 d77ddde3..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 @@ -116,6 +116,10 @@ class SampleContainerViewModel @Inject constructor( it.navigate(Destination.Autofill.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 @@ + + + + + +