From 08fe2b06fa0e5ce5e7ed7a489ad4c73f76309f9d Mon Sep 17 00:00:00 2001 From: YuKongA <70465933+YuKongA@users.noreply.github.com> Date: Fri, 10 Jan 2025 19:41:04 +0800 Subject: [PATCH] app: Update AutoCompleteTextField --- composeApp/src/commonMain/kotlin/App.kt | 7 +- .../ui/components/AutoCompleteTextField.kt | 100 +++---- .../kotlin/ui/components/SuperPopup.kt | 255 ++++++++++++++++++ .../kotlin/ui/components/SuperPopupHost.kt | 99 +++++++ 4 files changed, 410 insertions(+), 51 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ui/components/SuperPopup.kt create mode 100644 composeApp/src/commonMain/kotlin/ui/components/SuperPopupHost.kt diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index 9a16766..0ade30a 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import data.DataHelper @@ -41,11 +40,13 @@ import top.yukonga.miuix.kmp.basic.Surface import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.basic.rememberTopAppBarState import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.MiuixPopupUtil.Companion.MiuixPopupHost import top.yukonga.miuix.kmp.utils.getWindowSize import ui.AboutDialog import ui.InfoCardViews import ui.LoginCardView import ui.TextFieldViews +import ui.components.SuperPopupUtil.Companion.SuperPopupHost import updater.composeapp.generated.resources.Res import updater.composeapp.generated.resources.app_name @@ -113,6 +114,10 @@ fun App() { noiseFactor = 0f } ) + }, + popupHost = { + SuperPopupHost() + MiuixPopupHost() } ) { BoxWithConstraints( diff --git a/composeApp/src/commonMain/kotlin/ui/components/AutoCompleteTextField.kt b/composeApp/src/commonMain/kotlin/ui/components/AutoCompleteTextField.kt index 881c99b..b6ecc6f 100644 --- a/composeApp/src/commonMain/kotlin/ui/components/AutoCompleteTextField.kt +++ b/composeApp/src/commonMain/kotlin/ui/components/AutoCompleteTextField.kt @@ -1,36 +1,28 @@ package ui.components -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.MenuAnchorType.Companion.PrimaryEditable -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.MutableStateFlow +import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.basic.TextField -import top.yukonga.miuix.kmp.theme.LocalColors +import top.yukonga.miuix.kmp.extra.DropdownImpl import top.yukonga.miuix.kmp.theme.MiuixTheme -import top.yukonga.miuix.kmp.utils.MiuixIndication +import ui.components.SuperPopupUtil.Companion.dismissOwnPopup @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -40,9 +32,15 @@ fun AutoCompleteTextField( onValueChange: MutableStateFlow, label: String ) { - var isDropdownExpanded by remember { mutableStateOf(false) } + val listForItems = ArrayList(items) + val showTopPopup = remember { mutableStateOf(false) } + val list = listForItems.filter { + it.startsWith(text.value, ignoreCase = true) + || it.contains(text.value, ignoreCase = true) + || it.replace(" ", "").contains(text.value, ignoreCase = true) + }.sortedBy { !it.startsWith(text.value, ignoreCase = true) } - val hapticFeedback = LocalHapticFeedback.current + LocalHapticFeedback.current val focusManager = LocalFocusManager.current ExposedDropdownMenuBox( @@ -50,15 +48,19 @@ fun AutoCompleteTextField( .padding(horizontal = 12.dp) .padding(bottom = 12.dp) .fillMaxWidth(), - expanded = isDropdownExpanded, - onExpandedChange = { isDropdownExpanded = text.value.isNotEmpty() } + expanded = showTopPopup.value, + onExpandedChange = { + showTopPopup.value = text.value.isNotEmpty() + } ) { + println("list.isNotEmpty(): ${list.isNotEmpty()}, list: $list") + TextField( insideMargin = DpSize(16.dp, 20.dp), value = text.value, onValueChange = { onValueChange.value = it - isDropdownExpanded = it.isNotEmpty() + showTopPopup.value = it.isNotEmpty() && list.isNotEmpty() }, singleLine = true, label = label, @@ -66,47 +68,45 @@ fun AutoCompleteTextField( modifier = Modifier.menuAnchor(type = PrimaryEditable, enabled = true), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { - isDropdownExpanded = false focusManager.clearFocus() + dismissOwnPopup(showTopPopup) }) ) - val listForItems = ArrayList(items) - val list = listForItems.filter { - it.startsWith(text.value, ignoreCase = true) || it.contains(text.value, ignoreCase = true) - || it.replace(" ", "").contains(text.value, ignoreCase = true) - }.sortedBy { - !it.startsWith(text.value, ignoreCase = true) - } - ExposedDropdownMenu( - modifier = Modifier - .exposedDropdownSize() - .heightIn(max = 250.dp), - containerColor = MiuixTheme.colorScheme.surface, - shape = RoundedCornerShape(16.dp), - expanded = isDropdownExpanded && list.isNotEmpty(), - onDismissRequest = { isDropdownExpanded = false } + SuperPopup( + show = showTopPopup, + onDismissRequest = { + showTopPopup.value = false + }, + maxHeight = 300.dp ) { - list.forEach { text -> - DropdownMenuItem( - modifier = Modifier.clickable( - onClick = {}, - interactionSource = remember { MutableInteractionSource() }, - indication = MiuixIndication(backgroundColor = LocalColors.current.onBackground) - ), - text = { - Text( + ListPopupColumn { + if (list.isEmpty()) { + DropdownImpl( + text = "", + optionSize = 1, + onSelectedIndexChange = {}, + isSelected = false, + index = 0, + ) // Currently needed, fix crash. + showTopPopup.value = false + dismissOwnPopup(showTopPopup) + } else { + list.forEach { text -> + DropdownImpl( text = text, - color = MiuixTheme.colorScheme.onBackground + optionSize = list.size, + onSelectedIndexChange = { + onValueChange.value = text + KeyboardOptions(imeAction = ImeAction.Done) + focusManager.clearFocus() + dismissOwnPopup(showTopPopup) + showTopPopup.value = false + }, + isSelected = false, + index = 0, ) - }, - onClick = { - onValueChange.value = text - isDropdownExpanded = false - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - KeyboardOptions(imeAction = ImeAction.Done) - focusManager.clearFocus() } - ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/ui/components/SuperPopup.kt b/composeApp/src/commonMain/kotlin/ui/components/SuperPopup.kt new file mode 100644 index 0000000..d076ca2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/components/SuperPopup.kt @@ -0,0 +1,255 @@ +package ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsets.Companion +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.captionBar +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.BackHandler +import top.yukonga.miuix.kmp.utils.SmoothRoundedCornerShape +import top.yukonga.miuix.kmp.utils.getWindowSize +import ui.components.SuperPopupUtil.Companion.dismissOwnPopup +import ui.components.SuperPopupUtil.Companion.showOwnPopup + +@Composable +fun SuperPopup( + show: MutableState, + popupModifier: Modifier = Modifier, + popupPositionProvider: PopupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + onDismissRequest: (() -> Unit)? = null, + maxHeight: Dp? = null, + content: @Composable () -> Unit +) { + var offset by remember { mutableStateOf(IntOffset.Zero) } + + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val getWindowSize = rememberUpdatedState(getWindowSize()) + var windowSize by remember { mutableStateOf(IntSize(getWindowSize.value.width, getWindowSize.value.height)) } + + var parentBounds by remember { mutableStateOf(IntRect.Zero) } + val windowBounds by rememberUpdatedState(with(density) { + IntRect( + left = WindowInsets.displayCutout.asPaddingValues(density).calculateLeftPadding(layoutDirection).roundToPx(), + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding().roundToPx(), + right = windowSize.width - + WindowInsets.displayCutout.asPaddingValues(density).calculateRightPadding(layoutDirection).roundToPx(), + bottom = windowSize.height - + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding().roundToPx() - + WindowInsets.captionBar.asPaddingValues().calculateBottomPadding().roundToPx() + ) + }) + var popupContentSize = IntSize.Zero + var popupMargin by remember { mutableStateOf(IntRect.Zero) } + + + var transformOrigin by remember { mutableStateOf(TransformOrigin.Center) } + + if (!listPopupStates.contains(show)) listPopupStates.add(show) + + LaunchedEffect(show.value) { + if (show.value) { + listPopupStates.forEach { state -> if (state != show) state.value = false } + } + } + + BackHandler(enabled = show.value) { + dismissOwnPopup(show) + onDismissRequest?.let { it1 -> it1() } + } + + DisposableEffect(Unit) { + onDispose { + dismissOwnPopup(show) + onDismissRequest?.let { it1 -> it1() } + } + } + + DisposableEffect(popupPositionProvider) { + val popupMargins = popupPositionProvider.getMargins() + popupMargin = with(density) { + IntRect( + left = popupMargins.calculateLeftPadding(layoutDirection).roundToPx(), + top = popupMargins.calculateTopPadding().roundToPx(), + right = popupMargins.calculateRightPadding(layoutDirection).roundToPx(), + bottom = popupMargins.calculateBottomPadding().roundToPx() + ) + } + if (popupContentSize != IntSize.Zero) { + offset = popupPositionProvider.calculatePosition( + parentBounds, + windowBounds, + layoutDirection, + popupContentSize, + popupMargin + ) + } + onDispose {} + } + + if (show.value) { + val dropdownElevation by rememberUpdatedState(with(density) { + 11.dp.toPx() + }) + showOwnPopup( + transformOrigin = { transformOrigin } + ) { + Box( + modifier = popupModifier + .pointerInput(Unit) { + detectTapGestures { + dismissOwnPopup(show) + onDismissRequest?.let { it1 -> it1() } + } + } + .layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy( + minHeight = 50.dp.roundToPx(), + maxHeight = if (maxHeight != null) maxHeight.roundToPx() else windowBounds.height - popupMargin.top - popupMargin.bottom + ) + ) + popupContentSize = IntSize(placeable.width, placeable.height) + offset = popupPositionProvider.calculatePosition( + parentBounds, + windowBounds, + layoutDirection, + popupContentSize, + popupMargin + ) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place(offset) + } + } + ) { + Box( + Modifier + .align(AbsoluteAlignment.TopLeft) + .graphicsLayer( + clip = true, + shape = SmoothRoundedCornerShape(16.dp), + shadowElevation = dropdownElevation, + ambientShadowColor = Color.Black.copy(alpha = 0.3f), + spotShadowColor = Color.Black.copy(alpha = 0.3f) + ) + .background(MiuixTheme.colorScheme.surface) + ) { + content.invoke() + } + } + } + } + + Layout( + content = {}, + modifier = Modifier.onGloballyPositioned { childCoordinates -> + val parentCoordinates = childCoordinates.parentLayoutCoordinates!! + val positionInWindow = parentCoordinates.positionInWindow() + parentBounds = IntRect( + left = positionInWindow.x.toInt(), + top = positionInWindow.y.toInt(), + right = positionInWindow.x.toInt() + parentCoordinates.size.width, + bottom = positionInWindow.y.toInt() + parentCoordinates.size.height + ) + val windowHeightPx = getWindowSize.value.height + val windowWidthPx = getWindowSize.value.width + windowSize = IntSize(windowWidthPx, windowHeightPx) + with(density) { + val xInWindow = parentBounds.left + popupMargin.left + 64.dp.roundToPx() + val yInWindow = parentBounds.top + parentBounds.height / 2 - 56.dp.roundToPx() + transformOrigin = TransformOrigin( + xInWindow / windowWidthPx.toFloat(), + yInWindow / windowHeightPx.toFloat() + ) + } + } + ) { _, _ -> + layout(0, 0) {} + } +} + +interface PopupPositionProvider { + /** + * Calculate the position (offset) of Popup + * + * @param anchorBounds Bounds of the anchored (parent) component + * @param windowBounds Bounds of the safe area of window (excluding the [WindowInsets.Companion.statusBars], [WindowInsets.Companion.navigationBars] and [WindowInsets.Companion.captionBar]) + * @param layoutDirection [LayoutDirection] + * @param popupContentSize Actual size of the popup content + * @param popupMargin (Extra) Margins for the popup content. See [PopupPositionProvider.getMargins] + */ + fun calculatePosition( + anchorBounds: IntRect, + windowBounds: IntRect, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + popupMargin: IntRect, + ): IntOffset + + /** + * (Extra) Margins for the popup content. + */ + fun getMargins(): PaddingValues +} + +object ListPopupDefaults { + val ContextMenuPositionProvider = object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowBounds: IntRect, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + popupMargin: IntRect + ): IntOffset { + + val offsetX: Int = anchorBounds.left + val offsetY: Int = anchorBounds.bottom + popupMargin.top + + return IntOffset( + x = offsetX.coerceIn(windowBounds.left, windowBounds.right - popupContentSize.width - popupMargin.right), + y = offsetY.coerceIn(windowBounds.top + popupMargin.top, windowBounds.bottom - popupContentSize.height - popupMargin.bottom) + ) + } + + override fun getMargins(): PaddingValues { + return PaddingValues(horizontal = 20.dp, vertical = 0.dp) + } + } +} + +val listPopupStates = mutableStateListOf>() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/components/SuperPopupHost.kt b/composeApp/src/commonMain/kotlin/ui/components/SuperPopupHost.kt new file mode 100644 index 0000000..ce059ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/components/SuperPopupHost.kt @@ -0,0 +1,99 @@ +package ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.TransformOrigin.Companion +import androidx.compose.ui.zIndex +import top.yukonga.miuix.kmp.anim.DecelerateEasing +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * A util class for show popup and dialog. + */ +class SuperPopupUtil { + + companion object { + private var isPopupShowing = mutableStateOf(false) + private var popupContext = mutableStateOf<(@Composable () -> Unit)?>(null) + private var popupTransformOrigin = mutableStateOf({ TransformOrigin.Center }) + + + /** + * Show a popup. + * + * @param transformOrigin The pivot point in terms of fraction of the overall size, + * used for scale transformations. By default it's [TransformOrigin.Center]. + * @param content The [Composable] content of the popup. + */ + @Composable + fun showOwnPopup( + transformOrigin: (() -> TransformOrigin) = { TransformOrigin.Center }, + content: (@Composable () -> Unit)? = null, + ) { + if (isPopupShowing.value) return + popupTransformOrigin.value = transformOrigin + isPopupShowing.value = true + popupContext.value = content + } + + /** + * Dismiss the popup. + * + * @param show The show state of the popup. + */ + fun dismissOwnPopup( + show: MutableState, + ) { + isPopupShowing.value = false + show.value = false + } + + /** + * A host for show popup and dialog. Already added to the [Scaffold] by default. + */ + @Composable + fun SuperPopupHost() { + AnimatedVisibility( + visible = isPopupShowing.value, + modifier = Modifier.zIndex(2f).fillMaxSize(), + enter = fadeIn( + animationSpec = tween(150, easing = DecelerateEasing(1.5f)) + ) + scaleIn( + initialScale = 0.8f, + animationSpec = tween(150, easing = DecelerateEasing(1.5f)), + transformOrigin = popupTransformOrigin.value.invoke() + ), + exit = fadeOut( + animationSpec = tween(150, easing = DecelerateEasing(1.5f)) + ) + scaleOut( + targetScale = 0.8f, + animationSpec = tween(150, easing = DecelerateEasing(1.5f)), + transformOrigin = popupTransformOrigin.value.invoke() + ) + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + popupContext.value?.invoke() + } + } + } + } +} \ No newline at end of file