
commonMain.androidx.compose.material.Menu.kt Maven / Gradle / Ivy
/*
* Copyright 2020 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.material
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
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.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.RowScope
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.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
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.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kotlin.math.max
import kotlin.math.min
/**
* Material Design dropdown menu.
*
* A dropdown menu is a compact way of displaying multiple choices. It appears upon interaction with
* an element (such as an icon or button) or when users perform a specific action.
*
* 
*
* 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. It will try to expand horizontally, depending on layout direction, to the end of
* its parent, then to the start of its parent, and then screen end-aligned. Vertically, it will
* try to expand to the bottom of its parent, then from the top of its parent, and then screen
* top-aligned. 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. Note the offset will
* be applied in the direction in which the menu will decide to expand.
*
* Example usage:
* @sample androidx.compose.material.samples.MenuSample
*
* Example usage with a [ScrollState] to control the menu items scroll position:
* @sample androidx.compose.material.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] to be added to the position of the menu
* @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 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 = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit
)
/**
* Material Design dropdown menu item.
*
*
* Example usage:
* @sample androidx.compose.material.samples.MenuSample
*
* @param onClick Called when the menu item was clicked
* @param modifier The modifier to be applied to the menu item
* @param enabled Controls the enabled state of the menu item - when `false`, the menu item
* will not be clickable and [onClick] will not be invoked
* @param contentPadding the padding applied to the content of this menu item
* @param interactionSource the [MutableInteractionSource] representing the stream of
* [Interaction]s for this DropdownMenuItem. You can create and pass in your own remembered
* [MutableInteractionSource] if you want to observe [Interaction]s and customize the
* appearance / behavior of this DropdownMenuItem in different [Interaction]s.
*/
@Composable
expect fun DropdownMenuItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit
)
@Composable
internal fun DropdownMenuContent(
expandedStates: MutableTransitionState,
transformOriginState: MutableState,
scrollState: ScrollState,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
// Menu open/close animation.
val transition = rememberTransition(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
}
}
Card(
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = transformOriginState.value
},
elevation = MenuElevation
) {
Column(
modifier = modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(scrollState),
content = content
)
}
}
@Composable
internal fun DropdownMenuItemContent(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding,
interactionSource: MutableInteractionSource? = null,
content: @Composable RowScope.() -> Unit
) {
// TODO(popam, b/156911853): investigate replacing this Row with ListItem
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 = DropdownMenuItemDefaultMinHeight
)
.padding(contentPadding),
verticalAlignment = Alignment.CenterVertically
) {
val typography = MaterialTheme.typography
ProvideTextStyle(typography.subtitle1) {
val contentAlpha = if (enabled) ContentAlpha.high else ContentAlpha.disabled
CompositionLocalProvider(LocalContentAlpha provides contentAlpha) {
content()
}
}
}
}
/**
* Contains default values used for [DropdownMenuItem].
*/
object MenuDefaults {
/**
* Default padding used for [DropdownMenuItem].
*/
val DropdownMenuItemContentPadding = PaddingValues(
horizontal = DropdownMenuItemHorizontalPadding,
vertical = 0.dp
)
}
// Size defaults.
private val MenuElevation = 8.dp
internal val MenuVerticalMargin = 48.dp
private val DropdownMenuItemHorizontalPadding = 16.dp
internal val DropdownMenuVerticalPadding = 8.dp
private val DropdownMenuItemDefaultMinWidth = 112.dp
private val DropdownMenuItemDefaultMaxWidth = 280.dp
private val DropdownMenuItemDefaultMinHeight = 48.dp
// Menu open/close animation.
internal const val InTransitionDuration = 120
internal const val OutTransitionDuration = 75
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].
*/
@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() * (if (layoutDirection == LayoutDirection.Ltr) 1 else -1)
}
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
// Compute horizontal position.
val leftToAnchorLeft = anchorBounds.left + contentOffsetX
val rightToAnchorRight = anchorBounds.right - popupContentSize.width + contentOffsetX
val rightToWindowRight = windowSize.width - popupContentSize.width
val leftToWindowLeft = 0
val x = if (layoutDirection == LayoutDirection.Ltr) {
sequenceOf(
leftToAnchorLeft,
rightToAnchorRight,
// 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) rightToWindowRight else leftToWindowLeft
)
} else {
sequenceOf(
rightToAnchorRight,
leftToAnchorLeft,
// 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) leftToWindowLeft else rightToWindowRight
)
}.firstOrNull {
it >= 0 && it + popupContentSize.width <= windowSize.width
} ?: rightToAnchorRight
// Compute vertical position.
val topToAnchorBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
val bottomToAnchorTop = anchorBounds.top - popupContentSize.height + contentOffsetY
val centerToAnchorTop = anchorBounds.top - popupContentSize.height / 2 + contentOffsetY
val bottomToWindowBottom = windowSize.height - popupContentSize.height - verticalMargin
val y = sequenceOf(
topToAnchorBottom,
bottomToAnchorTop,
centerToAnchorTop,
bottomToWindowBottom
).firstOrNull {
it >= verticalMargin &&
it + popupContentSize.height <= windowSize.height - verticalMargin
} ?: bottomToAnchorTop
onPositionCalculated(
anchorBounds,
IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height)
)
return IntOffset(x, y)
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy