[go: nahoru, domu]

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