1 /*
<lambda>null2  * 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 
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.Arrangement
27 import androidx.compose.foundation.layout.Box
28 import androidx.compose.foundation.layout.Row
29 import androidx.compose.foundation.layout.RowScope
30 import androidx.compose.foundation.layout.WindowInsets
31 import androidx.compose.foundation.layout.WindowInsetsSides
32 import androidx.compose.foundation.layout.defaultMinSize
33 import androidx.compose.foundation.layout.fillMaxWidth
34 import androidx.compose.foundation.layout.only
35 import androidx.compose.foundation.layout.padding
36 import androidx.compose.foundation.layout.windowInsetsPadding
37 import androidx.compose.foundation.selection.selectable
38 import androidx.compose.foundation.selection.selectableGroup
39 import androidx.compose.runtime.Composable
40 import androidx.compose.runtime.CompositionLocalProvider
41 import androidx.compose.runtime.getValue
42 import androidx.compose.ui.Alignment
43 import androidx.compose.ui.Modifier
44 import androidx.compose.ui.draw.alpha
45 import androidx.compose.ui.graphics.Color
46 import androidx.compose.ui.graphics.lerp
47 import androidx.compose.ui.layout.FirstBaseline
48 import androidx.compose.ui.layout.Layout
49 import androidx.compose.ui.layout.MeasureResult
50 import androidx.compose.ui.layout.MeasureScope
51 import androidx.compose.ui.layout.Placeable
52 import androidx.compose.ui.layout.layoutId
53 import androidx.compose.ui.semantics.Role
54 import androidx.compose.ui.text.style.TextAlign
55 import androidx.compose.ui.unit.Constraints
56 import androidx.compose.ui.unit.Dp
57 import androidx.compose.ui.unit.constrainHeight
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 // TODO: b/149825331 add documentation references to Scaffold here and samples for using
64 // BottomNavigation inside a Scaffold
65 /**
66  * [Material Design bottom navigation](https://material.io/components/bottom-navigation)
67  *
68  * Bottom navigation bars allow movement between primary destinations in an app.
69  *
70  * ![Bottom navigation
71  * image](https://developer.android.com/images/reference/androidx/compose/material/bottom-navigation.png)
72  *
73  * This particular overload provides ability to specify [WindowInsets]. Recommended value can be
74  * found in [BottomNavigationDefaults.windowInsets].
75  *
76  * BottomNavigation should contain multiple [BottomNavigationItem]s, each representing a singular
77  * destination.
78  *
79  * A simple example looks like:
80  *
81  * @sample androidx.compose.material.samples.BottomNavigationSample
82  *
83  * See [BottomNavigationItem] for configuration specific to each item, and not the overall
84  * BottomNavigation component.
85  *
86  * For more information, see [Bottom Navigation](https://material.io/components/bottom-navigation/)
87  *
88  * @param windowInsets a window insets that bottom navigation will respect.
89  * @param modifier optional [Modifier] for this BottomNavigation
90  * @param backgroundColor The background color for this BottomNavigation
91  * @param contentColor The preferred content color provided by this BottomNavigation to its
92  *   children. Defaults to either the matching content color for [backgroundColor], or if
93  *   [backgroundColor] is not a color from the theme, this will keep the same value set above this
94  *   BottomNavigation.
95  * @param elevation elevation for this BottomNavigation
96  * @param content destinations inside this BottomNavigation, this should contain multiple
97  *   [BottomNavigationItem]s
98  */
99 @Composable
100 fun BottomNavigation(
101     windowInsets: WindowInsets,
102     modifier: Modifier = Modifier,
103     backgroundColor: Color = MaterialTheme.colors.primarySurface,
104     contentColor: Color = contentColorFor(backgroundColor),
105     elevation: Dp = BottomNavigationDefaults.Elevation,
106     content: @Composable RowScope.() -> Unit
107 ) {
108     Surface(
109         color = backgroundColor,
110         contentColor = contentColor,
111         elevation = elevation,
112         modifier = modifier
113     ) {
114         Row(
115             Modifier.fillMaxWidth()
116                 .windowInsetsPadding(windowInsets)
117                 .defaultMinSize(minHeight = BottomNavigationHeight)
118                 .selectableGroup(),
119             horizontalArrangement = Arrangement.SpaceBetween,
120             content = content
121         )
122     }
123 }
124 
125 /**
126  * [Material Design bottom navigation](https://material.io/components/bottom-navigation)
127  *
128  * Bottom navigation bars allow movement between primary destinations in an app.
129  *
130  * ![Bottom navigation
131  * image](https://developer.android.com/images/reference/androidx/compose/material/bottom-navigation.png)
132  *
133  * BottomNavigation should contain multiple [BottomNavigationItem]s, each representing a singular
134  * destination.
135  *
136  * A simple example looks like:
137  *
138  * @sample androidx.compose.material.samples.BottomNavigationSample
139  *
140  * See [BottomNavigationItem] for configuration specific to each item, and not the overall
141  * BottomNavigation component.
142  *
143  * For more information, see [Bottom Navigation](https://material.io/components/bottom-navigation/)
144  *
145  * @param modifier optional [Modifier] for this BottomNavigation
146  * @param backgroundColor The background color for this BottomNavigation
147  * @param contentColor The preferred content color provided by this BottomNavigation to its
148  *   children. Defaults to either the matching content color for [backgroundColor], or if
149  *   [backgroundColor] is not a color from the theme, this will keep the same value set above this
150  *   BottomNavigation.
151  * @param elevation elevation for this BottomNavigation
152  * @param content destinations inside this BottomNavigation, this should contain multiple
153  *   [BottomNavigationItem]s
154  */
155 @Composable
BottomNavigationnull156 fun BottomNavigation(
157     modifier: Modifier = Modifier,
158     backgroundColor: Color = MaterialTheme.colors.primarySurface,
159     contentColor: Color = contentColorFor(backgroundColor),
160     elevation: Dp = BottomNavigationDefaults.Elevation,
161     content: @Composable RowScope.() -> Unit
162 ) {
163     BottomNavigation(ZeroInsets, modifier, backgroundColor, contentColor, elevation, content)
164 }
165 
166 /**
167  * [Material Design bottom navigation](https://material.io/components/bottom-navigation)
168  *
169  * The recommended configuration for a BottomNavigationItem depends on how many items there are
170  * inside a [BottomNavigation]:
171  * - Three destinations: Display icons and text labels for all destinations.
172  * - Four destinations: Active destinations display an icon and text label. Inactive destinations
173  *   display icons, and text labels are recommended.
174  * - Five destinations: Active destinations display an icon and text label. Inactive destinations
175  *   use icons, and use text labels if space permits.
176  *
177  * A BottomNavigationItem always shows text labels (if it exists) when selected. Showing text labels
178  * if not selected is controlled by [alwaysShowLabel].
179  *
180  * @param selected whether this item is selected
181  * @param onClick the callback to be invoked when this item is selected
182  * @param icon icon for this item, typically this will be an [Icon]
183  * @param modifier optional [Modifier] for this item
184  * @param enabled controls the enabled state of this item. When `false`, this item will not be
185  *   clickable and will appear disabled to accessibility services.
186  * @param label optional text label for this item
187  * @param alwaysShowLabel whether to always show the label for this item. If false, the label will
188  *   only be shown when this item is selected.
189  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
190  *   emitting [Interaction]s for this item. You can use this to change the item's appearance or
191  *   preview the item in different states. Note that if `null` is provided, interactions will still
192  *   happen internally.
193  * @param selectedContentColor the color of the text label and icon when this item is selected, and
194  *   the color of the ripple.
195  * @param unselectedContentColor the color of the text label and icon when this item is not selected
196  */
197 @Composable
RowScopenull198 fun RowScope.BottomNavigationItem(
199     selected: Boolean,
200     onClick: () -> Unit,
201     icon: @Composable () -> Unit,
202     modifier: Modifier = Modifier,
203     enabled: Boolean = true,
204     label: @Composable (() -> Unit)? = null,
205     alwaysShowLabel: Boolean = true,
206     interactionSource: MutableInteractionSource? = null,
207     selectedContentColor: Color = LocalContentColor.current,
208     unselectedContentColor: Color = selectedContentColor.copy(alpha = ContentAlpha.medium)
209 ) {
210     val styledLabel: @Composable (() -> Unit)? =
211         label?.let {
212             @Composable {
213                 val style = MaterialTheme.typography.caption.copy(textAlign = TextAlign.Center)
214                 ProvideTextStyle(style, content = label)
215             }
216         }
217     // The color of the Ripple should always the selected color, as we want to show the color
218     // before the item is considered selected, and hence before the new contentColor is
219     // provided by BottomNavigationTransition.
220     val ripple = ripple(bounded = false, color = selectedContentColor)
221 
222     Box(
223         modifier
224             .selectable(
225                 selected = selected,
226                 onClick = onClick,
227                 enabled = enabled,
228                 role = Role.Tab,
229                 interactionSource = interactionSource,
230                 indication = ripple
231             )
232             .weight(1f),
233         contentAlignment = Alignment.Center
234     ) {
235         BottomNavigationTransition(selectedContentColor, unselectedContentColor, selected) {
236             progress ->
237             val animationProgress = if (alwaysShowLabel) 1f else progress
238 
239             BottomNavigationItemBaselineLayout(
240                 icon = icon,
241                 label = styledLabel,
242                 iconPositionAnimationProgress = animationProgress
243             )
244         }
245     }
246 }
247 
248 /** Contains default values used for [BottomNavigation]. */
249 object BottomNavigationDefaults {
250     /** Default elevation used for [BottomNavigation]. */
251     val Elevation = 8.dp
252 
253     /** Recommended window insets to be used and consumed by bottom navigation */
254     val windowInsets: WindowInsets
255         @Composable
256         get() =
257             WindowInsets.systemBarsForVisualComponents.only(
258                 WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
259             )
260 }
261 
262 /**
263  * Transition that animates [LocalContentColor] between [inactiveColor] and [activeColor], depending
264  * on [selected]. This component also provides the animation fraction as a parameter to [content],
265  * to allow animating the position of the icon and the scale of the label alongside this color
266  * animation.
267  *
268  * @param activeColor [LocalContentColor] when this item is [selected]
269  * @param inactiveColor [LocalContentColor] when this item is not [selected]
270  * @param selected whether this item is selected
271  * @param content the content of the [BottomNavigationItem] to animate [LocalContentColor] for,
272  *   where the animationProgress is the current progress of the animation from 0f to 1f.
273  */
274 @Composable
BottomNavigationTransitionnull275 private fun BottomNavigationTransition(
276     activeColor: Color,
277     inactiveColor: Color,
278     selected: Boolean,
279     content: @Composable (animationProgress: Float) -> Unit
280 ) {
281     val animationProgress by
282         animateFloatAsState(
283             targetValue = if (selected) 1f else 0f,
284             animationSpec = BottomNavigationAnimationSpec
285         )
286 
287     val color = lerp(inactiveColor, activeColor, animationProgress)
288 
289     CompositionLocalProvider(
290         LocalContentColor provides color.copy(alpha = 1f),
291         LocalContentAlpha provides color.alpha,
292     ) {
293         content(animationProgress)
294     }
295 }
296 
297 /**
298  * Base layout for a [BottomNavigationItem]
299  *
300  * @param icon icon for this item
301  * @param label text label for this item
302  * @param iconPositionAnimationProgress progress of the animation that controls icon position, where
303  *   0 represents its unselected position and 1 represents its selected position. If both the [icon]
304  *   and [label] should be shown at all times, this will always be 1, as the icon position should
305  *   remain constant.
306  */
307 @Composable
BottomNavigationItemBaselineLayoutnull308 private fun BottomNavigationItemBaselineLayout(
309     icon: @Composable () -> Unit,
310     label: @Composable (() -> Unit)?,
311     @FloatRange(from = 0.0, to = 1.0) iconPositionAnimationProgress: Float
312 ) {
313     Layout({
314         Box(Modifier.layoutId("icon")) { icon() }
315         if (label != null) {
316             Box(
317                 Modifier.layoutId("label")
318                     .alpha(iconPositionAnimationProgress)
319                     .padding(horizontal = BottomNavigationItemHorizontalPadding)
320             ) {
321                 label()
322             }
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 height = constraints.constrainHeight(BottomNavigationHeight.roundToPx())
359     val iconY = (height - iconPlaceable.height) / 2
360     return layout(iconPlaceable.width, height) { iconPlaceable.placeRelative(0, iconY) }
361 }
362 
363 /**
364  * Places the provided [labelPlaceable] and [iconPlaceable] in the correct position, depending on
365  * [iconPositionAnimationProgress].
366  *
367  * When [iconPositionAnimationProgress] is 0, [iconPlaceable] will be placed in the center, as with
368  * [placeIcon], and [labelPlaceable] will not be shown.
369  *
370  * When [iconPositionAnimationProgress] is 1, [iconPlaceable] will be placed near the top of item,
371  * and [labelPlaceable] will be placed at the bottom of the item, according to the spec.
372  *
373  * When [iconPositionAnimationProgress] is animating between these values, [iconPlaceable] will be
374  * placed at an interpolated position between its centered position and final resting position.
375  *
376  * @param labelPlaceable text label placeable inside this item
377  * @param iconPlaceable icon placeable inside this item
378  * @param constraints constraints of the item
379  * @param iconPositionAnimationProgress the progress of the icon position animation, where 0
380  *   represents centered icon and no label, and 1 represents top aligned icon with label. Values
381  *   between 0 and 1 interpolate the icon position so we can smoothly move the icon.
382  */
placeLabelAndIconnull383 private fun MeasureScope.placeLabelAndIcon(
384     labelPlaceable: Placeable,
385     iconPlaceable: Placeable,
386     constraints: Constraints,
387     @FloatRange(from = 0.0, to = 1.0) iconPositionAnimationProgress: Float
388 ): MeasureResult {
389     val firstBaseline = labelPlaceable[FirstBaseline]
390     val baselineOffset = CombinedItemTextBaseline.roundToPx()
391     val netBaselineAdjustment = baselineOffset - firstBaseline
392 
393     val contentHeight = iconPlaceable.height + labelPlaceable.height + netBaselineAdjustment
394     val height = constraints.constrainHeight(max(contentHeight, BottomNavigationHeight.roundToPx()))
395     val contentVerticalPadding = ((height - contentHeight) / 2).coerceAtLeast(0)
396 
397     val unselectedIconY = (height - iconPlaceable.height) / 2
398     // Icon should be [contentVerticalPadding] from the top
399     val selectedIconY = contentVerticalPadding
400 
401     // Label's first baseline should be [baselineOffset] below the icon
402     val labelY = selectedIconY + iconPlaceable.height + netBaselineAdjustment
403 
404     val containerWidth = max(labelPlaceable.width, iconPlaceable.width)
405 
406     val labelX = (containerWidth - labelPlaceable.width) / 2
407     val iconX = (containerWidth - iconPlaceable.width) / 2
408 
409     // How far the icon needs to move between unselected and selected states
410     val iconDistance = unselectedIconY - selectedIconY
411 
412     // When selected the icon is above the unselected position, so we will animate moving
413     // downwards from the selected state, so when progress is 1, the total distance is 0, and we
414     // are at the selected state.
415     val offset = (iconDistance * (1 - iconPositionAnimationProgress)).roundToInt()
416 
417     return layout(containerWidth, height) {
418         if (iconPositionAnimationProgress != 0f) {
419             labelPlaceable.placeRelative(labelX, labelY + offset)
420         }
421         iconPlaceable.placeRelative(iconX, selectedIconY + offset)
422     }
423 }
424 
425 /**
426  * [VectorizedAnimationSpec] controlling the transition between unselected and selected
427  * [BottomNavigationItem]s.
428  */
429 private val BottomNavigationAnimationSpec =
430     TweenSpec<Float>(durationMillis = 300, easing = FastOutSlowInEasing)
431 
432 /** Height of a [BottomNavigation] component */
433 private val BottomNavigationHeight = 56.dp
434 
435 /** Padding at the start and end of a [BottomNavigationItem] */
436 private val BottomNavigationItemHorizontalPadding = 12.dp
437 
438 /**
439  * The space between the text baseline and the bottom of the [BottomNavigationItem], and between the
440  * text baseline and the bottom of the icon placed above it.
441  */
442 private val CombinedItemTextBaseline = 12.dp
443 
444 private val ZeroInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)
445