| /* |
| * 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.tween |
| import androidx.compose.animation.core.updateTransition |
| import androidx.compose.foundation.Interaction |
| import androidx.compose.foundation.InteractionState |
| import androidx.compose.foundation.clickable |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.ColumnScope |
| import androidx.compose.foundation.layout.ExperimentalLayout |
| 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.preferredSizeIn |
| import androidx.compose.foundation.layout.preferredWidth |
| import androidx.compose.foundation.rememberScrollState |
| import androidx.compose.foundation.verticalScroll |
| import androidx.compose.material.ripple.rememberRipple |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.Immutable |
| import androidx.compose.runtime.CompositionLocalProvider |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| 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.platform.LocalDensity |
| import androidx.compose.ui.unit.Density |
| import androidx.compose.ui.unit.DpOffset |
| import androidx.compose.ui.unit.IntRect |
| import androidx.compose.ui.unit.IntOffset |
| 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 |
| |
| /** |
| * A Material Design [dropdown menu](https://material.io/components/menus#dropdown-menu). |
| * |
| * 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. |
| * |
| * [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 |
| * |
| * @param expanded Whether the menu is currently open and visible to the user |
| * @param onDismissRequest Called when the user requests to dismiss the menu, such as by |
| * tapping outside the menu's bounds |
| * @param offset [DpOffset] to be added to the position of the menu |
| */ |
| @Suppress("ModifierParameter") |
| @Composable |
| fun DropdownMenu( |
| expanded: Boolean, |
| onDismissRequest: () -> Unit, |
| modifier: Modifier = Modifier, |
| offset: DpOffset = DpOffset(0.dp, 0.dp), |
| properties: PopupProperties = PopupProperties(focusable = true), |
| content: @Composable ColumnScope.() -> Unit |
| ) { |
| val expandedStates = remember { MutableTransitionState(false) } |
| expandedStates.targetState = expanded |
| |
| if (expandedStates.currentState || expandedStates.targetState) { |
| val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } |
| val density = LocalDensity.current |
| val popupPositionProvider = DropdownMenuPositionProvider( |
| offset, |
| density |
| ) { parentBounds, menuBounds -> |
| transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds) |
| } |
| |
| Popup( |
| onDismissRequest = onDismissRequest, |
| popupPositionProvider = popupPositionProvider, |
| properties = properties |
| ) { |
| // 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 |
| } |
| } |
| Card( |
| modifier = Modifier.graphicsLayer { |
| scaleX = scale |
| scaleY = scale |
| this.alpha = alpha |
| transformOrigin = transformOriginState.value |
| }, |
| elevation = MenuElevation |
| ) { |
| @OptIn(ExperimentalLayout::class) |
| Column( |
| modifier = modifier |
| .padding(vertical = DropdownMenuVerticalPadding) |
| .preferredWidth(IntrinsicSize.Max) |
| .verticalScroll(rememberScrollState()), |
| content = content |
| ) |
| } |
| } |
| } |
| } |
| |
| /** |
| * A dropdown menu item, as defined by the Material Design spec. |
| * |
| * 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 interactionState the [InteractionState] representing the different [Interaction]s |
| * present on this DropdownMenuItem. You can create and pass in your own remembered |
| * [InteractionState] if you want to read the [InteractionState] and customize the appearance / |
| * behavior of this DropdownMenuItem in different [Interaction]s. |
| */ |
| @Composable |
| fun DropdownMenuItem( |
| onClick: () -> Unit, |
| modifier: Modifier = Modifier, |
| enabled: Boolean = true, |
| contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, |
| interactionState: InteractionState = remember { InteractionState() }, |
| content: @Composable RowScope.() -> Unit |
| ) { |
| // TODO(popam, b/156911853): investigate replacing this Row with ListItem |
| Row( |
| modifier = modifier |
| .clickable( |
| enabled = enabled, |
| onClick = onClick, |
| interactionState = interactionState, |
| indication = rememberRipple(true) |
| ) |
| .fillMaxWidth() |
| // Preferred min and max width used during the intrinsic measurement. |
| .preferredSizeIn( |
| 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 |
| private val MenuVerticalMargin = 32.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 |
| |
| 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) |
| } |
| |
| // 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, toDisplayRight) |
| } else { |
| sequenceOf(toLeft, toRight, toDisplayLeft) |
| }.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) |
| } |
| } |