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  * ![Navigation rail
72  * image](https://developer.android.com/images/reference/androidx/compose/material/navigation-rail.png)
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  * ![Navigation rail
141  * image](https://developer.android.com/images/reference/androidx/compose/material/navigation-rail.png)
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