/*
* 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.BorderStroke
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.Interaction
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.material3.tokens.ElevationTokens
import androidx.compose.material3.tokens.ListTokens
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import kotlin.math.max
import kotlin.math.min
/**
* Material Design dropdown menu.
*
* Menus display a list of choices on a temporary surface. They appear when users interact with a
* button, action, or other control.
*
* 
*
* A [DropdownMenu] behaves similarly to a [Popup], and will use the position of the parent layout
* to position itself on screen. Commonly a [DropdownMenu] will be placed in a [Box] with a sibling
* that will be used as the 'anchor'. Note that a [DropdownMenu] by itself will not take up any
* space in a layout, as the menu is displayed in a separate window, on top of other content.
*
* The [content] of a [DropdownMenu] will typically be [DropdownMenuItem]s, as well as custom
* content. Using [DropdownMenuItem]s will result in a menu that matches the Material specification
* for menus. Also note that the [content] is placed inside a scrollable [Column], so using a
* [LazyColumn] as the root layout inside [content] is unsupported.
*
* [onDismissRequest] will be called when the menu should close - for example when there is a tap
* outside the menu, or when the back key is pressed.
*
* [DropdownMenu] changes its positioning depending on the available space, always trying to be
* fully visible. Depending on layout direction, first it will try to align its start to the start
* of its parent, then its end to the end of its parent, and then to the edge of the window.
* Vertically, it will try to align its top to the bottom of its parent, then its bottom to top of
* its parent, and then to the edge of the window.
*
* An [offset] can be provided to adjust the positioning of the menu for cases when the layout
* bounds of its parent do not coincide with its visual bounds.
*
* Example usage:
*
* @sample androidx.compose.material3.samples.MenuSample
*
* Example usage with a [ScrollState] to control the menu items scroll position:
*
* @sample androidx.compose.material3.samples.MenuWithScrollStateSample
*
* @param expanded whether the menu is expanded or not
* @param onDismissRequest called when the user requests to dismiss the menu, such as by tapping
* outside the menu's bounds
* @param modifier [Modifier] to be applied to the menu's content
* @param offset [DpOffset] from the original position of the menu. The offset respects the
* [LayoutDirection], so the offset's x position will be added in LTR and subtracted in RTL.
* @param scrollState a [ScrollState] to used by the menu's content for items vertical scrolling
* @param properties [PopupProperties] for further customization of this popup's behavior
* @param shape the shape of the menu
* @param containerColor the container color of the menu
* @param tonalElevation when [containerColor] is [ColorScheme.surface], a translucent primary color
* overlay is applied on top of the container. A higher tonal elevation value will result in a
* darker color in light theme and lighter color in dark theme. See also: [Surface].
* @param shadowElevation the elevation for the shadow below the menu
* @param border the border to draw around the container of the menu. Pass `null` for no border.
* @param content the content of this dropdown menu, typically a [DropdownMenuItem]
*/
@Composable
expect fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
scrollState: ScrollState = rememberScrollState(),
properties: PopupProperties = DefaultMenuProperties,
shape: Shape = MenuDefaults.shape,
containerColor: Color = MenuDefaults.containerColor,
tonalElevation: Dp = MenuDefaults.TonalElevation,
shadowElevation: Dp = MenuDefaults.ShadowElevation,
border: BorderStroke? = null,
content: @Composable ColumnScope.() -> Unit
)
/**
* Material Design dropdown menu item.
*
* Menus display a list of choices on a temporary surface. They appear when users interact with a
* button, action, or other control.
*
* 
*
* Example usage:
*
* @sample androidx.compose.material3.samples.MenuSample
*
* @param text text of the menu item
* @param onClick called when this menu item is clicked
* @param modifier the [Modifier] to be applied to this menu item
* @param leadingIcon optional leading icon to be displayed at the beginning of the item's text
* @param trailingIcon optional trailing icon to be displayed at the end of the item's text. This
* trailing icon slot can also accept [Text] to indicate a keyboard shortcut.
* @param enabled controls the enabled state of this menu item. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param colors [MenuItemColors] that will be used to resolve the colors used for this menu item in
* different states. See [MenuDefaults.itemColors].
* @param contentPadding the padding applied to the content of this menu item
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this menu item. You can use this to change the menu item's
* appearance or preview the menu item in different states. Note that if `null` is provided,
* interactions will still happen internally.
*/
@Composable
expect fun DropdownMenuItem(
text: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
colors: MenuItemColors = MenuDefaults.itemColors(),
contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding,
interactionSource: MutableInteractionSource? = null,
)
/** Contains default values used for [DropdownMenu] and [DropdownMenuItem]. */
object MenuDefaults {
/** The default tonal elevation for a menu. */
val TonalElevation = ElevationTokens.Level0
/** The default shadow elevation for a menu. */
val ShadowElevation = MenuTokens.ContainerElevation
/** The default shape for a menu. */
val shape
@Composable get() = MenuTokens.ContainerShape.value
/** The default container color for a menu. */
val containerColor
@Composable get() = MenuTokens.ContainerColor.value
/**
* Creates a [MenuItemColors] that represents the default text and icon colors used in a
* [DropdownMenuItemContent].
*/
@Composable fun itemColors() = MaterialTheme.colorScheme.defaultMenuItemColors
/**
* 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 = Color.Unspecified,
leadingIconColor: Color = Color.Unspecified,
trailingIconColor: Color = Color.Unspecified,
disabledTextColor: Color = Color.Unspecified,
disabledLeadingIconColor: Color = Color.Unspecified,
disabledTrailingIconColor: Color = Color.Unspecified,
): MenuItemColors =
MaterialTheme.colorScheme.defaultMenuItemColors.copy(
textColor = textColor,
leadingIconColor = leadingIconColor,
trailingIconColor = trailingIconColor,
disabledTextColor = disabledTextColor,
disabledLeadingIconColor = disabledLeadingIconColor,
disabledTrailingIconColor = disabledTrailingIconColor,
)
internal val ColorScheme.defaultMenuItemColors: MenuItemColors
get() {
return defaultMenuItemColorsCached
?: MenuItemColors(
textColor = fromToken(ListTokens.ListItemLabelTextColor),
leadingIconColor = fromToken(ListTokens.ListItemLeadingIconColor),
trailingIconColor = fromToken(ListTokens.ListItemTrailingIconColor),
disabledTextColor =
fromToken(ListTokens.ListItemDisabledLabelTextColor)
.copy(alpha = ListTokens.ListItemDisabledLabelTextOpacity),
disabledLeadingIconColor =
fromToken(ListTokens.ListItemDisabledLeadingIconColor)
.copy(alpha = ListTokens.ListItemDisabledLeadingIconOpacity),
disabledTrailingIconColor =
fromToken(ListTokens.ListItemDisabledTrailingIconColor)
.copy(alpha = ListTokens.ListItemDisabledTrailingIconOpacity),
)
.also { defaultMenuItemColorsCached = it }
}
/** Default padding used for [DropdownMenuItem]. */
val DropdownMenuItemContentPadding =
PaddingValues(horizontal = DropdownMenuItemHorizontalPadding, vertical = 0.dp)
}
// TODO: Consider to move into public [MenuDefaults]
internal expect val DefaultMenuProperties: PopupProperties
/**
* Represents the text and icon colors used in a menu item at different states.
*
* @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
* @constructor create an instance with arbitrary colors. See [MenuDefaults.itemColors] for the
* default colors used in a [DropdownMenuItemContent].
*/
@Immutable
class MenuItemColors(
val textColor: Color,
val leadingIconColor: Color,
val trailingIconColor: Color,
val disabledTextColor: Color,
val disabledLeadingIconColor: Color,
val disabledTrailingIconColor: Color,
) {
/**
* Returns a copy of this MenuItemColors, optionally overriding some of the values. This uses
* the Color.Unspecified to mean “use the value from the source”
*/
fun copy(
textColor: Color = this.textColor,
leadingIconColor: Color = this.leadingIconColor,
trailingIconColor: Color = this.trailingIconColor,
disabledTextColor: Color = this.disabledTextColor,
disabledLeadingIconColor: Color = this.disabledLeadingIconColor,
disabledTrailingIconColor: Color = this.disabledTrailingIconColor,
) =
MenuItemColors(
textColor.takeOrElse { this.textColor },
leadingIconColor.takeOrElse { this.leadingIconColor },
trailingIconColor.takeOrElse { this.trailingIconColor },
disabledTextColor.takeOrElse { this.disabledTextColor },
disabledLeadingIconColor.takeOrElse { this.disabledLeadingIconColor },
disabledTrailingIconColor.takeOrElse { this.disabledTrailingIconColor },
)
/**
* Represents the text color for a menu item, depending on its [enabled] state.
*
* @param enabled whether the menu item is enabled
*/
@Stable
internal fun textColor(enabled: Boolean): Color = if (enabled) textColor else disabledTextColor
/**
* Represents the leading icon color for a menu item, depending on its [enabled] state.
*
* @param enabled whether the menu item is enabled
*/
@Stable
internal fun leadingIconColor(enabled: Boolean): Color =
if (enabled) leadingIconColor else disabledLeadingIconColor
/**
* Represents the trailing icon color for a menu item, depending on its [enabled] state.
*
* @param enabled whether the menu item is enabled
*/
@Stable
internal fun trailingIconColor(enabled: Boolean): Color =
if (enabled) trailingIconColor else disabledTrailingIconColor
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || other !is MenuItemColors) return false
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
}
}
@Composable
internal fun DropdownMenuContent(
modifier: Modifier,
expandedState: MutableTransitionState,
transformOriginState: MutableState,
scrollState: ScrollState,
shape: Shape,
containerColor: Color,
tonalElevation: Dp,
shadowElevation: Dp,
border: BorderStroke?,
content: @Composable ColumnScope.() -> Unit
) {
// Menu open/close animation.
@Suppress("DEPRECATION") val transition = updateTransition(expandedState, "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)
}
}
) { expanded ->
if (expanded) ExpandedScaleTarget else ClosedScaleTarget
}
val alpha by
transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = 30)
} else {
// Expanded to dismissed.
tween(durationMillis = OutTransitionDuration)
}
}
) { expanded ->
if (expanded) ExpandedAlphaTarget else ClosedAlphaTarget
}
val isInspecting = LocalInspectionMode.current
Surface(
modifier =
Modifier.graphicsLayer {
scaleX =
if (!isInspecting) scale
else if (expandedState.targetState) ExpandedScaleTarget else ClosedScaleTarget
scaleY =
if (!isInspecting) scale
else if (expandedState.targetState) ExpandedScaleTarget else ClosedScaleTarget
this.alpha =
if (!isInspecting) alpha
else if (expandedState.targetState) ExpandedAlphaTarget else ClosedAlphaTarget
transformOrigin = transformOriginState.value
},
shape = shape,
color = containerColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
border = border,
) {
Column(
modifier =
modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(scrollState),
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 = rippleOrFallbackImplementation(true)
)
.fillMaxWidth()
// Preferred min and max width used during the intrinsic measurement.
.sizeIn(
minWidth = DropdownMenuItemDefaultMinWidth,
maxWidth = DropdownMenuItemDefaultMaxWidth,
minHeight = MenuListItemContainerHeight
)
.padding(contentPadding),
verticalAlignment = Alignment.CenterVertically
) {
// TODO(b/271818892): Align menu list item style with general list item style.
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
if (leadingIcon != null) {
CompositionLocalProvider(
LocalContentColor provides colors.leadingIconColor(enabled),
) {
Box(Modifier.defaultMinSize(minWidth = ListTokens.ListItemLeadingIconSize)) {
leadingIcon()
}
}
}
CompositionLocalProvider(LocalContentColor provides colors.textColor(enabled)) {
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)
) {
Box(Modifier.defaultMinSize(minWidth = ListTokens.ListItemTrailingIconSize)) {
trailingIcon()
}
}
}
}
}
}
internal fun calculateTransformOrigin(anchorBounds: IntRect, menuBounds: IntRect): TransformOrigin {
val pivotX =
when {
menuBounds.left >= anchorBounds.right -> 0f
menuBounds.right <= anchorBounds.left -> 1f
menuBounds.width == 0 -> 0f
else -> {
val intersectionCenter =
(max(anchorBounds.left, menuBounds.left) +
min(anchorBounds.right, menuBounds.right)) / 2
(intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
}
}
val pivotY =
when {
menuBounds.top >= anchorBounds.bottom -> 0f
menuBounds.bottom <= anchorBounds.top -> 1f
menuBounds.height == 0 -> 0f
else -> {
val intersectionCenter =
(max(anchorBounds.top, menuBounds.top) +
min(anchorBounds.bottom, menuBounds.bottom)) / 2
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
}
}
return TransformOrigin(pivotX, pivotY)
}
// Size defaults.
internal val MenuVerticalMargin = 48.dp
private val MenuListItemContainerHeight = 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
internal const val ExpandedScaleTarget = 1f
internal const val ClosedScaleTarget = 0.8f
internal const val ExpandedAlphaTarget = 1f
internal const val ClosedAlphaTarget = 0f