| /* |
| * Copyright 2022 The Android Open Source Project |
| * |
| * 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 |
| * |
| * http://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. |
| */ |
| |
| package androidx.compose.material3 |
| |
| 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.clickable |
| import androidx.compose.foundation.interaction.MutableInteractionSource |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.ColumnScope |
| import androidx.compose.foundation.layout.IntrinsicSize |
| import androidx.compose.foundation.layout.PaddingValues |
| import androidx.compose.foundation.layout.Row |
| import androidx.compose.foundation.layout.defaultMinSize |
| import androidx.compose.foundation.layout.fillMaxWidth |
| import androidx.compose.foundation.layout.padding |
| 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.material.ripple.rememberRipple |
| import androidx.compose.material3.tokens.MenuTokens |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.CompositionLocalProvider |
| import androidx.compose.runtime.Immutable |
| import androidx.compose.runtime.MutableState |
| import androidx.compose.runtime.Stable |
| import androidx.compose.runtime.State |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.rememberUpdatedState |
| import androidx.compose.ui.Alignment |
| 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.unit.Density |
| import androidx.compose.ui.unit.Dp |
| 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 kotlin.math.max |
| import kotlin.math.min |
| |
| @Suppress("ModifierParameter") |
| @Composable |
| internal fun DropdownMenuContent( |
| expandedStates: MutableTransitionState<Boolean>, |
| transformOriginState: MutableState<TransformOrigin>, |
| modifier: Modifier = Modifier, |
| content: @Composable ColumnScope.() -> Unit |
| ) { |
| // Menu open/close animation. |
| val transition = updateTransition(expandedStates, "DropDownMenu") |
| |
| val scale by transition.animateFloat( |
| transitionSpec = { |
| if (false isTransitioningTo true) { |
| // Dismissed to expanded |
| tween( |
| durationMillis = InTransitionDuration, |
| easing = LinearOutSlowInEasing |
| ) |
| } else { |
| // Expanded to dismissed. |
| tween( |
| durationMillis = 1, |
| delayMillis = OutTransitionDuration - 1 |
| ) |
| } |
| } |
| ) { |
| if (it) { |
| // Menu is expanded. |
| 1f |
| } else { |
| // Menu is dismissed. |
| 0.8f |
| } |
| } |
| |
| val alpha by transition.animateFloat( |
| transitionSpec = { |
| if (false isTransitioningTo true) { |
| // Dismissed to expanded |
| tween(durationMillis = 30) |
| } else { |
| // Expanded to dismissed. |
| tween(durationMillis = OutTransitionDuration) |
| } |
| } |
| ) { |
| if (it) { |
| // Menu is expanded. |
| 1f |
| } else { |
| // Menu is dismissed. |
| 0f |
| } |
| } |
| Surface( |
| modifier = Modifier.graphicsLayer { |
| scaleX = scale |
| scaleY = scale |
| this.alpha = alpha |
| transformOrigin = transformOriginState.value |
| }, |
| shape = MenuTokens.ContainerShape, |
| color = MaterialTheme.colorScheme.fromToken(MenuTokens.ContainerColor), |
| tonalElevation = MenuTokens.ContainerElevation, |
| shadowElevation = MenuTokens.ContainerElevation |
| ) { |
| Column( |
| modifier = modifier |
| .padding(vertical = DropdownMenuVerticalPadding) |
| .width(IntrinsicSize.Max) |
| .verticalScroll(rememberScrollState()), |
| content = content |
| ) |
| } |
| } |
| |
| @Composable |
| internal fun DropdownMenuItemContent( |
| text: @Composable () -> Unit, |
| onClick: () -> Unit, |
| modifier: Modifier, |
| leadingIcon: @Composable (() -> Unit)?, |
| trailingIcon: @Composable (() -> Unit)?, |
| enabled: Boolean, |
| colors: MenuItemColors, |
| contentPadding: PaddingValues, |
| interactionSource: MutableInteractionSource |
| ) { |
| Row( |
| modifier = modifier |
| .clickable( |
| enabled = enabled, |
| onClick = onClick, |
| interactionSource = interactionSource, |
| indication = rememberRipple(true) |
| ) |
| .fillMaxWidth() |
| // Preferred min and max width used during the intrinsic measurement. |
| .sizeIn( |
| minWidth = DropdownMenuItemDefaultMinWidth, |
| maxWidth = DropdownMenuItemDefaultMaxWidth, |
| minHeight = MenuTokens.ListItemContainerHeight |
| ) |
| .padding(contentPadding), |
| verticalAlignment = Alignment.CenterVertically |
| ) { |
| ProvideTextStyle(MaterialTheme.typography.fromToken(MenuTokens.ListItemLabelTextFont)) { |
| if (leadingIcon != null) { |
| CompositionLocalProvider( |
| LocalContentColor provides colors.leadingIconColor(enabled).value, |
| ) { |
| Box(Modifier.defaultMinSize(minWidth = MenuTokens.ListItemLeadingIconSize)) { |
| leadingIcon() |
| } |
| } |
| } |
| CompositionLocalProvider(LocalContentColor provides colors.textColor(enabled).value) { |
| Box( |
| Modifier.weight(1f) |
| .padding( |
| start = if (leadingIcon != null) { |
| DropdownMenuItemHorizontalPadding |
| } else { |
| 0.dp |
| }, |
| end = if (trailingIcon != null) { |
| DropdownMenuItemHorizontalPadding |
| } else { |
| 0.dp |
| } |
| ) |
| ) { |
| text() |
| } |
| } |
| if (trailingIcon != null) { |
| CompositionLocalProvider( |
| LocalContentColor provides colors.trailingIconColor(enabled).value |
| ) { |
| Box(Modifier.defaultMinSize(minWidth = MenuTokens.ListItemTrailingIconSize)) { |
| trailingIcon() |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| /** |
| * Contains default values used for [DropdownMenuItem]. |
| */ |
| object MenuDefaults { |
| |
| /** |
| * Creates a [MenuItemColors] that represents the default text and icon colors used in a |
| * [DropdownMenuItemContent]. |
| * |
| * @param textColor the text color of this [DropdownMenuItemContent] when enabled |
| * @param leadingIconColor the leading icon color of this [DropdownMenuItemContent] when enabled |
| * @param trailingIconColor the trailing icon color of this [DropdownMenuItemContent] when |
| * enabled |
| * @param disabledTextColor the text color of this [DropdownMenuItemContent] when not enabled |
| * @param disabledLeadingIconColor the leading icon color of this [DropdownMenuItemContent] when |
| * not enabled |
| * @param disabledTrailingIconColor the trailing icon color of this [DropdownMenuItemContent] |
| * when not enabled |
| */ |
| @Composable |
| fun itemColors( |
| textColor: Color = MenuTokens.ListItemLabelTextColor.toColor(), |
| leadingIconColor: Color = MenuTokens.ListItemLeadingIconColor.toColor(), |
| trailingIconColor: Color = MenuTokens.ListItemTrailingIconColor.toColor(), |
| disabledTextColor: Color = |
| MenuTokens.ListItemDisabledLabelTextColor.toColor() |
| .copy(alpha = MenuTokens.ListItemDisabledLabelTextOpacity), |
| disabledLeadingIconColor: Color = MenuTokens.ListItemDisabledLeadingIconColor.toColor() |
| .copy(alpha = MenuTokens.ListItemDisabledLeadingIconOpacity), |
| disabledTrailingIconColor: Color = MenuTokens.ListItemDisabledTrailingIconColor.toColor() |
| .copy(alpha = MenuTokens.ListItemDisabledTrailingIconOpacity), |
| ): MenuItemColors = |
| DefaultMenuItemColors( |
| textColor = textColor, |
| leadingIconColor = leadingIconColor, |
| trailingIconColor = trailingIconColor, |
| disabledTextColor = disabledTextColor, |
| disabledLeadingIconColor = disabledLeadingIconColor, |
| disabledTrailingIconColor = disabledTrailingIconColor, |
| ) |
| |
| /** |
| * Default padding used for [DropdownMenuItem]. |
| */ |
| val DropdownMenuItemContentPadding = PaddingValues( |
| horizontal = DropdownMenuItemHorizontalPadding, |
| vertical = 0.dp |
| ) |
| |
| /** |
| * Default [Divider], which can be optionally positioned at the bottom of the |
| * [DropdownMenuItemContent]. |
| * |
| * @param modifier modifier for the divider's layout |
| * @param color color of the divider |
| * @param thickness thickness of the divider |
| */ |
| @Composable |
| fun Divider( |
| modifier: Modifier = Modifier, |
| color: Color = MenuTokens.DividerColor.toColor(), |
| thickness: Dp = MenuTokens.DividerHeight, |
| ) { |
| androidx.compose.material3.Divider( |
| modifier = modifier, |
| color = color, |
| thickness = thickness, |
| ) |
| } |
| } |
| |
| /** |
| * Represents the text and icon colors used in a menu item at different states. |
| * |
| * - See [MenuDefaults.itemColors] for the default colors used in a [DropdownMenuItemContent]. |
| */ |
| @Stable |
| interface MenuItemColors { |
| |
| /** |
| * Represents the text color for a menu item, depending on its [enabled] state. |
| * |
| * @param enabled whether the menu item is enabled |
| */ |
| @Composable |
| fun textColor(enabled: Boolean): State<Color> |
| |
| /** |
| * Represents the leading icon color for a menu item, depending on its [enabled] state. |
| * |
| * @param enabled whether the menu item is enabled |
| */ |
| @Composable |
| fun leadingIconColor(enabled: Boolean): State<Color> |
| |
| /** |
| * Represents the trailing icon color for a menu item, depending on its [enabled] state. |
| * |
| * @param enabled whether the menu item is enabled |
| */ |
| @Composable |
| fun trailingIconColor(enabled: Boolean): State<Color> |
| } |
| |
| 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 = |
| ( |
| 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) |
| } |
| |
| // Menu positioning. |
| |
| /** |
| * Calculates the position of a Material [DropdownMenu]. |
| */ |
| // TODO(popam): Investigate if this can/should consider the app window size rather than screen size |
| @Immutable |
| internal 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 { |
| // The min margin above and below the menu, relative to the screen. |
| val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() } |
| // The content offset specified using the dropdown offset parameter. |
| val contentOffsetX = with(density) { contentOffset.x.roundToPx() } |
| val contentOffsetY = with(density) { contentOffset.y.roundToPx() } |
| |
| // Compute horizontal position. |
| val toRight = anchorBounds.left + contentOffsetX |
| val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width |
| val toDisplayRight = windowSize.width - popupContentSize.width |
| val toDisplayLeft = 0 |
| val x = if (layoutDirection == LayoutDirection.Ltr) { |
| sequenceOf( |
| toRight, |
| toLeft, |
| // If the anchor gets outside of the window on the left, we want to position |
| // toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight. |
| if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft |
| ) |
| } else { |
| sequenceOf( |
| toLeft, |
| toRight, |
| // If the anchor gets outside of the window on the right, we want to position |
| // toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft. |
| if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight |
| ) |
| }.firstOrNull { |
| it >= 0 && it + popupContentSize.width <= windowSize.width |
| } ?: toLeft |
| |
| // Compute vertical position. |
| 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) |
| } |
| } |
| |
| /** Default [MenuItemColors] implementation. */ |
| @Immutable |
| private class DefaultMenuItemColors( |
| private val textColor: Color, |
| private val leadingIconColor: Color, |
| private val trailingIconColor: Color, |
| private val disabledTextColor: Color, |
| private val disabledLeadingIconColor: Color, |
| private val disabledTrailingIconColor: Color, |
| ) : MenuItemColors { |
| |
| @Composable |
| override fun textColor(enabled: Boolean): State<Color> { |
| return rememberUpdatedState(if (enabled) textColor else disabledTextColor) |
| } |
| |
| @Composable |
| override fun leadingIconColor(enabled: Boolean): State<Color> { |
| return rememberUpdatedState(if (enabled) leadingIconColor else disabledLeadingIconColor) |
| } |
| |
| @Composable |
| override fun trailingIconColor(enabled: Boolean): State<Color> { |
| return rememberUpdatedState(if (enabled) trailingIconColor else disabledTrailingIconColor) |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other == null || this::class != other::class) return false |
| |
| other as DefaultMenuItemColors |
| |
| if (textColor != other.textColor) return false |
| if (leadingIconColor != other.leadingIconColor) return false |
| if (trailingIconColor != other.trailingIconColor) return false |
| if (disabledTextColor != other.disabledTextColor) return false |
| if (disabledLeadingIconColor != other.disabledLeadingIconColor) return false |
| if (disabledTrailingIconColor != other.disabledTrailingIconColor) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = textColor.hashCode() |
| result = 31 * result + leadingIconColor.hashCode() |
| result = 31 * result + trailingIconColor.hashCode() |
| result = 31 * result + disabledTextColor.hashCode() |
| result = 31 * result + disabledLeadingIconColor.hashCode() |
| result = 31 * result + disabledTrailingIconColor.hashCode() |
| return result |
| } |
| } |
| |
| // Size defaults. |
| internal val MenuVerticalMargin = 48.dp |
| private val DropdownMenuItemHorizontalPadding = 12.dp |
| internal val DropdownMenuVerticalPadding = 8.dp |
| private val DropdownMenuItemDefaultMinWidth = 112.dp |
| private val DropdownMenuItemDefaultMaxWidth = 280.dp |
| |
| // Menu open/close animation. |
| internal const val InTransitionDuration = 120 |
| internal const val OutTransitionDuration = 75 |