[go: nahoru, domu]

blob: f2cdf2f04feea3534d8e48d05660bd7b7051af04 [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
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
26import androidx.compose.setValue
27import androidx.compose.state
28import androidx.ui.animation.Transition
Mihai Popad109a562020-05-12 17:40:50 +010029import androidx.ui.core.ContextAmbient
Mihai Popa63fbc242020-04-28 13:52:33 +010030import androidx.ui.core.DensityAmbient
Mihai Popad109a562020-05-12 17:40:50 +010031import androidx.ui.core.LayoutDirection
Mihai Popa63fbc242020-04-28 13:52:33 +010032import androidx.ui.core.Modifier
Mihai Popad109a562020-05-12 17:40:50 +010033import androidx.ui.core.Popup
34import androidx.ui.core.PopupPositionProvider
35import androidx.ui.unit.Position
Mihai Popae2202972020-05-12 03:18:57 +010036import androidx.ui.core.drawLayer
Mihai Popa63fbc242020-04-28 13:52:33 +010037import androidx.ui.foundation.Box
38import androidx.ui.foundation.ContentGravity
39import androidx.ui.foundation.ProvideTextStyle
40import androidx.ui.foundation.clickable
41import androidx.ui.layout.Column
42import androidx.ui.layout.ColumnScope
43import androidx.ui.layout.IntrinsicSize
44import androidx.ui.layout.fillMaxWidth
45import androidx.ui.layout.padding
46import androidx.ui.layout.preferredSizeIn
47import androidx.ui.layout.preferredWidth
48import androidx.ui.material.ripple.ripple
Mihai Popad109a562020-05-12 17:40:50 +010049import androidx.ui.unit.Density
Mihai Popa63fbc242020-04-28 13:52:33 +010050import androidx.ui.unit.IntPxPosition
Mihai Popad109a562020-05-12 17:40:50 +010051import androidx.ui.unit.IntPxSize
Mihai Popa63fbc242020-04-28 13:52:33 +010052import androidx.ui.unit.dp
Mihai Popad109a562020-05-12 17:40:50 +010053import androidx.ui.unit.ipx
Mihai Popa63fbc242020-04-28 13:52:33 +010054
55/**
56 * A Material Design [dropdown menu](https://material.io/components/menus#dropdown-menu).
57 *
58 * The menu has a [toggle], which is the element generating the menu. For example, this can be
59 * an icon which, when tapped, triggers the menu.
60 * The content of the [DropdownMenu] can be [DropdownMenuItem]s, as well as custom content.
61 * [DropdownMenuItem] can be used to achieve items as defined by the Material Design spec.
62 * [onDismissRequest] will be called when the menu should close - for example when there is a
63 * tap outside the menu, or when the back key is pressed.
Mihai Popad109a562020-05-12 17:40:50 +010064 * The menu will do a best effort to be fully visible on screen. It will try to expand
65 * horizontally, depending on layout direction, to the end of the [toggle], then to the start of
66 * the [toggle], and then screen end-aligned. Vertically, it will try to expand to the bottom
67 * of the [toggle], then from the top of the [toggle], and then screen top-aligned. A
68 * [dropdownOffset] can be provided to adjust the positioning of the menu for cases when the
69 * layout bounds of the [toggle] do not coincide with its visual bounds.
Mihai Popa63fbc242020-04-28 13:52:33 +010070 *
71 * Example usage:
72 * @sample androidx.ui.material.samples.MenuSample
73 *
74 * @param toggle The element generating the menu
75 * @param expanded Whether the menu is currently open or dismissed
76 * @param onDismissRequest Called when the menu should be dismiss
77 * @param toggleModifier The modifier to be applied to the toggle
Mihai Popad109a562020-05-12 17:40:50 +010078 * @param dropdownOffset Offset to be added to the position of the menu
Mihai Popa63fbc242020-04-28 13:52:33 +010079 * @param dropdownModifier Modifier to be applied to the menu content
80 */
81@Composable
82fun DropdownMenu(
83 toggle: @Composable () -> Unit,
84 expanded: Boolean,
85 onDismissRequest: () -> Unit,
86 toggleModifier: Modifier = Modifier,
Mihai Popad109a562020-05-12 17:40:50 +010087 dropdownOffset: Position = Position(0.dp, 0.dp),
Mihai Popa63fbc242020-04-28 13:52:33 +010088 dropdownModifier: Modifier = Modifier,
89 dropdownContent: @Composable ColumnScope.() -> Unit
90) {
Mihai Popae2202972020-05-12 03:18:57 +010091 var visibleMenu by state { expanded }
92 if (expanded) visibleMenu = true
93
Mihai Popa63fbc242020-04-28 13:52:33 +010094 Box(toggleModifier) {
95 toggle()
96
Mihai Popae2202972020-05-12 03:18:57 +010097 if (visibleMenu) {
Mihai Popad109a562020-05-12 17:40:50 +010098 val popupPositionProvider = DropdownMenuPositionProvider(
99 dropdownOffset,
100 DensityAmbient.current,
101 ContextAmbient.current.resources.displayMetrics
102 )
103
104 Popup(
Mihai Popa63fbc242020-04-28 13:52:33 +0100105 isFocusable = true,
106 onDismissRequest = onDismissRequest,
Mihai Popad109a562020-05-12 17:40:50 +0100107 popupPositionProvider = popupPositionProvider
Mihai Popa63fbc242020-04-28 13:52:33 +0100108 ) {
Mihai Popae2202972020-05-12 03:18:57 +0100109 Transition(
110 definition = DropdownMenuOpenCloseTransition,
111 initState = !expanded,
112 toState = expanded,
113 onStateChangeFinished = {
114 visibleMenu = it
115 }
116 ) { state ->
117 val scale = state[Scale]
118 val alpha = state[Alpha]
119 Card(
120 modifier = Modifier
121 .drawLayer(scaleX = scale, scaleY = scale, alpha = alpha, clip = true)
122 // Padding to account for the elevation, otherwise it is clipped.
123 .padding(MenuElevation),
124 elevation = MenuElevation
125 ) {
126 Column(
127 dropdownModifier
128 .padding(vertical = DropdownMenuVerticalPadding)
129 .preferredWidth(IntrinsicSize.Max),
130 children = dropdownContent
131 )
132 }
Mihai Popa63fbc242020-04-28 13:52:33 +0100133 }
134 }
135 }
136 }
137}
138
139/**
140 * A dropdown menu item, as defined by the Material Design spec.
141 *
142 * Example usage:
143 * @sample androidx.ui.material.samples.MenuSample
144 *
145 * @param onClick Called when the menu item was clicked
146 * @param modifier The modifier to be applied to the menu item
147 * @param enabled Controls the enabled state of the menu item - when `false`, the menu item
148 * will not be clickable and [onClick] will not be invoked
149 */
150@Composable
151fun DropdownMenuItem(
152 onClick: () -> Unit,
153 modifier: Modifier = Modifier,
154 enabled: Boolean = true,
155 content: @Composable () -> Unit
156) {
157 // TODO(popam, b/156911853): investigate replacing this Box with ListItem
158 Box(
159 modifier = modifier
160 .clickable(enabled = enabled, onClick = onClick)
161 .ripple(enabled = enabled)
162 .fillMaxWidth()
163 // Preferred min and max width used during the intrinsic measurement.
164 .preferredSizeIn(
165 minWidth = DropdownMenuItemDefaultMinWidth,
166 maxWidth = DropdownMenuItemDefaultMaxWidth,
167 minHeight = DropdownMenuItemDefaultMinHeight
168 )
169 .padding(horizontal = DropdownMenuHorizontalPadding),
170 gravity = ContentGravity.CenterStart
171 ) {
172 // TODO(popam, b/156912039): update emphasis if the menu item is disabled
173 val typography = MaterialTheme.typography
174 val emphasisLevels = EmphasisAmbient.current
175 ProvideTextStyle(typography.subtitle1) {
Mihai Popae5ad7c82020-05-20 18:09:41 +0100176 ProvideEmphasis(
177 if (enabled) emphasisLevels.high else emphasisLevels.disabled,
178 content
179 )
Mihai Popa63fbc242020-04-28 13:52:33 +0100180 }
181 }
182}
183
Mihai Popad109a562020-05-12 17:40:50 +0100184// Size constants.
Mihai Popa63fbc242020-04-28 13:52:33 +0100185internal val MenuElevation = 8.dp
186internal val DropdownMenuHorizontalPadding = 16.dp
187internal val DropdownMenuVerticalPadding = 8.dp
188internal val DropdownMenuItemDefaultMinWidth = 112.dp
189internal val DropdownMenuItemDefaultMaxWidth = 280.dp
190internal val DropdownMenuItemDefaultMinHeight = 48.dp
Mihai Popae2202972020-05-12 03:18:57 +0100191
Mihai Popad109a562020-05-12 17:40:50 +0100192// Menu open/close animation.
Mihai Popae2202972020-05-12 03:18:57 +0100193private val Scale = FloatPropKey()
194private val Alpha = FloatPropKey()
195internal val InTransitionDuration = 120
196internal val OutTransitionDuration = 75
197
198private val DropdownMenuOpenCloseTransition = transitionDefinition {
199 state(false) {
200 // Menu is dismissed.
201 this[Scale] = 0f
202 this[Alpha] = 0f
203 }
204 state(true) {
205 // Menu is expanded.
206 this[Scale] = 1f
207 this[Alpha] = 1f
208 }
209 transition(false, true) {
210 // Dismissed to expanded.
211 Scale using tween {
212 duration = InTransitionDuration
213 easing = LinearOutSlowInEasing
214 }
215 Alpha using tween {
216 duration = 30
217 }
218 }
219 transition(true, false) {
220 // Expanded to dismissed.
221 Scale using tween {
222 duration = 1
223 delay = OutTransitionDuration - 1
224 }
225 Alpha using tween {
226 duration = OutTransitionDuration
227 }
228 }
229}
Mihai Popad109a562020-05-12 17:40:50 +0100230
231// Menu positioning.
232
233/**
234 * Calculates the position of a Material [DropdownMenu].
235 */
236// TODO(popam): Investigate if this can/should consider the app window size rather than screen size
237@Immutable
238internal data class DropdownMenuPositionProvider(
239 val contentOffset: Position,
240 val density: Density,
241 val displayMetrics: DisplayMetrics
242) : PopupPositionProvider {
243 override fun calculatePosition(
244 parentLayoutPosition: IntPxPosition,
245 parentLayoutSize: IntPxSize,
246 layoutDirection: LayoutDirection,
247 popupSize: IntPxSize
248 ): IntPxPosition {
249 // The padding inset that accommodates elevation, needs to be taken into account.
250 val inset = with(density) { MenuElevation.toIntPx() }
251 val realPopupWidth = popupSize.width - inset * 2
252 val realPopupHeight = popupSize.height - inset * 2
253 val contentOffsetX = with(density) { contentOffset.x.toIntPx() }
254 val contentOffsetY = with(density) { contentOffset.y.toIntPx() }
255 val parentRight = parentLayoutPosition.x + parentLayoutSize.width
256 val parentBottom = parentLayoutPosition.y + parentLayoutSize.height
257
258 // Compute horizontal position.
259 val toRight = parentRight + contentOffsetX
260 val toLeft = parentLayoutPosition.x - contentOffsetX - realPopupWidth
261 val toDisplayRight = displayMetrics.widthPixels.ipx - realPopupWidth
262 val toDisplayLeft = 0.ipx
263 val x = if (layoutDirection == LayoutDirection.Ltr) {
264 sequenceOf(toRight, toLeft, toDisplayRight)
265 } else {
266 sequenceOf(toLeft, toRight, toDisplayLeft)
267 }.firstOrNull {
268 it >= 0.ipx && it + realPopupWidth <= displayMetrics.widthPixels.ipx
269 } ?: toLeft
270
271 // Compute vertical position.
272 val toBottom = parentBottom + contentOffsetY
273 val toTop = parentLayoutPosition.y - contentOffsetY - realPopupHeight
274 val toCenter = parentLayoutPosition.y - realPopupHeight / 2
275 val toDisplayBottom = displayMetrics.heightPixels.ipx - realPopupHeight
276 val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
277 it >= 0.ipx && it + realPopupHeight <= displayMetrics.heightPixels.ipx
278 } ?: toTop
279
280 return IntPxPosition(x - inset, y - inset)
281 }
282}