[go: nahoru, domu]

blob: 478e733110e5fb8489abba5f79f12bb5eaeabdeb [file] [log] [blame]
Mihai Popa63fbc242020-04-28 13:52:33 +01001/*
2 * Copyright 2020 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.ui.material
18
Louis Pullen-Freilich5bb0c4712020-07-20 22:01:15 +010019import androidx.compose.animation.core.FloatPropKey
20import androidx.compose.animation.core.LinearOutSlowInEasing
21import androidx.compose.animation.core.transitionDefinition
22import androidx.compose.animation.core.tween
Mihai Popa63fbc242020-04-28 13:52:33 +010023import androidx.compose.Composable
Mihai Popad109a562020-05-12 17:40:50 +010024import androidx.compose.Immutable
Mihai Popae2202972020-05-12 03:18:57 +010025import androidx.compose.getValue
Mihai Popa49b243e2020-06-22 13:01:36 +010026import androidx.compose.remember
Mihai Popae2202972020-05-12 03:18:57 +010027import androidx.compose.setValue
28import androidx.compose.state
Louis Pullen-Freilichbb77c7b2020-07-20 22:23:06 +010029import androidx.compose.animation.transition
Mihai Popa63fbc242020-04-28 13:52:33 +010030import androidx.ui.core.DensityAmbient
Mihai Popa49b243e2020-06-22 13:01:36 +010031import androidx.ui.core.DrawLayerModifier
Louis Pullen-Freilicha7eeb102020-07-22 17:54:24 +010032import androidx.compose.ui.unit.LayoutDirection
Mihai Popa63fbc242020-04-28 13:52:33 +010033import androidx.ui.core.Modifier
Mihai Popad109a562020-05-12 17:40:50 +010034import androidx.ui.core.Popup
35import androidx.ui.core.PopupPositionProvider
Mihai Popa49b243e2020-06-22 13:01:36 +010036import androidx.ui.core.TransformOrigin
Louis Pullen-Freilichddda7be2020-07-17 18:28:12 +010037import androidx.compose.foundation.Box
38import androidx.compose.foundation.ContentGravity
39import androidx.compose.foundation.ProvideTextStyle
40import androidx.compose.foundation.ScrollableColumn
41import androidx.compose.foundation.clickable
Louis Pullen-Freilich623e4052020-07-19 20:24:03 +010042import androidx.compose.foundation.layout.ColumnScope
43import androidx.compose.foundation.layout.ExperimentalLayout
44import androidx.compose.foundation.layout.IntrinsicSize
45import androidx.compose.foundation.layout.fillMaxWidth
46import androidx.compose.foundation.layout.padding
47import androidx.compose.foundation.layout.preferredSizeIn
48import androidx.compose.foundation.layout.preferredWidth
Mihai Popa6df744e2020-05-29 16:45:07 +010049import androidx.ui.material.ripple.RippleIndication
Louis Pullen-Freilicha7eeb102020-07-22 17:54:24 +010050import androidx.compose.ui.unit.Density
51import androidx.compose.ui.unit.IntBounds
52import androidx.compose.ui.unit.IntOffset
53import androidx.compose.ui.unit.IntSize
54import androidx.compose.ui.unit.Position
55import androidx.compose.ui.unit.dp
56import androidx.compose.ui.unit.height
57import androidx.compose.ui.unit.width
Mihai Popa49b243e2020-06-22 13:01:36 +010058import kotlin.math.max
59import kotlin.math.min
60
Mihai Popa63fbc242020-04-28 13:52:33 +010061/**
62 * A Material Design [dropdown menu](https://material.io/components/menus#dropdown-menu).
63 *
64 * The menu has a [toggle], which is the element generating the menu. For example, this can be
65 * an icon which, when tapped, triggers the menu.
66 * The content of the [DropdownMenu] can be [DropdownMenuItem]s, as well as custom content.
67 * [DropdownMenuItem] can be used to achieve items as defined by the Material Design spec.
68 * [onDismissRequest] will be called when the menu should close - for example when there is a
69 * tap outside the menu, or when the back key is pressed.
Mihai Popad109a562020-05-12 17:40:50 +010070 * The menu will do a best effort to be fully visible on screen. It will try to expand
71 * horizontally, depending on layout direction, to the end of the [toggle], then to the start of
72 * the [toggle], and then screen end-aligned. Vertically, it will try to expand to the bottom
73 * of the [toggle], then from the top of the [toggle], and then screen top-aligned. A
74 * [dropdownOffset] can be provided to adjust the positioning of the menu for cases when the
75 * layout bounds of the [toggle] do not coincide with its visual bounds.
Mihai Popa63fbc242020-04-28 13:52:33 +010076 *
77 * Example usage:
78 * @sample androidx.ui.material.samples.MenuSample
79 *
80 * @param toggle The element generating the menu
81 * @param expanded Whether the menu is currently open or dismissed
82 * @param onDismissRequest Called when the menu should be dismiss
83 * @param toggleModifier The modifier to be applied to the toggle
Mihai Popad109a562020-05-12 17:40:50 +010084 * @param dropdownOffset Offset to be added to the position of the menu
Mihai Popa63fbc242020-04-28 13:52:33 +010085 * @param dropdownModifier Modifier to be applied to the menu content
86 */
87@Composable
88fun DropdownMenu(
89 toggle: @Composable () -> Unit,
90 expanded: Boolean,
91 onDismissRequest: () -> Unit,
92 toggleModifier: Modifier = Modifier,
Mihai Popad109a562020-05-12 17:40:50 +010093 dropdownOffset: Position = Position(0.dp, 0.dp),
Mihai Popa63fbc242020-04-28 13:52:33 +010094 dropdownModifier: Modifier = Modifier,
95 dropdownContent: @Composable ColumnScope.() -> Unit
96) {
Mihai Popae2202972020-05-12 03:18:57 +010097 var visibleMenu by state { expanded }
98 if (expanded) visibleMenu = true
99
Mihai Popa63fbc242020-04-28 13:52:33 +0100100 Box(toggleModifier) {
101 toggle()
102
Mihai Popae2202972020-05-12 03:18:57 +0100103 if (visibleMenu) {
Mihai Popa49b243e2020-06-22 13:01:36 +0100104 var transformOrigin by state { TransformOrigin.Center }
105 val density = DensityAmbient.current
Mihai Popad109a562020-05-12 17:40:50 +0100106 val popupPositionProvider = DropdownMenuPositionProvider(
107 dropdownOffset,
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100108 density
Mihai Popa49b243e2020-06-22 13:01:36 +0100109 ) { parentBounds, menuBounds ->
110 transformOrigin = calculateTransformOrigin(parentBounds, menuBounds, density)
111 }
Mihai Popad109a562020-05-12 17:40:50 +0100112
113 Popup(
Mihai Popa63fbc242020-04-28 13:52:33 +0100114 isFocusable = true,
115 onDismissRequest = onDismissRequest,
Mihai Popad109a562020-05-12 17:40:50 +0100116 popupPositionProvider = popupPositionProvider
Mihai Popa63fbc242020-04-28 13:52:33 +0100117 ) {
Doris Liud2aa99c2020-07-07 15:11:15 -0700118 val state = transition(
Mihai Popae2202972020-05-12 03:18:57 +0100119 definition = DropdownMenuOpenCloseTransition,
120 initState = !expanded,
121 toState = expanded,
122 onStateChangeFinished = {
123 visibleMenu = it
124 }
Doris Liud2aa99c2020-07-07 15:11:15 -0700125 )
126 val drawLayer = remember {
127 MenuDrawLayerModifier(
128 { state[Scale] },
129 { state[Alpha] },
130 { transformOrigin }
131 )
132 }
133 Card(
134 modifier = drawLayer
135 // MenuVerticalMargin corresponds to the one Material row margin
136 // required between the menu and the display edges. The
137 // MenuElevationInset is needed for drawing the elevation,
138 // otherwise it is clipped. TODO(popam): remove it after b/156890315
139 .padding(MenuElevationInset, MenuVerticalMargin),
140 elevation = MenuElevation
141 ) {
142 @OptIn(ExperimentalLayout::class)
Matvei Malkov235b4fa2020-07-07 21:14:49 +0100143 ScrollableColumn(
Doris Liud2aa99c2020-07-07 15:11:15 -0700144 modifier = dropdownModifier
145 .padding(vertical = DropdownMenuVerticalPadding)
146 .preferredWidth(IntrinsicSize.Max),
147 children = dropdownContent
148 )
Mihai Popa63fbc242020-04-28 13:52:33 +0100149 }
150 }
151 }
152 }
153}
154
155/**
156 * A dropdown menu item, as defined by the Material Design spec.
157 *
158 * Example usage:
159 * @sample androidx.ui.material.samples.MenuSample
160 *
161 * @param onClick Called when the menu item was clicked
162 * @param modifier The modifier to be applied to the menu item
163 * @param enabled Controls the enabled state of the menu item - when `false`, the menu item
164 * will not be clickable and [onClick] will not be invoked
165 */
166@Composable
167fun DropdownMenuItem(
168 onClick: () -> Unit,
169 modifier: Modifier = Modifier,
170 enabled: Boolean = true,
171 content: @Composable () -> Unit
172) {
173 // TODO(popam, b/156911853): investigate replacing this Box with ListItem
174 Box(
175 modifier = modifier
Mihai Popa6df744e2020-05-29 16:45:07 +0100176 .clickable(enabled = enabled, onClick = onClick, indication = RippleIndication(true))
Mihai Popa63fbc242020-04-28 13:52:33 +0100177 .fillMaxWidth()
178 // Preferred min and max width used during the intrinsic measurement.
179 .preferredSizeIn(
180 minWidth = DropdownMenuItemDefaultMinWidth,
181 maxWidth = DropdownMenuItemDefaultMaxWidth,
182 minHeight = DropdownMenuItemDefaultMinHeight
183 )
184 .padding(horizontal = DropdownMenuHorizontalPadding),
185 gravity = ContentGravity.CenterStart
186 ) {
187 // TODO(popam, b/156912039): update emphasis if the menu item is disabled
188 val typography = MaterialTheme.typography
189 val emphasisLevels = EmphasisAmbient.current
190 ProvideTextStyle(typography.subtitle1) {
Mihai Popae5ad7c82020-05-20 18:09:41 +0100191 ProvideEmphasis(
192 if (enabled) emphasisLevels.high else emphasisLevels.disabled,
193 content
194 )
Mihai Popa63fbc242020-04-28 13:52:33 +0100195 }
196 }
197}
198
Mihai Popad109a562020-05-12 17:40:50 +0100199// Size constants.
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100200private val MenuElevation = 8.dp
Mihai Popaf3148062020-06-25 13:19:45 +0100201internal val MenuElevationInset = 32.dp
202private val MenuVerticalMargin = 32.dp
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100203private val DropdownMenuHorizontalPadding = 16.dp
Mihai Popa63fbc242020-04-28 13:52:33 +0100204internal val DropdownMenuVerticalPadding = 8.dp
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100205private val DropdownMenuItemDefaultMinWidth = 112.dp
206private val DropdownMenuItemDefaultMaxWidth = 280.dp
207private val DropdownMenuItemDefaultMinHeight = 48.dp
Mihai Popae2202972020-05-12 03:18:57 +0100208
Mihai Popad109a562020-05-12 17:40:50 +0100209// Menu open/close animation.
Mihai Popae2202972020-05-12 03:18:57 +0100210private val Scale = FloatPropKey()
211private val Alpha = FloatPropKey()
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100212internal const val InTransitionDuration = 120
213internal const val OutTransitionDuration = 75
Mihai Popae2202972020-05-12 03:18:57 +0100214
215private val DropdownMenuOpenCloseTransition = transitionDefinition {
216 state(false) {
217 // Menu is dismissed.
Mihai Popada3a2462020-06-22 13:01:36 +0100218 this[Scale] = 0.8f
Mihai Popae2202972020-05-12 03:18:57 +0100219 this[Alpha] = 0f
220 }
221 state(true) {
222 // Menu is expanded.
223 this[Scale] = 1f
224 this[Alpha] = 1f
225 }
226 transition(false, true) {
227 // Dismissed to expanded.
Doris Liua69d17b2020-06-19 16:39:42 -0700228 Scale using tween(
229 durationMillis = InTransitionDuration,
Mihai Popae2202972020-05-12 03:18:57 +0100230 easing = LinearOutSlowInEasing
Doris Liua69d17b2020-06-19 16:39:42 -0700231 )
232 Alpha using tween(
233 durationMillis = 30
234 )
Mihai Popae2202972020-05-12 03:18:57 +0100235 }
236 transition(true, false) {
237 // Expanded to dismissed.
Doris Liua69d17b2020-06-19 16:39:42 -0700238 Scale using tween(
239 durationMillis = 1,
240 delayMillis = OutTransitionDuration - 1
241 )
242 Alpha using tween(
243 durationMillis = OutTransitionDuration
244 )
Mihai Popae2202972020-05-12 03:18:57 +0100245 }
246}
Mihai Popad109a562020-05-12 17:40:50 +0100247
Mihai Popa49b243e2020-06-22 13:01:36 +0100248private class MenuDrawLayerModifier(
249 val scaleProvider: () -> Float,
250 val alphaProvider: () -> Float,
251 val transformOriginProvider: () -> TransformOrigin
252) : DrawLayerModifier {
253 override val scaleX: Float get() = scaleProvider()
254 override val scaleY: Float get() = scaleProvider()
255 override val alpha: Float get() = alphaProvider()
256 override val transformOrigin: TransformOrigin get() = transformOriginProvider()
257 override val clip: Boolean = true
258}
259
260private fun calculateTransformOrigin(
Mihai Popab7ffbed2020-07-06 13:25:18 +0100261 parentBounds: IntBounds,
262 menuBounds: IntBounds,
Mihai Popa49b243e2020-06-22 13:01:36 +0100263 density: Density
264): TransformOrigin {
Mihai Popab7ffbed2020-07-06 13:25:18 +0100265 val inset = with(density) { MenuElevationInset.toIntPx() }
266 val realMenuBounds = IntBounds(
Mihai Popa49b243e2020-06-22 13:01:36 +0100267 menuBounds.left + inset,
268 menuBounds.top + inset,
269 menuBounds.right - inset,
270 menuBounds.bottom - inset
271 )
272 val pivotX = when {
Mihai Popab7ffbed2020-07-06 13:25:18 +0100273 realMenuBounds.left >= parentBounds.right -> 0
274 realMenuBounds.right <= parentBounds.left -> 1
Mihai Popa49b243e2020-06-22 13:01:36 +0100275 else -> {
276 val intersectionCenter =
277 (max(parentBounds.left, realMenuBounds.left) +
278 min(parentBounds.right, realMenuBounds.right)) / 2
279 (intersectionCenter + inset - menuBounds.left) / menuBounds.width
280 }
281 }
282 val pivotY = when {
Mihai Popab7ffbed2020-07-06 13:25:18 +0100283 realMenuBounds.top >= parentBounds.bottom -> 0
284 realMenuBounds.bottom <= parentBounds.top -> 1
Mihai Popa49b243e2020-06-22 13:01:36 +0100285 else -> {
286 val intersectionCenter =
287 (max(parentBounds.top, realMenuBounds.top) +
288 min(parentBounds.bottom, realMenuBounds.bottom)) / 2
289 (intersectionCenter + inset - menuBounds.top) / menuBounds.height
290 }
291 }
Mihai Popab7ffbed2020-07-06 13:25:18 +0100292 return TransformOrigin(pivotX.toFloat(), pivotY.toFloat())
Mihai Popa49b243e2020-06-22 13:01:36 +0100293}
294
Mihai Popad109a562020-05-12 17:40:50 +0100295// Menu positioning.
296
297/**
298 * Calculates the position of a Material [DropdownMenu].
299 */
300// TODO(popam): Investigate if this can/should consider the app window size rather than screen size
301@Immutable
302internal data class DropdownMenuPositionProvider(
303 val contentOffset: Position,
304 val density: Density,
Mihai Popab7ffbed2020-07-06 13:25:18 +0100305 val onPositionCalculated: (IntBounds, IntBounds) -> Unit = { _, _ -> }
Mihai Popad109a562020-05-12 17:40:50 +0100306) : PopupPositionProvider {
307 override fun calculatePosition(
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100308 parentGlobalBounds: IntBounds,
309 windowGlobalBounds: IntBounds,
Mihai Popad109a562020-05-12 17:40:50 +0100310 layoutDirection: LayoutDirection,
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100311 popupContentSize: IntSize
George Mount8f237572020-04-30 12:08:30 -0700312 ): IntOffset {
Mihai Popaf3148062020-06-25 13:19:45 +0100313 // The min margin above and below the menu, relative to the screen.
314 val verticalMargin = with(density) { MenuVerticalMargin.toIntPx() }
Mihai Popad109a562020-05-12 17:40:50 +0100315 // The padding inset that accommodates elevation, needs to be taken into account.
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100316 val inset = with(density) { MenuElevationInset.toIntPx() }
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100317 val realPopupWidth = popupContentSize.width - inset * 2
318 val realPopupHeight = popupContentSize.height - inset * 2
Mihai Popad109a562020-05-12 17:40:50 +0100319 val contentOffsetX = with(density) { contentOffset.x.toIntPx() }
320 val contentOffsetY = with(density) { contentOffset.y.toIntPx() }
Mihai Popad109a562020-05-12 17:40:50 +0100321
322 // Compute horizontal position.
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100323 val toRight = parentGlobalBounds.right + contentOffsetX
324 val toLeft = parentGlobalBounds.left - contentOffsetX - realPopupWidth
325 val toDisplayRight = windowGlobalBounds.width - realPopupWidth
George Mount8f237572020-04-30 12:08:30 -0700326 val toDisplayLeft = 0
Mihai Popad109a562020-05-12 17:40:50 +0100327 val x = if (layoutDirection == LayoutDirection.Ltr) {
328 sequenceOf(toRight, toLeft, toDisplayRight)
329 } else {
330 sequenceOf(toLeft, toRight, toDisplayLeft)
331 }.firstOrNull {
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100332 it >= 0 && it + realPopupWidth <= windowGlobalBounds.width
Mihai Popad109a562020-05-12 17:40:50 +0100333 } ?: toLeft
334
335 // Compute vertical position.
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100336 val toBottom = parentGlobalBounds.bottom + contentOffsetY
337 val toTop = parentGlobalBounds.top - contentOffsetY - realPopupHeight
338 val toCenter = parentGlobalBounds.top - realPopupHeight / 2
339 val toDisplayBottom = windowGlobalBounds.height - realPopupHeight - verticalMargin
Mihai Popad109a562020-05-12 17:40:50 +0100340 val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
Mihai Popaf3148062020-06-25 13:19:45 +0100341 it >= verticalMargin &&
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100342 it + realPopupHeight <= windowGlobalBounds.height - verticalMargin
Mihai Popad109a562020-05-12 17:40:50 +0100343 } ?: toTop
344
Mihai Popa49b243e2020-06-22 13:01:36 +0100345 onPositionCalculated(
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100346 parentGlobalBounds,
Mihai Popab7ffbed2020-07-06 13:25:18 +0100347 IntBounds(x - inset, y - inset, x + inset + realPopupWidth, y + inset + realPopupHeight)
Mihai Popa49b243e2020-06-22 13:01:36 +0100348 )
George Mount8f237572020-04-30 12:08:30 -0700349 return IntOffset(x - inset, y - inset)
Mihai Popad109a562020-05-12 17:40:50 +0100350 }
351}