[go: nahoru, domu]

blob: 4c19528190fcee12ac8a6846ba355f10b45ef644 [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.
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100141 .padding(MenuElevationInset),
Mihai Popae2202972020-05-12 03:18:57 +0100142 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 Popadcf3d0b2020-06-24 13:35:36 +0100203private val MenuElevation = 8.dp
204internal val MenuElevationInset = 16.dp
205private val DropdownMenuHorizontalPadding = 16.dp
Mihai Popa63fbc242020-04-28 13:52:33 +0100206internal val DropdownMenuVerticalPadding = 8.dp
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100207private val DropdownMenuItemDefaultMinWidth = 112.dp
208private val DropdownMenuItemDefaultMaxWidth = 280.dp
209private val DropdownMenuItemDefaultMinHeight = 48.dp
Mihai Popae2202972020-05-12 03:18:57 +0100210
Mihai Popad109a562020-05-12 17:40:50 +0100211// Menu open/close animation.
Mihai Popae2202972020-05-12 03:18:57 +0100212private val Scale = FloatPropKey()
213private val Alpha = FloatPropKey()
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100214internal const val InTransitionDuration = 120
215internal const val OutTransitionDuration = 75
Mihai Popae2202972020-05-12 03:18:57 +0100216
217private val DropdownMenuOpenCloseTransition = transitionDefinition {
218 state(false) {
219 // Menu is dismissed.
Mihai Popada3a2462020-06-22 13:01:36 +0100220 this[Scale] = 0.8f
Mihai Popae2202972020-05-12 03:18:57 +0100221 this[Alpha] = 0f
222 }
223 state(true) {
224 // Menu is expanded.
225 this[Scale] = 1f
226 this[Alpha] = 1f
227 }
228 transition(false, true) {
229 // Dismissed to expanded.
Doris Liua69d17b2020-06-19 16:39:42 -0700230 Scale using tween(
231 durationMillis = InTransitionDuration,
Mihai Popae2202972020-05-12 03:18:57 +0100232 easing = LinearOutSlowInEasing
Doris Liua69d17b2020-06-19 16:39:42 -0700233 )
234 Alpha using tween(
235 durationMillis = 30
236 )
Mihai Popae2202972020-05-12 03:18:57 +0100237 }
238 transition(true, false) {
239 // Expanded to dismissed.
Doris Liua69d17b2020-06-19 16:39:42 -0700240 Scale using tween(
241 durationMillis = 1,
242 delayMillis = OutTransitionDuration - 1
243 )
244 Alpha using tween(
245 durationMillis = OutTransitionDuration
246 )
Mihai Popae2202972020-05-12 03:18:57 +0100247 }
248}
Mihai Popad109a562020-05-12 17:40:50 +0100249
Mihai Popa49b243e2020-06-22 13:01:36 +0100250private class MenuDrawLayerModifier(
251 val scaleProvider: () -> Float,
252 val alphaProvider: () -> Float,
253 val transformOriginProvider: () -> TransformOrigin
254) : DrawLayerModifier {
255 override val scaleX: Float get() = scaleProvider()
256 override val scaleY: Float get() = scaleProvider()
257 override val alpha: Float get() = alphaProvider()
258 override val transformOrigin: TransformOrigin get() = transformOriginProvider()
259 override val clip: Boolean = true
260}
261
262private fun calculateTransformOrigin(
263 parentBounds: PxBounds,
264 menuBounds: PxBounds,
265 density: Density
266): TransformOrigin {
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100267 val inset = with(density) { MenuElevationInset.toPx() }
Mihai Popa49b243e2020-06-22 13:01:36 +0100268 val realMenuBounds = PxBounds(
269 menuBounds.left + inset,
270 menuBounds.top + inset,
271 menuBounds.right - inset,
272 menuBounds.bottom - inset
273 )
274 val pivotX = when {
275 realMenuBounds.left >= parentBounds.right -> 0f
276 realMenuBounds.right <= parentBounds.left -> 1f
277 else -> {
278 val intersectionCenter =
279 (max(parentBounds.left, realMenuBounds.left) +
280 min(parentBounds.right, realMenuBounds.right)) / 2
281 (intersectionCenter + inset - menuBounds.left) / menuBounds.width
282 }
283 }
284 val pivotY = when {
285 realMenuBounds.top >= parentBounds.bottom -> 0f
286 realMenuBounds.bottom <= parentBounds.top -> 1f
287 else -> {
288 val intersectionCenter =
289 (max(parentBounds.top, realMenuBounds.top) +
290 min(parentBounds.bottom, realMenuBounds.bottom)) / 2
291 (intersectionCenter + inset - menuBounds.top) / menuBounds.height
292 }
293 }
294 return TransformOrigin(pivotX, pivotY)
295}
296
Mihai Popad109a562020-05-12 17:40:50 +0100297// Menu positioning.
298
299/**
300 * Calculates the position of a Material [DropdownMenu].
301 */
302// TODO(popam): Investigate if this can/should consider the app window size rather than screen size
303@Immutable
304internal data class DropdownMenuPositionProvider(
305 val contentOffset: Position,
306 val density: Density,
Mihai Popa49b243e2020-06-22 13:01:36 +0100307 val displayMetrics: DisplayMetrics,
308 val onPositionCalculated: (PxBounds, PxBounds) -> Unit = { _, _ -> }
Mihai Popad109a562020-05-12 17:40:50 +0100309) : PopupPositionProvider {
310 override fun calculatePosition(
George Mount8f237572020-04-30 12:08:30 -0700311 parentLayoutPosition: IntOffset,
312 parentLayoutSize: IntSize,
Mihai Popad109a562020-05-12 17:40:50 +0100313 layoutDirection: LayoutDirection,
George Mount8f237572020-04-30 12:08:30 -0700314 popupSize: IntSize
315 ): IntOffset {
Mihai Popad109a562020-05-12 17:40:50 +0100316 // The padding inset that accommodates elevation, needs to be taken into account.
Mihai Popadcf3d0b2020-06-24 13:35:36 +0100317 val inset = with(density) { MenuElevationInset.toIntPx() }
Mihai Popad109a562020-05-12 17:40:50 +0100318 val realPopupWidth = popupSize.width - inset * 2
319 val realPopupHeight = popupSize.height - inset * 2
320 val contentOffsetX = with(density) { contentOffset.x.toIntPx() }
321 val contentOffsetY = with(density) { contentOffset.y.toIntPx() }
322 val parentRight = parentLayoutPosition.x + parentLayoutSize.width
323 val parentBottom = parentLayoutPosition.y + parentLayoutSize.height
324
325 // Compute horizontal position.
326 val toRight = parentRight + contentOffsetX
327 val toLeft = parentLayoutPosition.x - contentOffsetX - realPopupWidth
George Mount8f237572020-04-30 12:08:30 -0700328 val toDisplayRight = displayMetrics.widthPixels - realPopupWidth
329 val toDisplayLeft = 0
Mihai Popad109a562020-05-12 17:40:50 +0100330 val x = if (layoutDirection == LayoutDirection.Ltr) {
331 sequenceOf(toRight, toLeft, toDisplayRight)
332 } else {
333 sequenceOf(toLeft, toRight, toDisplayLeft)
334 }.firstOrNull {
George Mount8f237572020-04-30 12:08:30 -0700335 it >= 0 && it + realPopupWidth <= displayMetrics.widthPixels
Mihai Popad109a562020-05-12 17:40:50 +0100336 } ?: toLeft
337
338 // Compute vertical position.
339 val toBottom = parentBottom + contentOffsetY
340 val toTop = parentLayoutPosition.y - contentOffsetY - realPopupHeight
341 val toCenter = parentLayoutPosition.y - realPopupHeight / 2
George Mount8f237572020-04-30 12:08:30 -0700342 val toDisplayBottom = displayMetrics.heightPixels - realPopupHeight
Mihai Popad109a562020-05-12 17:40:50 +0100343 val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
George Mount8f237572020-04-30 12:08:30 -0700344 it >= 0 && it + realPopupHeight <= displayMetrics.heightPixels
Mihai Popad109a562020-05-12 17:40:50 +0100345 } ?: toTop
346
Mihai Popa49b243e2020-06-22 13:01:36 +0100347 // TODO(popam, b/159596546): we should probably have androidx.ui.unit.IntBounds instead
348 onPositionCalculated(
349 PxBounds(parentLayoutPosition.toOffset(), parentLayoutSize.toSize()),
350 PxBounds(
351 x.toFloat() - inset,
352 y.toFloat() - inset,
353 x.toFloat() + inset + realPopupWidth,
354 y.toFloat() + inset + realPopupHeight
355 )
356 )
George Mount8f237572020-04-30 12:08:30 -0700357 return IntOffset(x - inset, y - inset)
Mihai Popad109a562020-05-12 17:40:50 +0100358 }
359}