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.material3
18
19 import androidx.compose.animation.animateColorAsState
20 import androidx.compose.animation.core.animateFloatAsState
21 import androidx.compose.foundation.background
22 import androidx.compose.foundation.indication
23 import androidx.compose.foundation.interaction.Interaction
24 import androidx.compose.foundation.interaction.MutableInteractionSource
25 import androidx.compose.foundation.layout.Arrangement
26 import androidx.compose.foundation.layout.Box
27 import androidx.compose.foundation.layout.Row
28 import androidx.compose.foundation.layout.RowScope
29 import androidx.compose.foundation.layout.WindowInsets
30 import androidx.compose.foundation.layout.WindowInsetsSides
31 import androidx.compose.foundation.layout.defaultMinSize
32 import androidx.compose.foundation.layout.fillMaxWidth
33 import androidx.compose.foundation.layout.only
34 import androidx.compose.foundation.layout.windowInsetsPadding
35 import androidx.compose.foundation.selection.selectable
36 import androidx.compose.foundation.selection.selectableGroup
37 import androidx.compose.material3.internal.MappedInteractionSource
38 import androidx.compose.material3.internal.ProvideContentColorTextStyle
39 import androidx.compose.material3.internal.systemBarsForVisualComponents
40 import androidx.compose.material3.tokens.ElevationTokens
41 import androidx.compose.material3.tokens.MotionSchemeKeyTokens
42 import androidx.compose.material3.tokens.NavigationBarTokens
43 import androidx.compose.material3.tokens.NavigationBarVerticalItemTokens
44 import androidx.compose.runtime.Composable
45 import androidx.compose.runtime.CompositionLocalProvider
46 import androidx.compose.runtime.Immutable
47 import androidx.compose.runtime.ProvidableCompositionLocal
48 import androidx.compose.runtime.Stable
49 import androidx.compose.runtime.State
50 import androidx.compose.runtime.compositionLocalOf
51 import androidx.compose.runtime.getValue
52 import androidx.compose.runtime.mutableIntStateOf
53 import androidx.compose.runtime.remember
54 import androidx.compose.runtime.setValue
55 import androidx.compose.ui.Alignment
56 import androidx.compose.ui.Modifier
57 import androidx.compose.ui.draw.clip
58 import androidx.compose.ui.geometry.Offset
59 import androidx.compose.ui.graphics.Color
60 import androidx.compose.ui.graphics.graphicsLayer
61 import androidx.compose.ui.graphics.takeOrElse
62 import androidx.compose.ui.layout.Layout
63 import androidx.compose.ui.layout.MeasureResult
64 import androidx.compose.ui.layout.MeasureScope
65 import androidx.compose.ui.layout.Placeable
66 import androidx.compose.ui.layout.layoutId
67 import androidx.compose.ui.layout.onSizeChanged
68 import androidx.compose.ui.platform.LocalDensity
69 import androidx.compose.ui.semantics.Role
70 import androidx.compose.ui.semantics.clearAndSetSemantics
71 import androidx.compose.ui.unit.Constraints
72 import androidx.compose.ui.unit.Dp
73 import androidx.compose.ui.unit.constrainHeight
74 import androidx.compose.ui.unit.dp
75 import androidx.compose.ui.util.fastFirst
76 import androidx.compose.ui.util.fastFirstOrNull
77 import kotlin.math.roundToInt
78
79 /**
80 * [Material Design bottom navigation
81 * bar](https://m3.material.io/components/navigation-bar/overview)
82 *
83 * Navigation bars offer a persistent and convenient way to switch between primary destinations in
84 * an app.
85 *
86 * 
88 *
89 * [NavigationBar] should contain three to five [NavigationBarItem]s, each representing a singular
90 * destination.
91 *
92 * A simple example looks like:
93 *
94 * @sample androidx.compose.material3.samples.NavigationBarSample
95 *
96 * See [NavigationBarItem] for configuration specific to each item, and not the overall
97 * [NavigationBar] component.
98 *
99 * @param modifier the [Modifier] to be applied to this navigation bar
100 * @param containerColor the color used for the background of this navigation bar. Use
101 * [Color.Transparent] to have no color.
102 * @param contentColor the preferred color for content inside this navigation bar. Defaults to
103 * either the matching content color for [containerColor], or to the current [LocalContentColor]
104 * if [containerColor] is not a color from the theme.
105 * @param tonalElevation when [containerColor] is [ColorScheme.surface], a translucent primary color
106 * overlay is applied on top of the container. A higher tonal elevation value will result in a
107 * darker color in light theme and lighter color in dark theme. See also: [Surface].
108 * @param windowInsets a window insets of the navigation bar.
109 * @param content the content of this navigation bar, typically 3-5 [NavigationBarItem]s
110 */
111 @OptIn(ExperimentalMaterial3ComponentOverrideApi::class)
112 @Composable
113 fun NavigationBar(
114 modifier: Modifier = Modifier,
115 containerColor: Color = NavigationBarDefaults.containerColor,
116 contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor),
117 tonalElevation: Dp = NavigationBarDefaults.Elevation,
118 windowInsets: WindowInsets = NavigationBarDefaults.windowInsets,
119 content: @Composable RowScope.() -> Unit
120 ) {
121 with(LocalNavigationBarOverride.current) {
122 NavigationBarOverrideScope(
123 modifier = modifier,
124 containerColor = containerColor,
125 contentColor = contentColor,
126 tonalElevation = tonalElevation,
127 windowInsets = windowInsets,
128 content = content,
129 )
130 .NavigationBar()
131 }
132 }
133
134 /**
135 * This override provides the default behavior of the [NavigationBar] component.
136 *
137 * [NavigationBarOverride] used when no override is specified.
138 */
139 @ExperimentalMaterial3ComponentOverrideApi
140 object DefaultNavigationBarOverride : NavigationBarOverride {
141 @Composable
NavigationBarnull142 override fun NavigationBarOverrideScope.NavigationBar() {
143 Surface(
144 color = containerColor,
145 contentColor = contentColor,
146 tonalElevation = tonalElevation,
147 modifier = modifier
148 ) {
149 Row(
150 modifier =
151 Modifier.fillMaxWidth()
152 .windowInsetsPadding(windowInsets)
153 .defaultMinSize(minHeight = NavigationBarHeight)
154 .selectableGroup(),
155 horizontalArrangement = Arrangement.spacedBy(NavigationBarItemHorizontalPadding),
156 verticalAlignment = Alignment.CenterVertically,
157 content = content
158 )
159 }
160 }
161 }
162
163 /**
164 * Material Design navigation bar item.
165 *
166 * Navigation bars offer a persistent and convenient way to switch between primary destinations in
167 * an app.
168 *
169 * The recommended configuration for a [NavigationBarItem] depends on how many items there are
170 * inside a [NavigationBar]:
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 [NavigationBarItem] 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 called when this item is clicked
182 * @param icon icon for this item, typically an [Icon]
183 * @param modifier the [Modifier] to be applied to this item
184 * @param enabled controls the enabled state of this item. When `false`, this component will not
185 * respond to user input, and it will appear visually disabled and disabled to accessibility
186 * services.
187 * @param label optional text label for this item
188 * @param alwaysShowLabel whether to always show the label for this item. If `false`, the label will
189 * only be shown when this item is selected.
190 * @param colors [NavigationBarItemColors] that will be used to resolve the colors used for this
191 * item in different states. See [NavigationBarItemDefaults.colors].
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 */
197 @Composable
RowScopenull198 fun RowScope.NavigationBarItem(
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 colors: NavigationBarItemColors = NavigationBarItemDefaults.colors(),
207 interactionSource: MutableInteractionSource? = null
208 ) {
209 @Suppress("NAME_SHADOWING")
210 val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
211 // TODO Load the motionScheme tokens from the component tokens file
212 val colorAnimationSpec = MotionSchemeKeyTokens.DefaultEffects.value<Color>()
213 val styledIcon =
214 @Composable {
215 val iconColor by
216 animateColorAsState(
217 targetValue = colors.iconColor(selected = selected, enabled = enabled),
218 animationSpec = colorAnimationSpec
219 )
220 // If there's a label, don't have a11y services repeat the icon description.
221 val clearSemantics = label != null && (alwaysShowLabel || selected)
222 Box(modifier = if (clearSemantics) Modifier.clearAndSetSemantics {} else Modifier) {
223 CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
224 }
225 }
226
227 val styledLabel: @Composable (() -> Unit)? =
228 label?.let {
229 @Composable {
230 val style = NavigationBarTokens.LabelTextFont.value
231 val textColor by
232 animateColorAsState(
233 targetValue = colors.textColor(selected = selected, enabled = enabled),
234 animationSpec = colorAnimationSpec
235 )
236 ProvideContentColorTextStyle(
237 contentColor = textColor,
238 textStyle = style,
239 content = label
240 )
241 }
242 }
243
244 var itemWidth by remember { mutableIntStateOf(0) }
245
246 Box(
247 modifier
248 .selectable(
249 selected = selected,
250 onClick = onClick,
251 enabled = enabled,
252 role = Role.Tab,
253 interactionSource = interactionSource,
254 indication = null,
255 )
256 .defaultMinSize(minHeight = NavigationBarHeight)
257 .weight(1f)
258 .onSizeChanged { itemWidth = it.width },
259 contentAlignment = Alignment.Center,
260 propagateMinConstraints = true,
261 ) {
262 val alphaAnimationProgress: State<Float> =
263 animateFloatAsState(
264 targetValue = if (selected) 1f else 0f,
265 // TODO Load the motionScheme tokens from the component tokens file
266 animationSpec = MotionSchemeKeyTokens.DefaultEffects.value()
267 )
268 val sizeAnimationProgress: State<Float> =
269 animateFloatAsState(
270 targetValue = if (selected) 1f else 0f,
271 // TODO Load the motionScheme tokens from the component tokens file
272 animationSpec = MotionSchemeKeyTokens.FastSpatial.value()
273 )
274 // The entire item is selectable, but only the indicator pill shows the ripple. To achieve
275 // this, we re-map the coordinates of the item's InteractionSource into the coordinates of
276 // the indicator.
277 val deltaOffset: Offset
278 with(LocalDensity.current) {
279 val indicatorWidth = NavigationBarVerticalItemTokens.ActiveIndicatorWidth.roundToPx()
280 deltaOffset =
281 Offset((itemWidth - indicatorWidth).toFloat() / 2, IndicatorVerticalOffset.toPx())
282 }
283 val offsetInteractionSource =
284 remember(interactionSource, deltaOffset) {
285 MappedInteractionSource(interactionSource, deltaOffset)
286 }
287
288 // The indicator has a width-expansion animation which interferes with the timing of the
289 // ripple, which is why they are separate composables
290 val indicatorRipple =
291 @Composable {
292 Box(
293 Modifier.layoutId(IndicatorRippleLayoutIdTag)
294 .clip(NavigationBarTokens.ItemActiveIndicatorShape.value)
295 .indication(offsetInteractionSource, ripple())
296 )
297 }
298 val indicator =
299 @Composable {
300 Box(
301 Modifier.layoutId(IndicatorLayoutIdTag)
302 .graphicsLayer { alpha = alphaAnimationProgress.value }
303 .background(
304 color = colors.indicatorColor,
305 shape = NavigationBarTokens.ItemActiveIndicatorShape.value,
306 )
307 )
308 }
309
310 NavigationBarItemLayout(
311 indicatorRipple = indicatorRipple,
312 indicator = indicator,
313 icon = styledIcon,
314 label = styledLabel,
315 alwaysShowLabel = alwaysShowLabel,
316 alphaAnimationProgress = { alphaAnimationProgress.value },
317 sizeAnimationProgress = { sizeAnimationProgress.value }
318 )
319 }
320 }
321
322 /** Defaults used in [NavigationBar]. */
323 object NavigationBarDefaults {
324 /** Default elevation for a navigation bar. */
325 val Elevation: Dp = ElevationTokens.Level0
326
327 /** Default color for a navigation bar. */
328 val containerColor: Color
329 @Composable get() = NavigationBarTokens.ContainerColor.value
330
331 /** Default window insets to be used and consumed by navigation bar */
332 val windowInsets: WindowInsets
333 @Composable
334 get() =
335 WindowInsets.systemBarsForVisualComponents.only(
336 WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
337 )
338 }
339
340 /** Defaults used in [NavigationBarItem]. */
341 object NavigationBarItemDefaults {
342
343 /**
344 * Creates a [NavigationBarItemColors] with the provided colors according to the Material
345 * specification.
346 */
colorsnull347 @Composable fun colors() = MaterialTheme.colorScheme.defaultNavigationBarItemColors
348
349 /**
350 * Creates a [NavigationBarItemColors] with the provided colors according to the Material
351 * specification.
352 *
353 * @param selectedIconColor the color to use for the icon when the item is selected.
354 * @param selectedTextColor the color to use for the text label when the item is selected.
355 * @param indicatorColor the color to use for the indicator when the item is selected.
356 * @param unselectedIconColor the color to use for the icon when the item is unselected.
357 * @param unselectedTextColor the color to use for the text label when the item is unselected.
358 * @param disabledIconColor the color to use for the icon when the item is disabled.
359 * @param disabledTextColor the color to use for the text label when the item is disabled.
360 * @return the resulting [NavigationBarItemColors] used for [NavigationBarItem]
361 */
362 @Composable
363 fun colors(
364 selectedIconColor: Color = Color.Unspecified,
365 selectedTextColor: Color = Color.Unspecified,
366 indicatorColor: Color = Color.Unspecified,
367 unselectedIconColor: Color = Color.Unspecified,
368 unselectedTextColor: Color = Color.Unspecified,
369 disabledIconColor: Color = Color.Unspecified,
370 disabledTextColor: Color = Color.Unspecified,
371 ): NavigationBarItemColors =
372 MaterialTheme.colorScheme.defaultNavigationBarItemColors.copy(
373 selectedIconColor = selectedIconColor,
374 selectedTextColor = selectedTextColor,
375 selectedIndicatorColor = indicatorColor,
376 unselectedIconColor = unselectedIconColor,
377 unselectedTextColor = unselectedTextColor,
378 disabledIconColor = disabledIconColor,
379 disabledTextColor = disabledTextColor,
380 )
381
382 internal val ColorScheme.defaultNavigationBarItemColors: NavigationBarItemColors
383 get() {
384 return defaultNavigationBarItemColorsCached
385 ?: NavigationBarItemColors(
386 selectedIconColor = fromToken(NavigationBarTokens.ItemActiveIconColor),
387 selectedTextColor = fromToken(NavigationBarTokens.ItemActiveLabelTextColor),
388 selectedIndicatorColor =
389 fromToken(NavigationBarTokens.ItemActiveIndicatorColor),
390 unselectedIconColor = fromToken(NavigationBarTokens.ItemInactiveIconColor),
391 unselectedTextColor =
392 fromToken(NavigationBarTokens.ItemInactiveLabelTextColor),
393 disabledIconColor =
394 fromToken(NavigationBarTokens.ItemInactiveIconColor)
395 .copy(alpha = DisabledAlpha),
396 disabledTextColor =
397 fromToken(NavigationBarTokens.ItemInactiveLabelTextColor)
398 .copy(alpha = DisabledAlpha),
399 )
400 .also { defaultNavigationBarItemColorsCached = it }
401 }
402
403 @Deprecated(
404 "Use overload with disabledIconColor and disabledTextColor",
405 level = DeprecationLevel.HIDDEN
406 )
407 @Composable
colorsnull408 fun colors(
409 selectedIconColor: Color = NavigationBarTokens.ItemActiveIconColor.value,
410 selectedTextColor: Color = NavigationBarTokens.ItemActiveLabelTextColor.value,
411 indicatorColor: Color = NavigationBarTokens.ItemActiveIndicatorColor.value,
412 unselectedIconColor: Color = NavigationBarTokens.ItemInactiveIconColor.value,
413 unselectedTextColor: Color = NavigationBarTokens.ItemInactiveLabelTextColor.value,
414 ): NavigationBarItemColors =
415 NavigationBarItemColors(
416 selectedIconColor = selectedIconColor,
417 selectedTextColor = selectedTextColor,
418 selectedIndicatorColor = indicatorColor,
419 unselectedIconColor = unselectedIconColor,
420 unselectedTextColor = unselectedTextColor,
421 disabledIconColor = unselectedIconColor.copy(alpha = DisabledAlpha),
422 disabledTextColor = unselectedTextColor.copy(alpha = DisabledAlpha),
423 )
424 }
425
426 /**
427 * Represents the colors of the various elements of a navigation item.
428 *
429 * @param selectedIconColor the color to use for the icon when the item is selected.
430 * @param selectedTextColor the color to use for the text label when the item is selected.
431 * @param selectedIndicatorColor the color to use for the indicator when the item is selected.
432 * @param unselectedIconColor the color to use for the icon when the item is unselected.
433 * @param unselectedTextColor the color to use for the text label when the item is unselected.
434 * @param disabledIconColor the color to use for the icon when the item is disabled.
435 * @param disabledTextColor the color to use for the text label when the item is disabled.
436 * @constructor create an instance with arbitrary colors.
437 */
438 @Immutable
439 class NavigationBarItemColors
440 constructor(
441 val selectedIconColor: Color,
442 val selectedTextColor: Color,
443 val selectedIndicatorColor: Color,
444 val unselectedIconColor: Color,
445 val unselectedTextColor: Color,
446 val disabledIconColor: Color,
447 val disabledTextColor: Color,
448 ) {
449 /**
450 * Returns a copy of this NavigationBarItemColors, optionally overriding some of the values.
451 * This uses the Color.Unspecified to mean “use the value from the source”
452 */
453 fun copy(
454 selectedIconColor: Color = this.selectedIconColor,
455 selectedTextColor: Color = this.selectedTextColor,
456 selectedIndicatorColor: Color = this.selectedIndicatorColor,
457 unselectedIconColor: Color = this.unselectedIconColor,
458 unselectedTextColor: Color = this.unselectedTextColor,
459 disabledIconColor: Color = this.disabledIconColor,
460 disabledTextColor: Color = this.disabledTextColor,
461 ) =
462 NavigationBarItemColors(
463 selectedIconColor.takeOrElse { this.selectedIconColor },
464 selectedTextColor.takeOrElse { this.selectedTextColor },
465 selectedIndicatorColor.takeOrElse { this.selectedIndicatorColor },
466 unselectedIconColor.takeOrElse { this.unselectedIconColor },
467 unselectedTextColor.takeOrElse { this.unselectedTextColor },
468 disabledIconColor.takeOrElse { this.disabledIconColor },
469 disabledTextColor.takeOrElse { this.disabledTextColor },
470 )
471
472 /**
473 * Represents the icon color for this item, depending on whether it is [selected].
474 *
475 * @param selected whether the item is selected
476 * @param enabled whether the item is enabled
477 */
478 @Stable
479 internal fun iconColor(selected: Boolean, enabled: Boolean): Color =
480 when {
481 !enabled -> disabledIconColor
482 selected -> selectedIconColor
483 else -> unselectedIconColor
484 }
485
486 /**
487 * Represents the text color for this item, depending on whether it is [selected].
488 *
489 * @param selected whether the item is selected
490 * @param enabled whether the item is enabled
491 */
492 @Stable
493 internal fun textColor(selected: Boolean, enabled: Boolean): Color =
494 when {
495 !enabled -> disabledTextColor
496 selected -> selectedTextColor
497 else -> unselectedTextColor
498 }
499
500 /** Represents the color of the indicator used for selected items. */
501 internal val indicatorColor: Color
502 get() = selectedIndicatorColor
503
504 override fun equals(other: Any?): Boolean {
505 if (this === other) return true
506 if (other == null || other !is NavigationBarItemColors) return false
507
508 if (selectedIconColor != other.selectedIconColor) return false
509 if (unselectedIconColor != other.unselectedIconColor) return false
510 if (selectedTextColor != other.selectedTextColor) return false
511 if (unselectedTextColor != other.unselectedTextColor) return false
512 if (selectedIndicatorColor != other.selectedIndicatorColor) return false
513 if (disabledIconColor != other.disabledIconColor) return false
514 if (disabledTextColor != other.disabledTextColor) return false
515
516 return true
517 }
518
519 override fun hashCode(): Int {
520 var result = selectedIconColor.hashCode()
521 result = 31 * result + unselectedIconColor.hashCode()
522 result = 31 * result + selectedTextColor.hashCode()
523 result = 31 * result + unselectedTextColor.hashCode()
524 result = 31 * result + selectedIndicatorColor.hashCode()
525 result = 31 * result + disabledIconColor.hashCode()
526 result = 31 * result + disabledTextColor.hashCode()
527
528 return result
529 }
530 }
531
532 /**
533 * Base layout for a [NavigationBarItem].
534 *
535 * @param indicatorRipple indicator ripple for this item when it is selected
536 * @param indicator indicator for this item when it is selected
537 * @param icon icon for this item
538 * @param label text label for this item
539 * @param alwaysShowLabel whether to always show the label for this item. If false, the label will
540 * only be shown when this item is selected.
541 * @param alphaAnimationProgress progress of the animation, where 0 represents the unselected state
542 * of this item and 1 represents the selected state. This value controls the indicator's opacity.
543 * @param sizeAnimationProgress progress of the animation, where 0 represents the unselected state
544 * of this item and 1 represents the selected state. This value controls other values such as
545 * indicator size, icon and label positions, etc.
546 */
547 @Composable
NavigationBarItemLayoutnull548 private fun NavigationBarItemLayout(
549 indicatorRipple: @Composable () -> Unit,
550 indicator: @Composable () -> Unit,
551 icon: @Composable () -> Unit,
552 label: @Composable (() -> Unit)?,
553 alwaysShowLabel: Boolean,
554 alphaAnimationProgress: () -> Float,
555 sizeAnimationProgress: () -> Float,
556 ) {
557 Layout(
558 modifier = Modifier.badgeBounds(),
559 content = {
560 indicatorRipple()
561 indicator()
562
563 Box(Modifier.layoutId(IconLayoutIdTag)) { icon() }
564
565 if (label != null) {
566 Box(
567 Modifier.layoutId(LabelLayoutIdTag).graphicsLayer {
568 alpha = if (alwaysShowLabel) 1f else alphaAnimationProgress()
569 }
570 ) {
571 label()
572 }
573 }
574 }
575 ) { measurables, constraints ->
576 @Suppress("NAME_SHADOWING")
577 // Ensure that the progress is >= 0. It may be negative on bouncy springs, for example.
578 val animationProgress = sizeAnimationProgress().coerceAtLeast(0f)
579 val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
580 val iconPlaceable =
581 measurables.fastFirst { it.layoutId == IconLayoutIdTag }.measure(looseConstraints)
582
583 val totalIndicatorWidth = iconPlaceable.width + (IndicatorHorizontalPadding * 2).roundToPx()
584 val animatedIndicatorWidth = (totalIndicatorWidth * animationProgress).roundToInt()
585 val indicatorHeight = iconPlaceable.height + (IndicatorVerticalPadding * 2).roundToPx()
586 val indicatorRipplePlaceable =
587 measurables
588 .fastFirst { it.layoutId == IndicatorRippleLayoutIdTag }
589 .measure(Constraints.fixed(width = totalIndicatorWidth, height = indicatorHeight))
590 val indicatorPlaceable =
591 measurables
592 .fastFirstOrNull { it.layoutId == IndicatorLayoutIdTag }
593 ?.measure(
594 Constraints.fixed(width = animatedIndicatorWidth, height = indicatorHeight)
595 )
596
597 val labelPlaceable =
598 label?.let {
599 measurables.fastFirst { it.layoutId == LabelLayoutIdTag }.measure(looseConstraints)
600 }
601
602 if (label == null) {
603 placeIcon(iconPlaceable, indicatorRipplePlaceable, indicatorPlaceable, constraints)
604 } else {
605 placeLabelAndIcon(
606 labelPlaceable!!,
607 iconPlaceable,
608 indicatorRipplePlaceable,
609 indicatorPlaceable,
610 constraints,
611 alwaysShowLabel,
612 animationProgress
613 )
614 }
615 }
616 }
617
618 /** Places the provided [Placeable]s in the center of the provided [constraints]. */
MeasureScopenull619 private fun MeasureScope.placeIcon(
620 iconPlaceable: Placeable,
621 indicatorRipplePlaceable: Placeable,
622 indicatorPlaceable: Placeable?,
623 constraints: Constraints
624 ): MeasureResult {
625 val width =
626 if (constraints.maxWidth == Constraints.Infinity) {
627 iconPlaceable.width + NavigationBarItemToIconMinimumPadding.roundToPx() * 2
628 } else {
629 constraints.maxWidth
630 }
631 val height = constraints.constrainHeight(NavigationBarHeight.roundToPx())
632
633 val iconX = (width - iconPlaceable.width) / 2
634 val iconY = (height - iconPlaceable.height) / 2
635
636 val rippleX = (width - indicatorRipplePlaceable.width) / 2
637 val rippleY = (height - indicatorRipplePlaceable.height) / 2
638
639 return layout(width, height) {
640 indicatorPlaceable?.let {
641 val indicatorX = (width - it.width) / 2
642 val indicatorY = (height - it.height) / 2
643 it.placeRelative(indicatorX, indicatorY)
644 }
645 iconPlaceable.placeRelative(iconX, iconY)
646 indicatorRipplePlaceable.placeRelative(rippleX, rippleY)
647 }
648 }
649
650 /**
651 * Places the provided [Placeable]s in the correct position, depending on [alwaysShowLabel] and
652 * [animationProgress].
653 *
654 * When [alwaysShowLabel] is true, the positions do not move. The [iconPlaceable] and
655 * [labelPlaceable] will be placed together in the center with padding between them, according to
656 * the spec.
657 *
658 * When [animationProgress] is 1 (representing the selected state), the positions will be the same
659 * as above.
660 *
661 * Otherwise, when [animationProgress] is 0, [iconPlaceable] will be placed in the center, like in
662 * [placeIcon], and [labelPlaceable] will not be shown.
663 *
664 * When [animationProgress] is animating between these values, [iconPlaceable] and [labelPlaceable]
665 * will be placed at a corresponding interpolated position.
666 *
667 * [indicatorRipplePlaceable] and [indicatorPlaceable] will always be placed in such a way that to
668 * share the same center as [iconPlaceable].
669 *
670 * @param labelPlaceable text label placeable inside this item
671 * @param iconPlaceable icon placeable inside this item
672 * @param indicatorRipplePlaceable indicator ripple placeable inside this item
673 * @param indicatorPlaceable indicator placeable inside this item, if it exists
674 * @param constraints constraints of the item
675 * @param alwaysShowLabel whether to always show the label for this item. If true, icon and label
676 * positions will not change. If false, positions transition between 'centered icon with no label'
677 * and 'top aligned icon with label'.
678 * @param animationProgress progress of the animation, where 0 represents the unselected state of
679 * this item and 1 represents the selected state. Values between 0 and 1 interpolate positions of
680 * the icon and label.
681 */
placeLabelAndIconnull682 private fun MeasureScope.placeLabelAndIcon(
683 labelPlaceable: Placeable,
684 iconPlaceable: Placeable,
685 indicatorRipplePlaceable: Placeable,
686 indicatorPlaceable: Placeable?,
687 constraints: Constraints,
688 alwaysShowLabel: Boolean,
689 animationProgress: Float,
690 ): MeasureResult {
691 val contentHeight =
692 iconPlaceable.height +
693 IndicatorVerticalPadding.toPx() +
694 NavigationBarIndicatorToLabelPadding.toPx() +
695 labelPlaceable.height
696 val contentVerticalPadding =
697 ((constraints.minHeight - contentHeight) / 2).coerceAtLeast(IndicatorVerticalPadding.toPx())
698 val height = contentHeight + contentVerticalPadding * 2
699
700 // Icon (when selected) should be `contentVerticalPadding` from top
701 val selectedIconY = contentVerticalPadding
702 val unselectedIconY =
703 if (alwaysShowLabel) selectedIconY else (height - iconPlaceable.height) / 2
704
705 // How far the icon needs to move between unselected and selected states.
706 val iconDistance = unselectedIconY - selectedIconY
707
708 // The interpolated fraction of iconDistance that all placeables need to move based on
709 // animationProgress.
710 val offset = iconDistance * (1 - animationProgress)
711
712 // Label should be fixed padding below icon
713 val labelY =
714 selectedIconY +
715 iconPlaceable.height +
716 IndicatorVerticalPadding.toPx() +
717 NavigationBarIndicatorToLabelPadding.toPx()
718
719 val containerWidth =
720 if (constraints.maxWidth == Constraints.Infinity) {
721 iconPlaceable.width + NavigationBarItemToIconMinimumPadding.roundToPx() * 2
722 } else {
723 constraints.maxWidth
724 }
725
726 val labelX = (containerWidth - labelPlaceable.width) / 2
727 val iconX = (containerWidth - iconPlaceable.width) / 2
728
729 val rippleX = (containerWidth - indicatorRipplePlaceable.width) / 2
730 val rippleY = selectedIconY - IndicatorVerticalPadding.toPx()
731
732 return layout(containerWidth, height.roundToInt()) {
733 indicatorPlaceable?.let {
734 val indicatorX = (containerWidth - it.width) / 2
735 val indicatorY = selectedIconY - IndicatorVerticalPadding.roundToPx()
736 it.placeRelative(indicatorX, (indicatorY + offset).roundToInt())
737 }
738 if (alwaysShowLabel || animationProgress != 0f) {
739 labelPlaceable.placeRelative(labelX, (labelY + offset).roundToInt())
740 }
741 iconPlaceable.placeRelative(iconX, (selectedIconY + offset).roundToInt())
742 indicatorRipplePlaceable.placeRelative(rippleX, (rippleY + offset).roundToInt())
743 }
744 }
745
746 private const val IndicatorRippleLayoutIdTag: String = "indicatorRipple"
747
748 private const val IndicatorLayoutIdTag: String = "indicator"
749
750 private const val IconLayoutIdTag: String = "icon"
751
752 private const val LabelLayoutIdTag: String = "label"
753
754 private val NavigationBarHeight: Dp = NavigationBarTokens.TallContainerHeight
755
756 /*@VisibleForTesting*/
757 internal val NavigationBarItemHorizontalPadding: Dp = 8.dp
758
759 /*@VisibleForTesting*/
760 internal val NavigationBarIndicatorToLabelPadding: Dp = 4.dp
761
762 private val IndicatorHorizontalPadding: Dp =
763 (NavigationBarVerticalItemTokens.ActiveIndicatorWidth -
764 NavigationBarVerticalItemTokens.IconSize) / 2
765
766 /*@VisibleForTesting*/
767 internal val IndicatorVerticalPadding: Dp =
768 (NavigationBarVerticalItemTokens.ActiveIndicatorHeight -
769 NavigationBarVerticalItemTokens.IconSize) / 2
770
771 private val IndicatorVerticalOffset: Dp = 12.dp
772
773 /*@VisibleForTesting*/
774 internal val NavigationBarItemToIconMinimumPadding: Dp = 44.dp
775
776 /**
777 * Interface that allows libraries to override the behavior of the [NavigationBar] component.
778 *
779 * To override this component, implement the member function of this interface, then provide the
780 * implementation to [LocalNavigationBarOverride] in the Compose hierarchy.
781 */
782 @ExperimentalMaterial3ComponentOverrideApi
783 interface NavigationBarOverride {
784 /** Behavior function that is called by the [NavigationBar] component. */
NavigationBarnull785 @Composable fun NavigationBarOverrideScope.NavigationBar()
786 }
787
788 /**
789 * Parameters available to [NavigationBar].
790 *
791 * @param modifier the [Modifier] to be applied to this navigation bar
792 * @param containerColor the color used for the background of this navigation bar. Use
793 * [Color.Transparent] to have no color.
794 * @param contentColor the preferred color for content inside this navigation bar. Defaults to
795 * either the matching content color for [containerColor], or to the current [LocalContentColor]
796 * if [containerColor] is not a color from the theme.
797 * @param tonalElevation when [containerColor] is [ColorScheme.surface], a translucent primary color
798 * overlay is applied on top of the container. A higher tonal elevation value will result in a
799 * darker color in light theme and lighter color in dark theme. See also: [Surface].
800 * @param windowInsets a window insets of the navigation bar.
801 * @param content the content of this navigation bar, typically 3-5 [NavigationBarItem]s
802 */
803 @ExperimentalMaterial3ComponentOverrideApi
804 class NavigationBarOverrideScope
805 internal constructor(
806 val modifier: Modifier = Modifier,
807 val containerColor: Color,
808 val contentColor: Color,
809 val tonalElevation: Dp,
810 val windowInsets: WindowInsets,
811 val content: @Composable RowScope.() -> Unit,
812 )
813
814 /** CompositionLocal containing the currently-selected [NavigationBarOverride]. */
815 @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
816 @get:ExperimentalMaterial3ComponentOverrideApi
817 @ExperimentalMaterial3ComponentOverrideApi
818 val LocalNavigationBarOverride: ProvidableCompositionLocal<NavigationBarOverride> =
819 compositionLocalOf {
820 DefaultNavigationBarOverride
821 }
822