[go: nahoru, domu]

blob: d6132e8ba2c77b242cb7be255f9221611c917fd8 [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
Mihai Popad109a562020-05-12 17:40:50 +010019import android.util.DisplayMetrics
Mihai Popae2202972020-05-12 03:18:57 +010020import androidx.animation.FloatPropKey
21import androidx.animation.LinearOutSlowInEasing
22import androidx.animation.transitionDefinition
Doris Liua69d17b2020-06-19 16:39:42 -070023import androidx.animation.tween
Mihai Popa63fbc242020-04-28 13:52:33 +010024import androidx.compose.Composable
Mihai Popad109a562020-05-12 17:40:50 +010025import androidx.compose.Immutable
Mihai Popae2202972020-05-12 03:18:57 +010026import androidx.compose.getValue
Mihai Popa49b243e2020-06-22 13:01:36 +010027import androidx.compose.remember
Mihai Popae2202972020-05-12 03:18:57 +010028import androidx.compose.setValue
29import androidx.compose.state
30import androidx.ui.animation.Transition
Mihai Popad109a562020-05-12 17:40:50 +010031import androidx.ui.core.ContextAmbient
Mihai Popa63fbc242020-04-28 13:52:33 +010032import androidx.ui.core.DensityAmbient
Mihai Popa49b243e2020-06-22 13:01:36 +010033import androidx.ui.core.DrawLayerModifier
Mihai Popad109a562020-05-12 17:40:50 +010034import androidx.ui.core.LayoutDirection
Mihai Popa63fbc242020-04-28 13:52:33 +010035import androidx.ui.core.Modifier
Mihai Popad109a562020-05-12 17:40:50 +010036import androidx.ui.core.Popup
37import androidx.ui.core.PopupPositionProvider
Mihai Popa49b243e2020-06-22 13:01:36 +010038import androidx.ui.core.TransformOrigin
Mihai Popad109a562020-05-12 17:40:50 +010039import androidx.ui.unit.Position
Mihai Popa63fbc242020-04-28 13:52:33 +010040import androidx.ui.foundation.Box
41import androidx.ui.foundation.ContentGravity
42import androidx.ui.foundation.ProvideTextStyle
43import androidx.ui.foundation.clickable
44import androidx.ui.layout.Column
45import androidx.ui.layout.ColumnScope
Mihai Popaa22885d2020-05-26 18:31:21 +010046import androidx.ui.layout.ExperimentalLayout
Mihai Popa63fbc242020-04-28 13:52:33 +010047import androidx.ui.layout.IntrinsicSize
48import androidx.ui.layout.fillMaxWidth
49import androidx.ui.layout.padding
50import androidx.ui.layout.preferredSizeIn
51import androidx.ui.layout.preferredWidth
Mihai Popa6df744e2020-05-29 16:45:07 +010052import androidx.ui.material.ripple.RippleIndication
Mihai Popad109a562020-05-12 17:40:50 +010053import androidx.ui.unit.Density
George Mount8f237572020-04-30 12:08:30 -070054import androidx.ui.unit.IntOffset
55import androidx.ui.unit.IntSize
Mihai Popa49b243e2020-06-22 13:01:36 +010056import androidx.ui.unit.PxBounds
Mihai Popa63fbc242020-04-28 13:52:33 +010057import androidx.ui.unit.dp
Mihai Popa49b243e2020-06-22 13:01:36 +010058import androidx.ui.unit.height
59import androidx.ui.unit.toOffset
60import androidx.ui.unit.toSize
61import androidx.ui.unit.width
62import kotlin.math.max
63import kotlin.math.min
64
Mihai Popa63fbc242020-04-28 13:52:33 +010065/**
66 * A Material Design [dropdown menu](https://material.io/components/menus#dropdown-menu).
67 *
68 * The menu has a [toggle], which is the element generating the menu. For example, this can be
69 * an icon which, when tapped, triggers the menu.
70 * The content of the [DropdownMenu] can be [DropdownMenuItem]s, as well as custom content.
71 * [DropdownMenuItem] can be used to achieve items as defined by the Material Design spec.
72 * [onDismissRequest] will be called when the menu should close - for example when there is a
73 * tap outside the menu, or when the back key is pressed.
Mihai Popad109a562020-05-12 17:40:50 +010074 * The menu will do a best effort to be fully visible on screen. It will try to expand
75 * horizontally, depending on layout direction, to the end of the [toggle], then to the start of
76 * the [toggle], and then screen end-aligned. Vertically, it will try to expand to the bottom
77 * of the [toggle], then from the top of the [toggle], and then screen top-aligned. A
78 * [dropdownOffset] can be provided to adjust the positioning of the menu for cases when the
79 * layout bounds of the [toggle] do not coincide with its visual bounds.
Mihai Popa63fbc242020-04-28 13:52:33 +010080 *
81 * Example usage:
82 * @sample androidx.ui.material.samples.MenuSample
83 *
84 * @param toggle The element generating the menu
85 * @param expanded Whether the menu is currently open or dismissed
86 * @param onDismissRequest Called when the menu should be dismiss
87 * @param toggleModifier The modifier to be applied to the toggle
Mihai Popad109a562020-05-12 17:40:50 +010088 * @param dropdownOffset Offset to be added to the position of the menu
Mihai Popa63fbc242020-04-28 13:52:33 +010089 * @param dropdownModifier Modifier to be applied to the menu content
90 */
91@Composable
92fun DropdownMenu(
93 toggle: @Composable () -> Unit,
94 expanded: Boolean,
95 onDismissRequest: () -> Unit,
96 toggleModifier: Modifier = Modifier,
Mihai Popad109a562020-05-12 17:40:50 +010097 dropdownOffset: Position = Position(0.dp, 0.dp),
Mihai Popa63fbc242020-04-28 13:52:33 +010098 dropdownModifier: Modifier = Modifier,
99 dropdownContent: @Composable ColumnScope.() -> Unit
100) {
Mihai Popae2202972020-05-12 03:18:57 +0100101 var visibleMenu by state { expanded }
102 if (expanded) visibleMenu = true
103
Mihai Popa63fbc242020-04-28 13:52:33 +0100104 Box(toggleModifier) {
105 toggle()
106
Mihai Popae2202972020-05-12 03:18:57 +0100107 if (visibleMenu) {
Mihai Popa49b243e2020-06-22 13:01:36 +0100108 var transformOrigin by state { TransformOrigin.Center }
109 val density = DensityAmbient.current
Mihai Popad109a562020-05-12 17:40:50 +0100110 val popupPositionProvider = DropdownMenuPositionProvider(
111 dropdownOffset,
Mihai Popa49b243e2020-06-22 13:01:36 +0100112 density,
Mihai Popad109a562020-05-12 17:40:50 +0100113 ContextAmbient.current.resources.displayMetrics
Mihai Popa49b243e2020-06-22 13:01:36 +0100114 ) { parentBounds, menuBounds ->
115 transformOrigin = calculateTransformOrigin(parentBounds, menuBounds, density)
116 }
Mihai Popad109a562020-05-12 17:40:50 +0100117
118 Popup(
Mihai Popa63fbc242020-04-28 13:52:33 +0100119 isFocusable = true,
120 onDismissRequest = onDismissRequest,
Mihai Popad109a562020-05-12 17:40:50 +0100121 popupPositionProvider = popupPositionProvider
Mihai Popa63fbc242020-04-28 13:52:33 +0100122 ) {
Mihai Popae2202972020-05-12 03:18:57 +0100123 Transition(
124 definition = DropdownMenuOpenCloseTransition,
125 initState = !expanded,
126 toState = expanded,
127 onStateChangeFinished = {
128 visibleMenu = it
129 }
130 ) { state ->
Mihai Popa49b243e2020-06-22 13:01:36 +0100131 val drawLayer = remember {
132 MenuDrawLayerModifier(
133 { state[Scale] },
134 { state[Alpha] },
135 { transformOrigin }
136 )
137 }
Mihai Popae2202972020-05-12 03:18:57 +0100138 Card(
Mihai Popa49b243e2020-06-22 13:01:36 +0100139 modifier = drawLayer
Mihai Popae2202972020-05-12 03:18:57 +0100140 // Padding to account for the elevation, otherwise it is clipped.
141 .padding(MenuElevation),
142 elevation = MenuElevation
143 ) {
Mihai Popaa22885d2020-05-26 18:31:21 +0100144 @OptIn(ExperimentalLayout::class)
Mihai Popae2202972020-05-12 03:18:57 +0100145 Column(
Mihai Popa49b243e2020-06-22 13:01:36 +0100146 modifier = dropdownModifier
Mihai Popae2202972020-05-12 03:18:57 +0100147 .padding(vertical = DropdownMenuVerticalPadding)
148 .preferredWidth(IntrinsicSize.Max),
149 children = dropdownContent
150 )
151 }
Mihai Popa63fbc242020-04-28 13:52:33 +0100152 }
153 }
154 }
155 }
156}
157
158/**
159 * A dropdown menu item, as defined by the Material Design spec.
160 *
161 * Example usage:
162 * @sample androidx.ui.material.samples.MenuSample
163 *
164 * @param onClick Called when the menu item was clicked
165 * @param modifier The modifier to be applied to the menu item
166 * @param enabled Controls the enabled state of the menu item - when `false`, the menu item
167 * will not be clickable and [onClick] will not be invoked
168 */
169@Composable
170fun DropdownMenuItem(
171 onClick: () -> Unit,
172 modifier: Modifier = Modifier,
173 enabled: Boolean = true,
174 content: @Composable () -> Unit
175) {
176 // TODO(popam, b/156911853): investigate replacing this Box with ListItem
177 Box(
178 modifier = modifier
Mihai Popa6df744e2020-05-29 16:45:07 +0100179 .clickable(enabled = enabled, onClick = onClick, indication = RippleIndication(true))
Mihai Popa63fbc242020-04-28 13:52:33 +0100180 .fillMaxWidth()
181 // Preferred min and max width used during the intrinsic measurement.
182 .preferredSizeIn(
183 minWidth = DropdownMenuItemDefaultMinWidth,
184 maxWidth = DropdownMenuItemDefaultMaxWidth,
185 minHeight = DropdownMenuItemDefaultMinHeight
186 )
187 .padding(horizontal = DropdownMenuHorizontalPadding),
188 gravity = ContentGravity.CenterStart
189 ) {
190 // TODO(popam, b/156912039): update emphasis if the menu item is disabled
191 val typography = MaterialTheme.typography
192 val emphasisLevels = EmphasisAmbient.current
193 ProvideTextStyle(typography.subtitle1) {
Mihai Popae5ad7c82020-05-20 18:09:41 +0100194 ProvideEmphasis(
195 if (enabled) emphasisLevels.high else emphasisLevels.disabled,
196 content
197 )
Mihai Popa63fbc242020-04-28 13:52:33 +0100198 }
199 }
200}
201
Mihai Popad109a562020-05-12 17:40:50 +0100202// Size constants.
Mihai Popa63fbc242020-04-28 13:52:33 +0100203internal val MenuElevation = 8.dp
204internal val DropdownMenuHorizontalPadding = 16.dp
205internal val DropdownMenuVerticalPadding = 8.dp
206internal val DropdownMenuItemDefaultMinWidth = 112.dp
207internal val DropdownMenuItemDefaultMaxWidth = 280.dp
208internal val DropdownMenuItemDefaultMinHeight = 48.dp
Mihai Popae2202972020-05-12 03:18:57 +0100209
Mihai Popad109a562020-05-12 17:40:50 +0100210// Menu open/close animation.
Mihai Popae2202972020-05-12 03:18:57 +0100211private val Scale = FloatPropKey()
212private val Alpha = FloatPropKey()
213internal val InTransitionDuration = 120
214internal val OutTransitionDuration = 75
215
216private val DropdownMenuOpenCloseTransition = transitionDefinition {
217 state(false) {
218 // Menu is dismissed.
Mihai Popada3a2462020-06-22 13:01:36 +0100219 this[Scale] = 0.8f
Mihai Popae2202972020-05-12 03:18:57 +0100220 this[Alpha] = 0f
221 }
222 state(true) {
223 // Menu is expanded.
224 this[Scale] = 1f
225 this[Alpha] = 1f
226 }
227 transition(false, true) {
228 // Dismissed to expanded.
Doris Liua69d17b2020-06-19 16:39:42 -0700229 Scale using tween(
230 durationMillis = InTransitionDuration,
Mihai Popae2202972020-05-12 03:18:57 +0100231 easing = LinearOutSlowInEasing
Doris Liua69d17b2020-06-19 16:39:42 -0700232 )
233 Alpha using tween(
234 durationMillis = 30
235 )
Mihai Popae2202972020-05-12 03:18:57 +0100236 }
237 transition(true, false) {
238 // Expanded to dismissed.
Doris Liua69d17b2020-06-19 16:39:42 -0700239 Scale using tween(
240 durationMillis = 1,
241 delayMillis = OutTransitionDuration - 1
242 )
243 Alpha using tween(
244 durationMillis = OutTransitionDuration
245 )
Mihai Popae2202972020-05-12 03:18:57 +0100246 }
247}
Mihai Popad109a562020-05-12 17:40:50 +0100248
Mihai Popa49b243e2020-06-22 13:01:36 +0100249private class MenuDrawLayerModifier(
250 val scaleProvider: () -> Float,
251 val alphaProvider: () -> Float,
252 val transformOriginProvider: () -> TransformOrigin
253) : DrawLayerModifier {
254 override val scaleX: Float get() = scaleProvider()
255 override val scaleY: Float get() = scaleProvider()
256 override val alpha: Float get() = alphaProvider()
257 override val transformOrigin: TransformOrigin get() = transformOriginProvider()
258 override val clip: Boolean = true
259}
260
261private fun calculateTransformOrigin(
262 parentBounds: PxBounds,
263 menuBounds: PxBounds,
264 density: Density
265): TransformOrigin {
266 val inset = with(density) { MenuElevation.toPx() }
267 val realMenuBounds = PxBounds(
268 menuBounds.left + inset,
269 menuBounds.top + inset,
270 menuBounds.right - inset,
271 menuBounds.bottom - inset
272 )
273 val pivotX = when {
274 realMenuBounds.left >= parentBounds.right -> 0f
275 realMenuBounds.right <= parentBounds.left -> 1f
276 else -> {
277 val intersectionCenter =
278 (max(parentBounds.left, realMenuBounds.left) +
279 min(parentBounds.right, realMenuBounds.right)) / 2
280 (intersectionCenter + inset - menuBounds.left) / menuBounds.width
281 }
282 }
283 val pivotY = when {
284 realMenuBounds.top >= parentBounds.bottom -> 0f
285 realMenuBounds.bottom <= parentBounds.top -> 1f
286 else -> {
287 val intersectionCenter =
288 (max(parentBounds.top, realMenuBounds.top) +
289 min(parentBounds.bottom, realMenuBounds.bottom)) / 2
290 (intersectionCenter + inset - menuBounds.top) / menuBounds.height
291 }
292 }
293 return TransformOrigin(pivotX, pivotY)
294}
295
Mihai Popad109a562020-05-12 17:40:50 +0100296// Menu positioning.
297
298/**
299 * Calculates the position of a Material [DropdownMenu].
300 */
301// TODO(popam): Investigate if this can/should consider the app window size rather than screen size
302@Immutable
303internal data class DropdownMenuPositionProvider(
304 val contentOffset: Position,
305 val density: Density,
Mihai Popa49b243e2020-06-22 13:01:36 +0100306 val displayMetrics: DisplayMetrics,
307 val onPositionCalculated: (PxBounds, PxBounds) -> Unit = { _, _ -> }
Mihai Popad109a562020-05-12 17:40:50 +0100308) : PopupPositionProvider {
309 override fun calculatePosition(
George Mount8f237572020-04-30 12:08:30 -0700310 parentLayoutPosition: IntOffset,
311 parentLayoutSize: IntSize,
Mihai Popad109a562020-05-12 17:40:50 +0100312 layoutDirection: LayoutDirection,
George Mount8f237572020-04-30 12:08:30 -0700313 popupSize: IntSize
314 ): IntOffset {
Mihai Popad109a562020-05-12 17:40:50 +0100315 // The padding inset that accommodates elevation, needs to be taken into account.
316 val inset = with(density) { MenuElevation.toIntPx() }
317 val realPopupWidth = popupSize.width - inset * 2
318 val realPopupHeight = popupSize.height - inset * 2
319 val contentOffsetX = with(density) { contentOffset.x.toIntPx() }
320 val contentOffsetY = with(density) { contentOffset.y.toIntPx() }
321 val parentRight = parentLayoutPosition.x + parentLayoutSize.width
322 val parentBottom = parentLayoutPosition.y + parentLayoutSize.height
323
324 // Compute horizontal position.
325 val toRight = parentRight + contentOffsetX
326 val toLeft = parentLayoutPosition.x - contentOffsetX - realPopupWidth
George Mount8f237572020-04-30 12:08:30 -0700327 val toDisplayRight = displayMetrics.widthPixels - realPopupWidth
328 val toDisplayLeft = 0
Mihai Popad109a562020-05-12 17:40:50 +0100329 val x = if (layoutDirection == LayoutDirection.Ltr) {
330 sequenceOf(toRight, toLeft, toDisplayRight)
331 } else {
332 sequenceOf(toLeft, toRight, toDisplayLeft)
333 }.firstOrNull {
George Mount8f237572020-04-30 12:08:30 -0700334 it >= 0 && it + realPopupWidth <= displayMetrics.widthPixels
Mihai Popad109a562020-05-12 17:40:50 +0100335 } ?: toLeft
336
337 // Compute vertical position.
338 val toBottom = parentBottom + contentOffsetY
339 val toTop = parentLayoutPosition.y - contentOffsetY - realPopupHeight
340 val toCenter = parentLayoutPosition.y - realPopupHeight / 2
George Mount8f237572020-04-30 12:08:30 -0700341 val toDisplayBottom = displayMetrics.heightPixels - realPopupHeight
Mihai Popad109a562020-05-12 17:40:50 +0100342 val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
George Mount8f237572020-04-30 12:08:30 -0700343 it >= 0 && it + realPopupHeight <= displayMetrics.heightPixels
Mihai Popad109a562020-05-12 17:40:50 +0100344 } ?: toTop
345
Mihai Popa49b243e2020-06-22 13:01:36 +0100346 // TODO(popam, b/159596546): we should probably have androidx.ui.unit.IntBounds instead
347 onPositionCalculated(
348 PxBounds(parentLayoutPosition.toOffset(), parentLayoutSize.toSize()),
349 PxBounds(
350 x.toFloat() - inset,
351 y.toFloat() - inset,
352 x.toFloat() + inset + realPopupWidth,
353 y.toFloat() + inset + realPopupHeight
354 )
355 )
George Mount8f237572020-04-30 12:08:30 -0700356 return IntOffset(x - inset, y - inset)
Mihai Popad109a562020-05-12 17:40:50 +0100357 }
358}