1 /*
<lambda>null2 * Copyright 2021 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
17 package androidx.compose.material
18
19 import androidx.annotation.FloatRange
20 import androidx.compose.animation.core.FastOutSlowInEasing
21 import androidx.compose.animation.core.TweenSpec
22 import androidx.compose.animation.core.VectorizedAnimationSpec
23 import androidx.compose.animation.core.animateFloatAsState
24 import androidx.compose.foundation.interaction.Interaction
25 import androidx.compose.foundation.interaction.MutableInteractionSource
26 import androidx.compose.foundation.layout.Box
27 import androidx.compose.foundation.layout.Column
28 import androidx.compose.foundation.layout.ColumnScope
29 import androidx.compose.foundation.layout.Spacer
30 import androidx.compose.foundation.layout.WindowInsets
31 import androidx.compose.foundation.layout.WindowInsetsSides
32 import androidx.compose.foundation.layout.fillMaxHeight
33 import androidx.compose.foundation.layout.height
34 import androidx.compose.foundation.layout.only
35 import androidx.compose.foundation.layout.padding
36 import androidx.compose.foundation.layout.size
37 import androidx.compose.foundation.layout.windowInsetsPadding
38 import androidx.compose.foundation.selection.selectable
39 import androidx.compose.foundation.selection.selectableGroup
40 import androidx.compose.runtime.Composable
41 import androidx.compose.runtime.CompositionLocalProvider
42 import androidx.compose.runtime.getValue
43 import androidx.compose.ui.Alignment
44 import androidx.compose.ui.Modifier
45 import androidx.compose.ui.draw.alpha
46 import androidx.compose.ui.graphics.Color
47 import androidx.compose.ui.graphics.lerp
48 import androidx.compose.ui.layout.LastBaseline
49 import androidx.compose.ui.layout.Layout
50 import androidx.compose.ui.layout.MeasureResult
51 import androidx.compose.ui.layout.MeasureScope
52 import androidx.compose.ui.layout.Placeable
53 import androidx.compose.ui.layout.layoutId
54 import androidx.compose.ui.semantics.Role
55 import androidx.compose.ui.text.style.TextAlign
56 import androidx.compose.ui.unit.Constraints
57 import androidx.compose.ui.unit.Dp
58 import androidx.compose.ui.unit.dp
59 import androidx.compose.ui.util.fastFirst
60 import kotlin.math.max
61 import kotlin.math.roundToInt
62
63 /**
64 * [Material Design navigation rail](https://material.io/components/navigation-rail)
65 *
66 * A Navigation Rail is a side navigation component that allows movement between primary
67 * destinations in an app. A navigation rail should be used to display three to seven app
68 * destinations and, optionally, a [FloatingActionButton] or a logo inside [header]. Each
69 * destination is typically represented by an icon and an optional text label.
70 *
71 * 
73 *
74 * This particular overload provides ability to specify [WindowInsets]. Recommended value can be
75 * found in [NavigationRailDefaults.windowInsets].
76 *
77 * NavigationRail should contain multiple [NavigationRailItem]s, each representing a singular
78 * destination.
79 *
80 * A simple example looks like:
81 *
82 * @sample androidx.compose.material.samples.NavigationRailSample
83 *
84 * See [NavigationRailItem] for configuration specific to each item, and not the overall
85 * NavigationRail component.
86 *
87 * For more information, see [Navigation Rail](https://material.io/components/navigation-rail/)
88 *
89 * @param windowInsets a window insets that navigation rail will respect
90 * @param modifier optional [Modifier] for this NavigationRail
91 * @param backgroundColor The background color for this NavigationRail
92 * @param contentColor The preferred content color provided by this NavigationRail to its children.
93 * Defaults to either the matching content color for [backgroundColor], or if [backgroundColor] is
94 * not a color from the theme, this will keep the same value set above this NavigationRail.
95 * @param elevation elevation for this NavigationRail
96 * @param header an optional header that may hold a [FloatingActionButton] or a logo
97 * @param content destinations inside this NavigationRail, this should contain multiple
98 * [NavigationRailItem]s
99 */
100 @Composable
101 fun NavigationRail(
102 windowInsets: WindowInsets,
103 modifier: Modifier = Modifier,
104 backgroundColor: Color = MaterialTheme.colors.surface,
105 contentColor: Color = contentColorFor(backgroundColor),
106 elevation: Dp = NavigationRailDefaults.Elevation,
107 header: @Composable (ColumnScope.() -> Unit)? = null,
108 content: @Composable ColumnScope.() -> Unit
109 ) {
110 Surface(
111 modifier = modifier,
112 color = backgroundColor,
113 contentColor = contentColor,
114 elevation = elevation
115 ) {
116 Column(
117 Modifier.fillMaxHeight()
118 .windowInsetsPadding(windowInsets)
119 .padding(vertical = NavigationRailPadding)
120 .selectableGroup(),
121 horizontalAlignment = Alignment.CenterHorizontally,
122 ) {
123 if (header != null) {
124 header()
125 Spacer(Modifier.height(HeaderPadding))
126 }
127 content()
128 }
129 }
130 }
131
132 /**
133 * [Material Design navigation rail](https://material.io/components/navigation-rail)
134 *
135 * A Navigation Rail is a side navigation component that allows movement between primary
136 * destinations in an app. A navigation rail should be used to display three to seven app
137 * destinations and, optionally, a [FloatingActionButton] or a logo inside [header]. Each
138 * destination is typically represented by an icon and an optional text label.
139 *
140 * 
142 *
143 * NavigationRail should contain multiple [NavigationRailItem]s, each representing a singular
144 * destination.
145 *
146 * A simple example looks like:
147 *
148 * @sample androidx.compose.material.samples.NavigationRailSample
149 *
150 * See [NavigationRailItem] for configuration specific to each item, and not the overall
151 * NavigationRail component.
152 *
153 * For more information, see [Navigation Rail](https://material.io/components/navigation-rail/)
154 *
155 * @param modifier optional [Modifier] for this NavigationRail
156 * @param backgroundColor The background color for this NavigationRail
157 * @param contentColor The preferred content color provided by this NavigationRail to its children.
158 * Defaults to either the matching content color for [backgroundColor], or if [backgroundColor] is
159 * not a color from the theme, this will keep the same value set above this NavigationRail.
160 * @param elevation elevation for this NavigationRail
161 * @param header an optional header that may hold a [FloatingActionButton] or a logo
162 * @param content destinations inside this NavigationRail, this should contain multiple
163 * [NavigationRailItem]s
164 */
165 @Composable
NavigationRailnull166 fun NavigationRail(
167 modifier: Modifier = Modifier,
168 backgroundColor: Color = MaterialTheme.colors.surface,
169 contentColor: Color = contentColorFor(backgroundColor),
170 elevation: Dp = NavigationRailDefaults.Elevation,
171 header: @Composable (ColumnScope.() -> Unit)? = null,
172 content: @Composable ColumnScope.() -> Unit
173 ) {
174 NavigationRail(ZeroInsets, modifier, backgroundColor, contentColor, elevation, header, content)
175 }
176
177 /**
178 * [Material Design navigation rail](https://material.io/components/navigation-rail)
179 *
180 * A NavigationRailItem always shows text labels (if it exists) when selected. Showing text labels
181 * if not selected is controlled by [alwaysShowLabel].
182 *
183 * @param selected whether this item is selected (active)
184 * @param onClick the callback to be invoked when this item is selected
185 * @param icon icon for this item, typically this will be an [Icon]
186 * @param modifier optional [Modifier] for this item
187 * @param enabled controls the enabled state of this item. When `false`, this item will not be
188 * clickable and will appear disabled to accessibility services.
189 * @param label optional text label for this item
190 * @param alwaysShowLabel whether to always show the label for this item. If false, the label will
191 * only be shown when this item is selected.
192 * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
193 * emitting [Interaction]s for this item. You can use this to change the item's appearance or
194 * preview the item in different states. Note that if `null` is provided, interactions will still
195 * happen internally.
196 * @param selectedContentColor the color of the text label and icon when this item is selected, and
197 * the color of the ripple.
198 * @param unselectedContentColor the color of the text label and icon when this item is not selected
199 */
200 @Composable
NavigationRailItemnull201 fun NavigationRailItem(
202 selected: Boolean,
203 onClick: () -> Unit,
204 icon: @Composable () -> Unit,
205 modifier: Modifier = Modifier,
206 enabled: Boolean = true,
207 label: @Composable (() -> Unit)? = null,
208 alwaysShowLabel: Boolean = true,
209 interactionSource: MutableInteractionSource? = null,
210 selectedContentColor: Color = MaterialTheme.colors.primary,
211 unselectedContentColor: Color = LocalContentColor.current.copy(alpha = ContentAlpha.medium)
212 ) {
213 val styledLabel: @Composable (() -> Unit)? =
214 label?.let {
215 @Composable {
216 val style = MaterialTheme.typography.caption.copy(textAlign = TextAlign.Center)
217 ProvideTextStyle(style, content = label)
218 }
219 }
220 // Default to compact size when the item has no label, or a regular size when it does.
221 // Any size value that was set on the given Modifier will take precedence and allow custom
222 // sizing.
223 val itemSize = if (label == null) NavigationRailItemCompactSize else NavigationRailItemSize
224 // The color of the Ripple should always the selected color, as we want to show the color
225 // before the item is considered selected, and hence before the new contentColor is
226 // provided by NavigationRailTransition.
227 val ripple = ripple(bounded = false, color = selectedContentColor)
228 Box(
229 modifier
230 .selectable(
231 selected = selected,
232 onClick = onClick,
233 enabled = enabled,
234 role = Role.Tab,
235 interactionSource = interactionSource,
236 indication = ripple
237 )
238 .size(itemSize),
239 contentAlignment = Alignment.Center
240 ) {
241 NavigationRailTransition(selectedContentColor, unselectedContentColor, selected) { progress
242 ->
243 val animationProgress = if (alwaysShowLabel) 1f else progress
244
245 NavigationRailItemBaselineLayout(
246 icon = icon,
247 label = styledLabel,
248 iconPositionAnimationProgress = animationProgress
249 )
250 }
251 }
252 }
253
254 /** Contains default values used for [NavigationRail]. */
255 object NavigationRailDefaults {
256 /** Default elevation used for [NavigationRail]. */
257 val Elevation = 8.dp
258
259 /** Recommended window insets for navigation rail. */
260 val windowInsets: WindowInsets
261 @Composable
262 get() =
263 WindowInsets.systemBarsForVisualComponents.only(
264 WindowInsetsSides.Vertical + WindowInsetsSides.Start
265 )
266 }
267
268 /**
269 * Transition that animates [LocalContentColor] between [inactiveColor] and [activeColor], depending
270 * on [selected]. This component also provides the animation fraction as a parameter to [content],
271 * to allow animating the position of the icon and the scale of the label alongside this color
272 * animation.
273 *
274 * @param activeColor [LocalContentColor] when this item is [selected]
275 * @param inactiveColor [LocalContentColor] when this item is not [selected]
276 * @param selected whether this item is selected
277 * @param content the content of the [NavigationRailItem] to animate [LocalContentColor] for, where
278 * the animationProgress is the current progress of the animation from 0f to 1f.
279 */
280 @Composable
NavigationRailTransitionnull281 private fun NavigationRailTransition(
282 activeColor: Color,
283 inactiveColor: Color,
284 selected: Boolean,
285 content: @Composable (animationProgress: Float) -> Unit
286 ) {
287 val animationProgress by
288 animateFloatAsState(
289 targetValue = if (selected) 1f else 0f,
290 animationSpec = NavigationRailAnimationSpec
291 )
292
293 val color = lerp(inactiveColor, activeColor, animationProgress)
294
295 CompositionLocalProvider(
296 LocalContentColor provides color.copy(alpha = 1f),
297 LocalContentAlpha provides color.alpha,
298 ) {
299 content(animationProgress)
300 }
301 }
302
303 /**
304 * Base layout for a [NavigationRailItem]
305 *
306 * @param icon icon for this item
307 * @param label text label for this item
308 * @param iconPositionAnimationProgress progress of the animation that controls icon position, where
309 * 0 represents its unselected position and 1 represents its selected position. If both the [icon]
310 * and [label] should be shown at all times, this will always be 1, as the icon position should
311 * remain constant.
312 */
313 @Composable
NavigationRailItemBaselineLayoutnull314 private fun NavigationRailItemBaselineLayout(
315 icon: @Composable () -> Unit,
316 label: @Composable (() -> Unit)?,
317 @FloatRange(from = 0.0, to = 1.0) iconPositionAnimationProgress: Float
318 ) {
319 Layout({
320 Box(Modifier.layoutId("icon")) { icon() }
321 if (label != null) {
322 Box(Modifier.layoutId("label").alpha(iconPositionAnimationProgress)) { label() }
323 }
324 }) { measurables, constraints ->
325 val iconPlaceable = measurables.fastFirst { it.layoutId == "icon" }.measure(constraints)
326
327 val labelPlaceable =
328 label?.let {
329 measurables
330 .fastFirst { it.layoutId == "label" }
331 .measure(
332 // Measure with loose constraints for height as we don't want the label to
333 // take up more
334 // space than it needs
335 constraints.copy(minHeight = 0)
336 )
337 }
338
339 // If there is no label, just place the icon.
340 if (label == null) {
341 placeIcon(iconPlaceable, constraints)
342 } else {
343 placeLabelAndIcon(
344 labelPlaceable!!,
345 iconPlaceable,
346 constraints,
347 iconPositionAnimationProgress
348 )
349 }
350 }
351 }
352
353 /** Places the provided [iconPlaceable] in the vertical center of the provided [constraints] */
MeasureScopenull354 private fun MeasureScope.placeIcon(
355 iconPlaceable: Placeable,
356 constraints: Constraints
357 ): MeasureResult {
358 val iconX = max(0, (constraints.maxWidth - iconPlaceable.width) / 2)
359 val iconY = max(0, (constraints.maxHeight - iconPlaceable.height) / 2)
360 return layout(constraints.maxWidth, constraints.maxHeight) {
361 iconPlaceable.placeRelative(iconX, iconY)
362 }
363 }
364
365 /**
366 * Places the provided [labelPlaceable] and [iconPlaceable] in the correct position, depending on
367 * [iconPositionAnimationProgress].
368 *
369 * When [iconPositionAnimationProgress] is 0, [iconPlaceable] will be placed in the center, as with
370 * [placeIcon], and [labelPlaceable] will not be shown.
371 *
372 * When [iconPositionAnimationProgress] is 1, [iconPlaceable] will be placed near the top of item,
373 * and [labelPlaceable] will be placed at the bottom of the item, according to the spec.
374 *
375 * When [iconPositionAnimationProgress] is animating between these values, [iconPlaceable] will be
376 * placed at an interpolated position between its centered position and final resting position.
377 *
378 * @param labelPlaceable text label placeable inside this item
379 * @param iconPlaceable icon placeable inside this item
380 * @param iconPositionAnimationProgress the progress of the icon position animation, where 0
381 * represents centered icon and no label, and 1 represents top aligned icon with label. Values
382 * between 0 and 1 interpolate the icon position so we can smoothly move the icon.
383 */
placeLabelAndIconnull384 private fun MeasureScope.placeLabelAndIcon(
385 labelPlaceable: Placeable,
386 iconPlaceable: Placeable,
387 constraints: Constraints,
388 @FloatRange(from = 0.0, to = 1.0) iconPositionAnimationProgress: Float
389 ): MeasureResult {
390 val baseline = labelPlaceable[LastBaseline]
391 val labelBaselineOffset = ItemLabelBaselineBottomOffset.roundToPx()
392 // Label should be [ItemLabelBaselineBottomOffset] from the bottom
393 val labelY = constraints.maxHeight - baseline - labelBaselineOffset
394 val labelX = (constraints.maxWidth - labelPlaceable.width) / 2
395
396 // Icon should be [ItemIconTopOffset] from the top when selected
397 val selectedIconY = ItemIconTopOffset.roundToPx()
398 val unselectedIconY = (constraints.maxHeight - iconPlaceable.height) / 2
399 val iconX = (constraints.maxWidth - iconPlaceable.width) / 2
400 // How far the icon needs to move between unselected and selected states
401 val iconDistance = unselectedIconY - selectedIconY
402
403 // When selected the icon is above the unselected position, so we will animate moving
404 // downwards from the selected state, so when progress is 1, the total distance is 0, and we
405 // are at the selected state.
406 val offset = (iconDistance * (1 - iconPositionAnimationProgress)).roundToInt()
407
408 return layout(constraints.maxWidth, constraints.maxHeight) {
409 if (iconPositionAnimationProgress != 0f) {
410 labelPlaceable.placeRelative(labelX, labelY + offset)
411 }
412 iconPlaceable.placeRelative(iconX, selectedIconY + offset)
413 }
414 }
415
416 /**
417 * [VectorizedAnimationSpec] controlling the transition between unselected and selected
418 * [NavigationRailItem]s.
419 */
420 private val NavigationRailAnimationSpec =
421 TweenSpec<Float>(durationMillis = 300, easing = FastOutSlowInEasing)
422
423 /** Size of a regular [NavigationRailItem]. */
424 private val NavigationRailItemSize = 72.dp
425
426 /** Size of a compact [NavigationRailItem]. */
427 private val NavigationRailItemCompactSize = 56.dp
428
429 /** Padding at the top and the bottom of the [NavigationRail] */
430 private val NavigationRailPadding = 8.dp
431
432 /**
433 * Padding at the bottom of the [NavigationRail]'s header [Composable]. This padding will only be
434 * added when the header is not null.
435 */
436 private val HeaderPadding = 8.dp
437
438 /** The space between the text label's baseline and the bottom of the container. */
439 private val ItemLabelBaselineBottomOffset = 16.dp
440
441 /**
442 * The space between the icon and the top of the container when an item contains a label and icon.
443 */
444 private val ItemIconTopOffset = 14.dp
445
446 private val ZeroInsets = WindowInsets(0.dp)
447