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