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 * 
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 * 
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