mirror of
https://github.com/BetterAndroid/FlexiUI.git
synced 2025-09-07 19:14:12 +08:00
feat: add DropdownMenu
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* Flexi UI - A flexible and useful UI component library.
|
||||
* Copyright (C) 2019-2023 HighCapable
|
||||
* https://github.com/BetterAndroid/FlexiUI
|
||||
*
|
||||
* Apache License Version 2.0
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* This file is created by fankes on 2023/11/9.
|
||||
*/
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.highcapable.flexiui.component
|
||||
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusManager
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.InputModeManager
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
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 androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.highcapable.flexiui.LocalColors
|
||||
import com.highcapable.flexiui.LocalShapes
|
||||
import com.highcapable.flexiui.LocalSizes
|
||||
import com.highcapable.flexiui.interaction.rippleClickable
|
||||
import com.highcapable.flexiui.utils.orElse
|
||||
import com.highcapable.flexiui.utils.status
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Immutable
|
||||
data class DropdownMenuColors(
|
||||
val contentColor: Color,
|
||||
val borderColor: Color
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class DropdownMenuStyle(
|
||||
val inTransitionDuration: Int,
|
||||
val outTransitionDuration: Int,
|
||||
val contentStyle: AreaBoxStyle,
|
||||
val borderStyle: AreaBoxStyle
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DropdownMenu(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
colors: DropdownMenuColors = DropdownMenu.colors,
|
||||
style: DropdownMenuStyle = DropdownMenu.style,
|
||||
offset: DpOffset = DpOffset(0.dp, 0.dp),
|
||||
scrollState: ScrollState = rememberScrollState(),
|
||||
properties: PopupProperties = PopupProperties(focusable = true),
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val expandedStates = remember { MutableTransitionState(false) }
|
||||
expandedStates.targetState = expanded
|
||||
if (expandedStates.currentState || expandedStates.targetState) {
|
||||
val density = LocalDensity.current
|
||||
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
|
||||
val popupPositionProvider = DropdownMenuPositionProvider(offset, density) { parentBounds, menuBounds ->
|
||||
transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
|
||||
}
|
||||
var focusManager: FocusManager? by mutableStateOf(null)
|
||||
var inputModeManager: InputModeManager? by mutableStateOf(null)
|
||||
Popup(
|
||||
onDismissRequest = onDismissRequest,
|
||||
popupPositionProvider = popupPositionProvider,
|
||||
properties = properties,
|
||||
onKeyEvent = { handlePopupOnKeyEvent(it, focusManager, inputModeManager) }
|
||||
) {
|
||||
focusManager = LocalFocusManager.current
|
||||
inputModeManager = LocalInputModeManager.current
|
||||
DropdownMenuContent(
|
||||
expandedStates = expandedStates,
|
||||
transformOriginState = transformOriginState,
|
||||
scrollState = scrollState,
|
||||
modifier = modifier,
|
||||
colors = colors,
|
||||
style = style,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DropdownMenuItem(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentColor: Color = Color.Unspecified,
|
||||
contentStyle: AreaBoxStyle? = null,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
val currentColor = contentColor.orElse() ?: LocalDropdownMenuContentColor.current.orElse() ?: DropdownMenu.colors.contentColor
|
||||
val currentStyle = contentStyle ?: LocalDropdownMenuContentStyle.current ?: DropdownMenu.style.contentStyle
|
||||
AreaRow(
|
||||
modifier = Modifier.status(enabled)
|
||||
.then(modifier)
|
||||
.fillMaxWidth()
|
||||
.sizeIn(
|
||||
minWidth = DefaultMenuItemMinWidth,
|
||||
maxWidth = DefaultMenuItemMaxWidth,
|
||||
minHeight = DefaultMenuItemMinHeight
|
||||
)
|
||||
.rippleClickable(
|
||||
enabled = enabled,
|
||||
role = Role.DropdownList,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick
|
||||
),
|
||||
color = Color.Transparent,
|
||||
style = currentStyle,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.default(currentColor)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DropdownMenuContent(
|
||||
expandedStates: MutableTransitionState<Boolean>,
|
||||
transformOriginState: MutableState<TransformOrigin>,
|
||||
scrollState: ScrollState,
|
||||
modifier: Modifier = Modifier,
|
||||
colors: DropdownMenuColors,
|
||||
style: DropdownMenuStyle,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val transition = updateTransition(expandedStates, label = "DropDownMenu")
|
||||
val scale by transition.animateFloat(
|
||||
transitionSpec = {
|
||||
if (false isTransitioningTo true) tween(
|
||||
durationMillis = style.inTransitionDuration,
|
||||
easing = LinearOutSlowInEasing
|
||||
) else tween(
|
||||
durationMillis = 1,
|
||||
delayMillis = style.outTransitionDuration - 1
|
||||
)
|
||||
}
|
||||
) { if (it) 1f else 0.8f }
|
||||
val alpha by transition.animateFloat(
|
||||
transitionSpec = {
|
||||
if (false isTransitioningTo true) tween(durationMillis = 30)
|
||||
else tween(durationMillis = style.outTransitionDuration)
|
||||
}
|
||||
) { if (it) 1f else 0f }
|
||||
AreaColumn(
|
||||
modifier = modifier.width(IntrinsicSize.Max)
|
||||
.verticalScroll(scrollState),
|
||||
initializer = {
|
||||
graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
this.alpha = alpha
|
||||
transformOrigin = transformOriginState.value
|
||||
}
|
||||
},
|
||||
style = style.borderStyle
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalDropdownMenuContentColor provides colors.contentColor,
|
||||
LocalDropdownMenuContentStyle provides style.contentStyle
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
private 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 =
|
||||
(max(parentBounds.left, menuBounds.left) + 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 =
|
||||
(max(parentBounds.top, menuBounds.top) + min(parentBounds.bottom, menuBounds.bottom)) / 2
|
||||
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
|
||||
}
|
||||
}
|
||||
return TransformOrigin(pivotX, pivotY)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun handlePopupOnKeyEvent(
|
||||
keyEvent: KeyEvent,
|
||||
focusManager: FocusManager?,
|
||||
inputModeManager: InputModeManager?
|
||||
) = if (keyEvent.type == KeyEventType.KeyDown) when (keyEvent.key) {
|
||||
Key.DirectionDown -> {
|
||||
inputModeManager?.requestInputMode(InputMode.Keyboard)
|
||||
focusManager?.moveFocus(FocusDirection.Next)
|
||||
true
|
||||
}
|
||||
Key.DirectionUp -> {
|
||||
inputModeManager?.requestInputMode(InputMode.Keyboard)
|
||||
focusManager?.moveFocus(FocusDirection.Previous)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
} else false
|
||||
|
||||
@Immutable
|
||||
private data class DropdownMenuPositionProvider(
|
||||
val contentOffset: DpOffset,
|
||||
val density: Density,
|
||||
val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> }
|
||||
) : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize
|
||||
): IntOffset {
|
||||
val verticalMargin = with(density) { 48.dp.roundToPx() }
|
||||
val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
|
||||
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
|
||||
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 (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft)
|
||||
else sequenceOf(toLeft, toRight, if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight)).firstOrNull {
|
||||
it >= 0 && it + popupContentSize.width <= windowSize.width
|
||||
} ?: toLeft
|
||||
val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
|
||||
val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
|
||||
val toCenter = anchorBounds.top - popupContentSize.height / 2
|
||||
val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin
|
||||
val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
|
||||
it >= verticalMargin &&
|
||||
it + popupContentSize.height <= windowSize.height - verticalMargin
|
||||
} ?: toTop
|
||||
onPositionCalculated(anchorBounds, IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height))
|
||||
return IntOffset(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
object DropdownMenu {
|
||||
val colors: DropdownMenuColors
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = defaultDropdownMenuColors()
|
||||
val style: DropdownMenuStyle
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = defaultDropdownMenuStyle()
|
||||
}
|
||||
|
||||
private val LocalDropdownMenuContentColor = compositionLocalOf { Color.Unspecified }
|
||||
|
||||
private val LocalDropdownMenuContentStyle = compositionLocalOf<AreaBoxStyle?> { null }
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun defaultDropdownMenuColors() = DropdownMenuColors(
|
||||
contentColor = LocalColors.current.textPrimary,
|
||||
borderColor = AreaBox.color
|
||||
)
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun defaultDropdownMenuStyle() = DropdownMenuStyle(
|
||||
inTransitionDuration = DefaultInTransitionDuration,
|
||||
outTransitionDuration = DefaultOutTransitionDuration,
|
||||
contentStyle = AreaBox.style.copy(
|
||||
padding = 0.dp,
|
||||
startPadding = DefaultMenuContentPadding,
|
||||
endPadding = DefaultMenuContentPadding,
|
||||
shape = LocalShapes.current.secondary
|
||||
),
|
||||
borderStyle = AreaBox.style.copy(
|
||||
padding = LocalSizes.current.spacingTertiary,
|
||||
shadowSize = LocalSizes.current.zoomSizeTertiary,
|
||||
shape = LocalShapes.current.primary
|
||||
)
|
||||
)
|
||||
|
||||
private val DefaultMenuContentPadding = 16.dp
|
||||
|
||||
private const val DefaultInTransitionDuration = 120
|
||||
private const val DefaultOutTransitionDuration = 90
|
||||
|
||||
private val DefaultMenuItemMinWidth = 112.dp
|
||||
private val DefaultMenuItemMaxWidth = 280.dp
|
||||
private val DefaultMenuItemMinHeight = 32.dp
|
Reference in New Issue
Block a user