1 /*
2 * Copyright 2024 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.core.animateFloatAsState
20 import androidx.compose.foundation.background
21 import androidx.compose.foundation.indication
22 import androidx.compose.foundation.interaction.Interaction
23 import androidx.compose.foundation.interaction.InteractionSource
24 import androidx.compose.foundation.interaction.MutableInteractionSource
25 import androidx.compose.foundation.layout.Box
26 import androidx.compose.foundation.layout.defaultMinSize
27 import androidx.compose.foundation.selection.selectable
28 import androidx.compose.material3.internal.MappedInteractionSource
29 import androidx.compose.material3.internal.ProvideContentColorTextStyle
30 import androidx.compose.material3.internal.layoutId
31 import androidx.compose.material3.tokens.MotionSchemeKeyTokens
32 import androidx.compose.runtime.Composable
33 import androidx.compose.runtime.CompositionLocalProvider
34 import androidx.compose.runtime.Immutable
35 import androidx.compose.runtime.Stable
36 import androidx.compose.runtime.derivedStateOf
37 import androidx.compose.runtime.getValue
38 import androidx.compose.runtime.mutableIntStateOf
39 import androidx.compose.runtime.remember
40 import androidx.compose.runtime.setValue
41 import androidx.compose.ui.Alignment
42 import androidx.compose.ui.Modifier
43 import androidx.compose.ui.draw.clip
44 import androidx.compose.ui.geometry.Offset
45 import androidx.compose.ui.graphics.Color
46 import androidx.compose.ui.graphics.Shape
47 import androidx.compose.ui.graphics.graphicsLayer
48 import androidx.compose.ui.graphics.takeOrElse
49 import androidx.compose.ui.layout.IntrinsicMeasurable
50 import androidx.compose.ui.layout.IntrinsicMeasureScope
51 import androidx.compose.ui.layout.Layout
52 import androidx.compose.ui.layout.Measurable
53 import androidx.compose.ui.layout.MeasurePolicy
54 import androidx.compose.ui.layout.MeasureResult
55 import androidx.compose.ui.layout.MeasureScope
56 import androidx.compose.ui.layout.Placeable
57 import androidx.compose.ui.layout.layoutId
58 import androidx.compose.ui.layout.onSizeChanged
59 import androidx.compose.ui.platform.LocalDensity
60 import androidx.compose.ui.semantics.Role
61 import androidx.compose.ui.text.TextStyle
62 import androidx.compose.ui.unit.Constraints
63 import androidx.compose.ui.unit.Dp
64 import androidx.compose.ui.unit.constrain
65 import androidx.compose.ui.unit.constrainHeight
66 import androidx.compose.ui.unit.constrainWidth
67 import androidx.compose.ui.unit.dp
68 import androidx.compose.ui.unit.offset
69 import androidx.compose.ui.util.fastFirst
70 import androidx.compose.ui.util.fastFirstOrNull
71 import androidx.compose.ui.util.lerp
72 import kotlin.jvm.JvmInline
73 import kotlin.math.max
74 import kotlin.math.roundToInt
75
76 /** Class that describes the different supported icon positions of the navigation item. */
77 @JvmInline
78 @ExperimentalMaterial3ExpressiveApi
79 value class NavigationItemIconPosition private constructor(private val value: Int) {
80 companion object {
81 /* The icon is positioned on top of the label. */
82 val Top = NavigationItemIconPosition(0)
83
84 /* The icon is positioned at the start of the label. */
85 val Start = NavigationItemIconPosition(1)
86 }
87
toStringnull88 override fun toString() =
89 when (this) {
90 Top -> "Top"
91 Start -> "Start"
92 else -> "Unknown"
93 }
94 }
95
96 /**
97 * Represents the colors of the various elements of a navigation item.
98 *
99 * @param selectedIconColor the color to use for the icon when the item is selected.
100 * @param selectedTextColor the color to use for the text label when the item is selected.
101 * @param selectedIndicatorColor the color to use for the indicator when the item is selected.
102 * @param unselectedIconColor the color to use for the icon when the item is unselected.
103 * @param unselectedTextColor the color to use for the text label when the item is unselected.
104 * @param disabledIconColor the color to use for the icon when the item is disabled.
105 * @param disabledTextColor the color to use for the text label when the item is disabled.
106 * @constructor create an instance with arbitrary colors.
107 */
108 @Immutable
109 class NavigationItemColors
110 constructor(
111 val selectedIconColor: Color,
112 val selectedTextColor: Color,
113 val selectedIndicatorColor: Color,
114 val unselectedIconColor: Color,
115 val unselectedTextColor: Color,
116 val disabledIconColor: Color,
117 val disabledTextColor: Color,
118 ) {
119 /**
120 * Returns a copy of this NavigationItemColors, optionally overriding some of the values. This
121 * uses the Color.Unspecified to mean “use the value from the source”.
122 */
copynull123 fun copy(
124 selectedIconColor: Color = this.selectedIconColor,
125 selectedTextColor: Color = this.selectedTextColor,
126 selectedIndicatorColor: Color = this.selectedIndicatorColor,
127 unselectedIconColor: Color = this.unselectedIconColor,
128 unselectedTextColor: Color = this.unselectedTextColor,
129 disabledIconColor: Color = this.disabledIconColor,
130 disabledTextColor: Color = this.disabledTextColor,
131 ) =
132 NavigationItemColors(
133 selectedIconColor.takeOrElse { this.selectedIconColor },
<lambda>null134 selectedTextColor.takeOrElse { this.selectedTextColor },
<lambda>null135 selectedIndicatorColor.takeOrElse { this.selectedIndicatorColor },
<lambda>null136 unselectedIconColor.takeOrElse { this.unselectedIconColor },
<lambda>null137 unselectedTextColor.takeOrElse { this.unselectedTextColor },
<lambda>null138 disabledIconColor.takeOrElse { this.disabledIconColor },
<lambda>null139 disabledTextColor.takeOrElse { this.disabledTextColor },
140 )
141
142 /**
143 * Represents the icon color for this item, depending on whether it is [selected].
144 *
145 * @param selected whether the item is selected
146 * @param enabled whether the item is enabled
147 */
148 @Stable
iconColornull149 fun iconColor(selected: Boolean, enabled: Boolean): Color {
150 return when {
151 !enabled -> disabledIconColor
152 selected -> selectedIconColor
153 else -> unselectedIconColor
154 }
155 }
156
157 /**
158 * Represents the text color for this item, depending on whether it is [selected].
159 *
160 * @param selected whether the item is selected
161 * @param enabled whether the item is enabled
162 */
163 @Stable
textColornull164 fun textColor(selected: Boolean, enabled: Boolean): Color {
165 return when {
166 !enabled -> disabledTextColor
167 selected -> selectedTextColor
168 else -> unselectedTextColor
169 }
170 }
171
equalsnull172 override fun equals(other: Any?): Boolean {
173 if (this === other) return true
174 if (other == null || other !is NavigationItemColors) return false
175
176 if (selectedIconColor != other.selectedIconColor) return false
177 if (unselectedIconColor != other.unselectedIconColor) return false
178 if (selectedTextColor != other.selectedTextColor) return false
179 if (unselectedTextColor != other.unselectedTextColor) return false
180 if (selectedIndicatorColor != other.selectedIndicatorColor) return false
181 if (disabledIconColor != other.disabledIconColor) return false
182 if (disabledTextColor != other.disabledTextColor) return false
183
184 return true
185 }
186
hashCodenull187 override fun hashCode(): Int {
188 var result = selectedIconColor.hashCode()
189 result = 31 * result + unselectedIconColor.hashCode()
190 result = 31 * result + selectedTextColor.hashCode()
191 result = 31 * result + unselectedTextColor.hashCode()
192 result = 31 * result + selectedIndicatorColor.hashCode()
193 result = 31 * result + disabledIconColor.hashCode()
194 result = 31 * result + disabledTextColor.hashCode()
195
196 return result
197 }
198 }
199
200 /**
201 * Internal function to make a navigation suite component, such as the [ShortNavigationBarItem].
202 *
203 * @param selected whether this item is selected
204 * @param onClick called when this item is clicked
205 * @param icon icon for this item, typically an [Icon]
206 * @param labelTextStyle the text style of the label of this item
207 * @param indicatorShape the shape of the indicator when the item is selected
208 * @param indicatorWidth the width of the indicator when the item is selected
209 * @param indicatorHorizontalPadding the horizontal padding of the indicator
210 * @param indicatorVerticalPadding the vertical padding of the indicator
211 * @param indicatorToLabelVerticalPadding the padding between the indicator and the label when there
212 * is a top icon for this item (the iconPosition is Top)
213 * @param startIconToLabelHorizontalPadding the padding between the start icon and the label of the
214 * item (the iconPosition is Start)
215 * @param topIconItemVerticalPadding the vertical padding of the item when the iconPosition is Top
216 * @param colors [NavigationItemColors] that will be used to resolve the colors used for this item
217 * in different states
218 * @param modifier the [Modifier] to be applied to this item
219 * @param enabled controls the enabled state of this item. When `false`, this component will not
220 * respond to user input, and it will appear visually disabled and disabled to accessibility
221 * services
222 * @param label the text label for this item
223 * @param iconPosition the [NavigationItemIconPosition] for this icon
224 * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
225 * for this item. You can create and pass in your own `remember`ed instance to observe
226 * [Interaction]s and customize the appearance / behavior of this item in different states
227 */
228 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
229 @Composable
230 internal fun NavigationItem(
231 selected: Boolean,
232 onClick: () -> Unit,
233 icon: @Composable () -> Unit,
234 labelTextStyle: TextStyle,
235 indicatorShape: Shape,
236 indicatorWidth: Dp,
237 indicatorHorizontalPadding: Dp,
238 indicatorVerticalPadding: Dp,
239 indicatorToLabelVerticalPadding: Dp,
240 startIconToLabelHorizontalPadding: Dp,
241 topIconItemVerticalPadding: Dp,
242 colors: NavigationItemColors,
243 modifier: Modifier,
244 enabled: Boolean,
245 label: @Composable (() -> Unit)?,
246 iconPosition: NavigationItemIconPosition,
247 interactionSource: MutableInteractionSource
248 ) {
249 val iconColor = colors.iconColor(selected = selected, enabled = enabled)
250 val styledIcon: @Composable () -> Unit = {
251 CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
252 }
253 val styledLabel: @Composable (() -> Unit)? =
254 if (label == null) {
255 null
256 } else {
<lambda>null257 { StyledLabel(selected, labelTextStyle, colors, enabled, label) }
258 }
259
<lambda>null260 var itemWidth by remember { mutableIntStateOf(0) }
261
262 Box(
263 modifier
264 .selectable(
265 selected = selected,
266 onClick = onClick,
267 enabled = enabled,
268 role = Role.Tab,
269 interactionSource = interactionSource,
270 indication = null,
271 )
272 .defaultMinSize(
273 minWidth = LocalMinimumInteractiveComponentSize.current,
274 minHeight = LocalMinimumInteractiveComponentSize.current
275 )
<lambda>null276 .onSizeChanged { itemWidth = it.width },
277 contentAlignment = Alignment.Center,
278 propagateMinConstraints = true,
<lambda>null279 ) {
280 val indicatorAnimationProgress = animateIndicatorProgressAsState(selected)
281 var offsetInteractionSource: MappedInteractionSource? = null
282 if (iconPosition == NavigationItemIconPosition.Top) {
283 // The entire item is selectable, but only the indicator pill shows the ripple. To
284 // achieve this, we re-map the coordinates of the item's InteractionSource into the
285 // coordinates of the indicator.
286 val deltaOffset: Offset
287 with(LocalDensity.current) {
288 deltaOffset =
289 Offset(
290 (itemWidth - indicatorWidth.roundToPx()).toFloat() / 2,
291 IndicatorVerticalOffset.toPx()
292 )
293 }
294 offsetInteractionSource =
295 remember(interactionSource, deltaOffset) {
296 MappedInteractionSource(interactionSource, deltaOffset)
297 }
298 }
299
300 NavigationItemLayout(
301 interactionSource = offsetInteractionSource ?: interactionSource,
302 indicatorColor = colors.selectedIndicatorColor,
303 indicatorShape = indicatorShape,
304 icon = styledIcon,
305 iconPosition = iconPosition,
306 label = styledLabel,
307 indicatorAnimationProgress = { indicatorAnimationProgress.value.coerceAtLeast(0f) },
308 indicatorHorizontalPadding = indicatorHorizontalPadding,
309 indicatorVerticalPadding = indicatorVerticalPadding,
310 indicatorToLabelVerticalPadding = indicatorToLabelVerticalPadding,
311 startIconToLabelHorizontalPadding = startIconToLabelHorizontalPadding,
312 topIconItemVerticalPadding = topIconItemVerticalPadding
313 )
314 }
315 }
316
317 /**
318 * Internal function to make an animated navigation item to be used with a navigation suite
319 * component, such as the [WideNavigationRailItem].
320 *
321 * This item will animate its elements when the value of [iconPosition] changes.
322 */
323 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
324 @Composable
325 internal fun AnimatedNavigationItem(
326 selected: Boolean,
327 onClick: () -> Unit,
328 icon: @Composable () -> Unit,
329 indicatorShape: Shape,
330 topIconIndicatorWidth: Dp,
331 topIconLabelTextStyle: TextStyle,
332 startIconLabelTextStyle: TextStyle,
333 topIconIndicatorHorizontalPadding: Dp,
334 topIconIndicatorVerticalPadding: Dp,
335 topIconIndicatorToLabelVerticalPadding: Dp,
336 startIconIndicatorHorizontalPadding: Dp,
337 startIconIndicatorVerticalPadding: Dp,
338 noLabelIndicatorPadding: Dp,
339 startIconToLabelHorizontalPadding: Dp,
340 itemHorizontalPadding: Dp,
341 colors: NavigationItemColors,
342 modifier: Modifier,
343 enabled: Boolean,
344 label: @Composable (() -> Unit)?,
345 iconPosition: NavigationItemIconPosition,
346 interactionSource: MutableInteractionSource
347 ) {
348 val iconColor = colors.iconColor(selected = selected, enabled = enabled)
349 val styledIcon: @Composable () -> Unit = {
350 CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
351 }
352
<lambda>null353 var itemWidth by remember { mutableIntStateOf(0) }
354
355 Box(
356 modifier
357 .selectable(
358 selected = selected,
359 onClick = onClick,
360 enabled = enabled,
361 role = Role.Tab,
362 interactionSource = interactionSource,
363 indication = null,
364 )
365 .defaultMinSize(
366 minWidth = LocalMinimumInteractiveComponentSize.current,
367 minHeight = LocalMinimumInteractiveComponentSize.current
368 )
<lambda>null369 .onSizeChanged { itemWidth = it.width },
370 contentAlignment = Alignment.Center,
371 propagateMinConstraints = true,
<lambda>null372 ) {
373 val isIconPositionTop = iconPosition == NavigationItemIconPosition.Top
374 val indicatorAnimationProgress = animateIndicatorProgressAsState(selected)
375 val iconPositionProgress by
376 animateFloatAsState(
377 targetValue = if (isIconPositionTop) 0f else 1f,
378 // TODO Load the motionScheme tokens from the component tokens file
379 animationSpec = MotionSchemeKeyTokens.DefaultSpatial.value()
380 )
381
382 val textStyle by remember {
383 derivedStateOf {
384 if (isIconPositionTop && iconPositionProgress < 0.5f) topIconLabelTextStyle
385 else startIconLabelTextStyle
386 }
387 }
388 val styledLabel: @Composable (() -> Unit)? =
389 if (label != null) {
390 {
391 StyledLabel(
392 selected = selected,
393 labelTextStyle = textStyle,
394 colors = colors,
395 enabled = enabled,
396 content = label
397 )
398 }
399 } else {
400 null
401 }
402
403 var offsetInteractionSource: MappedInteractionSource? = null
404 if (isIconPositionTop) {
405 // The entire item is selectable, but only the indicator pill shows the ripple. To
406 // achieve this, we re-map the coordinates of the item's InteractionSource into the
407 // coordinates of the indicator.
408 val deltaOffset: Offset
409 with(LocalDensity.current) {
410 deltaOffset =
411 Offset(
412 (itemWidth - topIconIndicatorWidth.roundToPx()).toFloat() / 2,
413 IndicatorVerticalOffset.toPx()
414 )
415 }
416 offsetInteractionSource =
417 remember(interactionSource, deltaOffset) {
418 MappedInteractionSource(interactionSource, deltaOffset)
419 }
420 }
421
422 AnimatedNavigationItemLayout(
423 interactionSource = offsetInteractionSource ?: interactionSource,
424 indicatorColor = colors.selectedIndicatorColor,
425 indicatorShape = indicatorShape,
426 indicatorAnimationProgress = { indicatorAnimationProgress.value.coerceAtLeast(0f) },
427 icon = styledIcon,
428 iconPosition = iconPosition,
429 iconPositionProgress = { iconPositionProgress.coerceAtLeast(0f) },
430 label = styledLabel,
431 topIconIndicatorHorizontalPadding = topIconIndicatorHorizontalPadding,
432 topIconIndicatorVerticalPadding = topIconIndicatorVerticalPadding,
433 topIconIndicatorToLabelVerticalPadding = topIconIndicatorToLabelVerticalPadding,
434 startIconIndicatorHorizontalPadding = startIconIndicatorHorizontalPadding,
435 startIconIndicatorVerticalPadding = startIconIndicatorVerticalPadding,
436 noLabelIndicatorPadding = noLabelIndicatorPadding,
437 startIconToLabelHorizontalPadding = startIconToLabelHorizontalPadding,
438 itemHorizontalPadding = itemHorizontalPadding
439 )
440 }
441 }
442
443 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
444 @Composable
NavigationItemLayoutnull445 private fun NavigationItemLayout(
446 interactionSource: InteractionSource,
447 indicatorColor: Color,
448 indicatorShape: Shape,
449 icon: @Composable () -> Unit,
450 iconPosition: NavigationItemIconPosition,
451 label: @Composable (() -> Unit)?,
452 indicatorAnimationProgress: () -> Float,
453 indicatorHorizontalPadding: Dp,
454 indicatorVerticalPadding: Dp,
455 indicatorToLabelVerticalPadding: Dp,
456 startIconToLabelHorizontalPadding: Dp,
457 topIconItemVerticalPadding: Dp
458 ) {
459 Layout(
460 modifier = Modifier.badgeBounds(),
461 content = {
462 // Create the indicator ripple.
463 IndicatorRipple(interactionSource, indicatorShape)
464 // Create the indicator. The indicator has a width-expansion animation which interferes
465 // with the timing of the ripple, which is why they are separate composables.
466 Indicator(indicatorColor, indicatorShape, indicatorAnimationProgress)
467
468 Box(Modifier.layoutId(IconLayoutIdTag)) { icon() }
469
470 if (label != null) {
471 Box(Modifier.layoutId(LabelLayoutIdTag)) { label() }
472 }
473 },
474 measurePolicy =
475 if (label == null || iconPosition == NavigationItemIconPosition.Top) {
476 TopIconOrIconOnlyMeasurePolicy(
477 label != null,
478 indicatorAnimationProgress,
479 indicatorHorizontalPadding,
480 indicatorVerticalPadding,
481 indicatorToLabelVerticalPadding,
482 topIconItemVerticalPadding
483 )
484 } else {
485 StartIconMeasurePolicy(
486 indicatorAnimationProgress,
487 indicatorHorizontalPadding,
488 indicatorVerticalPadding,
489 startIconToLabelHorizontalPadding,
490 )
491 }
492 )
493 }
494
495 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
496 @Composable
AnimatedNavigationItemLayoutnull497 private fun AnimatedNavigationItemLayout(
498 interactionSource: InteractionSource,
499 indicatorColor: Color,
500 indicatorShape: Shape,
501 indicatorAnimationProgress: () -> Float,
502 icon: @Composable () -> Unit,
503 iconPosition: NavigationItemIconPosition,
504 iconPositionProgress: () -> Float,
505 label: @Composable (() -> Unit)?,
506 topIconIndicatorHorizontalPadding: Dp,
507 topIconIndicatorVerticalPadding: Dp,
508 topIconIndicatorToLabelVerticalPadding: Dp,
509 startIconIndicatorHorizontalPadding: Dp,
510 startIconIndicatorVerticalPadding: Dp,
511 noLabelIndicatorPadding: Dp,
512 startIconToLabelHorizontalPadding: Dp,
513 itemHorizontalPadding: Dp,
514 ) {
515 Layout(
516 modifier = Modifier.badgeBounds(),
517 content = {
518 // Create the indicator ripple.
519 IndicatorRipple(interactionSource, indicatorShape)
520 // Create the indicator. The indicator has a width-expansion animation which interferes
521 // with the timing of the ripple, which is why they are separate composables.
522 Indicator(indicatorColor, indicatorShape, indicatorAnimationProgress)
523
524 Box(Modifier.layoutId(IconLayoutIdTag)) { icon() }
525
526 if (label != null) {
527 Box(Modifier.layoutId(LabelLayoutIdTag)) { label() }
528 }
529 },
530 measurePolicy =
531 if (label != null) {
532 AnimatedMeasurePolicy(
533 iconPosition = iconPosition,
534 iconPositionProgress = iconPositionProgress,
535 indicatorAnimationProgress = indicatorAnimationProgress,
536 topIconIndicatorHorizontalPadding = topIconIndicatorHorizontalPadding,
537 topIconIndicatorVerticalPadding = topIconIndicatorVerticalPadding,
538 topIconIndicatorToLabelVerticalPadding = topIconIndicatorToLabelVerticalPadding,
539 startIconIndicatorHorizontalPadding = startIconIndicatorHorizontalPadding,
540 startIconIndicatorVerticalPadding = startIconIndicatorVerticalPadding,
541 startIconToLabelHorizontalPadding = startIconToLabelHorizontalPadding,
542 itemHorizontalPadding = itemHorizontalPadding
543 )
544 } else {
545 // If no label, default to circular indicator for the item.
546 TopIconOrIconOnlyMeasurePolicy(
547 hasLabel = false,
548 indicatorAnimationProgress = indicatorAnimationProgress,
549 indicatorHorizontalPadding = noLabelIndicatorPadding,
550 indicatorVerticalPadding = noLabelIndicatorPadding,
551 indicatorToLabelVerticalPadding = 0.dp,
552 topIconItemVerticalPadding = 0.dp
553 )
554 }
555 )
556 }
557
558 private class TopIconOrIconOnlyMeasurePolicy(
559 val hasLabel: Boolean,
560 val indicatorAnimationProgress: () -> Float,
561 val indicatorHorizontalPadding: Dp,
562 val indicatorVerticalPadding: Dp,
563 val indicatorToLabelVerticalPadding: Dp,
564 val topIconItemVerticalPadding: Dp,
565 ) : MeasurePolicy {
measurenull566 override fun MeasureScope.measure(
567 measurables: List<Measurable>,
568 constraints: Constraints
569 ): MeasureResult {
570 @Suppress("NAME_SHADOWING") val indicatorAnimationProgress = indicatorAnimationProgress()
571 val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
572 // When measuring icon, account for the indicator in its constraints.
573 val iconPlaceable =
574 measurables
575 .fastFirst { it.layoutId == IconLayoutIdTag }
576 .measure(
577 looseConstraints.offset(
578 horizontal = -(indicatorHorizontalPadding * 2).roundToPx(),
579 vertical = -(indicatorVerticalPadding * 2).roundToPx()
580 )
581 )
582 // Next, when measuring the indicator and ripple, still need to obey looseConstraints.
583 val totalIndicatorWidth = iconPlaceable.width + (indicatorHorizontalPadding * 2).roundToPx()
584 val indicatorHeight = iconPlaceable.height + (indicatorVerticalPadding * 2).roundToPx()
585 val animatedIndicatorWidth = (totalIndicatorWidth * indicatorAnimationProgress).roundToInt()
586 val indicatorRipplePlaceable =
587 measurables
588 .fastFirst { it.layoutId == IndicatorRippleLayoutIdTag }
589 .measure(
590 looseConstraints.constrain(
591 Constraints.fixed(width = totalIndicatorWidth, height = indicatorHeight)
592 )
593 )
594 val indicatorPlaceable =
595 measurables
596 .fastFirst { it.layoutId == IndicatorLayoutIdTag }
597 .measure(
598 looseConstraints.constrain(
599 Constraints.fixed(width = animatedIndicatorWidth, height = indicatorHeight)
600 )
601 )
602
603 return if (hasLabel) {
604 // When measuring label, account for the indicator and the padding between indicator and
605 // label.
606 val labelPlaceable =
607 measurables
608 .fastFirst { it.layoutId == LabelLayoutIdTag }
609 .measure(
610 looseConstraints.offset(
611 vertical =
612 -(indicatorPlaceable.height +
613 indicatorToLabelVerticalPadding.roundToPx())
614 )
615 )
616
617 placeLabelAndTopIcon(
618 labelPlaceable,
619 iconPlaceable,
620 indicatorRipplePlaceable,
621 indicatorPlaceable,
622 constraints,
623 indicatorToLabelVerticalPadding,
624 indicatorVerticalPadding,
625 topIconItemVerticalPadding
626 )
627 } else {
628 placeIcon(iconPlaceable, indicatorRipplePlaceable, indicatorPlaceable, constraints)
629 }
630 }
631
maxIntrinsicHeightnull632 override fun IntrinsicMeasureScope.maxIntrinsicHeight(
633 measurables: List<IntrinsicMeasurable>,
634 width: Int
635 ): Int {
636 val iconHeight =
637 measurables.fastFirst { it.layoutId == IconLayoutIdTag }.maxIntrinsicHeight(width)
638 val labelHeight =
639 measurables
640 .fastFirstOrNull { it.layoutId == LabelLayoutIdTag }
641 ?.maxIntrinsicHeight(width) ?: 0
642 val paddings =
643 (topIconItemVerticalPadding * 2 +
644 indicatorVerticalPadding * 2 +
645 indicatorToLabelVerticalPadding)
646 .roundToPx()
647
648 return iconHeight + labelHeight + paddings
649 }
650 }
651
652 private class StartIconMeasurePolicy(
653 val indicatorAnimationProgress: () -> Float,
654 val indicatorHorizontalPadding: Dp,
655 val indicatorVerticalPadding: Dp,
656 val startIconToLabelHorizontalPadding: Dp,
657 ) : MeasurePolicy {
measurenull658 override fun MeasureScope.measure(
659 measurables: List<Measurable>,
660 constraints: Constraints
661 ): MeasureResult {
662 @Suppress("NAME_SHADOWING") val indicatorAnimationProgress = indicatorAnimationProgress()
663 val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
664 // When measuring icon, account for the indicator in its constraints.
665 val iconPlaceable =
666 measurables.fastFirst { it.layoutId == IconLayoutIdTag }.measure(looseConstraints)
667 // When measuring the label, account for the indicator, the icon, and the padding between
668 // icon and label.
669 val labelPlaceable =
670 measurables
671 .fastFirst { it.layoutId == LabelLayoutIdTag }
672 .measure(
673 looseConstraints.offset(
674 horizontal =
675 -(iconPlaceable.width + startIconToLabelHorizontalPadding.roundToPx())
676 )
677 )
678
679 val totalIndicatorWidth =
680 iconPlaceable.width +
681 labelPlaceable.width +
682 (startIconToLabelHorizontalPadding + indicatorHorizontalPadding * 2).roundToPx()
683 val indicatorHeight =
684 max(iconPlaceable.height, labelPlaceable.height) +
685 (indicatorVerticalPadding * 2).roundToPx()
686 val animatedIndicatorWidth = (totalIndicatorWidth * indicatorAnimationProgress).roundToInt()
687 // When measuring the indicator and ripple, still need to obey looseConstraints.
688 val indicatorRipplePlaceable =
689 measurables
690 .fastFirst { it.layoutId == IndicatorRippleLayoutIdTag }
691 .measure(
692 looseConstraints.constrain(
693 Constraints.fixed(width = totalIndicatorWidth, height = indicatorHeight)
694 )
695 )
696 val indicatorPlaceable =
697 measurables
698 .fastFirst { it.layoutId == IndicatorLayoutIdTag }
699 .measure(
700 looseConstraints.constrain(
701 Constraints.fixed(width = animatedIndicatorWidth, height = indicatorHeight)
702 )
703 )
704
705 return placeLabelAndStartIcon(
706 labelPlaceable,
707 iconPlaceable,
708 indicatorRipplePlaceable,
709 indicatorPlaceable,
710 constraints,
711 startIconToLabelHorizontalPadding
712 )
713 }
714
maxIntrinsicWidthnull715 override fun IntrinsicMeasureScope.maxIntrinsicWidth(
716 measurables: List<IntrinsicMeasurable>,
717 height: Int
718 ): Int {
719 val iconWidth =
720 measurables.fastFirst { it.layoutId == IconLayoutIdTag }.maxIntrinsicWidth(height)
721 val labelWidth =
722 measurables.fastFirst { it.layoutId == LabelLayoutIdTag }.maxIntrinsicWidth(height)
723 val paddings =
724 (indicatorHorizontalPadding * 2 + startIconToLabelHorizontalPadding).roundToPx()
725
726 return iconWidth + labelWidth + paddings
727 }
728
maxIntrinsicHeightnull729 override fun IntrinsicMeasureScope.maxIntrinsicHeight(
730 measurables: List<IntrinsicMeasurable>,
731 width: Int
732 ): Int {
733 val iconHeight =
734 measurables.fastFirst { it.layoutId == IconLayoutIdTag }.maxIntrinsicHeight(width)
735 val labelHeight =
736 measurables.fastFirst { it.layoutId == LabelLayoutIdTag }.maxIntrinsicHeight(width)
737 val paddings = (indicatorVerticalPadding * 2).roundToPx()
738
739 return max(iconHeight, labelHeight) + paddings
740 }
741 }
742
743 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
744 private class AnimatedMeasurePolicy(
745 val iconPosition: NavigationItemIconPosition,
746 val iconPositionProgress: () -> Float,
747 val indicatorAnimationProgress: () -> Float,
748 val topIconIndicatorHorizontalPadding: Dp,
749 val topIconIndicatorVerticalPadding: Dp,
750 val topIconIndicatorToLabelVerticalPadding: Dp,
751 val startIconIndicatorHorizontalPadding: Dp,
752 val startIconIndicatorVerticalPadding: Dp,
753 val startIconToLabelHorizontalPadding: Dp,
754 val itemHorizontalPadding: Dp,
755 ) : MeasurePolicy {
measurenull756 override fun MeasureScope.measure(
757 measurables: List<Measurable>,
758 constraints: Constraints
759 ): MeasureResult {
760 @Suppress("NAME_SHADOWING") val indicatorAnimationProgress = indicatorAnimationProgress()
761 val iconPositionProgressValue = iconPositionProgress()
762 val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
763
764 val iconPlaceable =
765 measurables.fastFirst { it.layoutId == IconLayoutIdTag }.measure(looseConstraints)
766
767 val labelPlaceable =
768 measurables.fastFirst { it.layoutId == LabelLayoutIdTag }.measure(looseConstraints)
769
770 val topIconIndicatorWidth =
771 iconPlaceable.width + (topIconIndicatorHorizontalPadding * 2).roundToPx()
772 val topIconIndicatorHeight =
773 iconPlaceable.height + (topIconIndicatorVerticalPadding * 2).roundToPx()
774
775 val startIconIndicatorWidth =
776 iconPlaceable.width +
777 labelPlaceable.width +
778 (startIconToLabelHorizontalPadding + startIconIndicatorHorizontalPadding * 2)
779 .roundToPx()
780 val startIconIndicatorHeight =
781 max(iconPlaceable.height, labelPlaceable.height) +
782 (startIconIndicatorVerticalPadding * 2).roundToPx()
783
784 val indicatorWidthProgress =
785 lerp(topIconIndicatorWidth, startIconIndicatorWidth, iconPositionProgressValue)
786 val animatedIndicatorWidth =
787 (indicatorWidthProgress * indicatorAnimationProgress).roundToInt()
788 val indicatorHeightProgress =
789 lerp(topIconIndicatorHeight, startIconIndicatorHeight, iconPositionProgressValue)
790
791 val indicatorRipplePlaceable =
792 measurables
793 .fastFirst { it.layoutId == IndicatorRippleLayoutIdTag }
794 .measure(
795 looseConstraints.constrain(
796 Constraints.fixed(
797 width = indicatorWidthProgress,
798 height = indicatorHeightProgress
799 )
800 )
801 )
802 val indicatorPlaceable =
803 measurables
804 .fastFirst { it.layoutId == IndicatorLayoutIdTag }
805 .measure(
806 looseConstraints.constrain(
807 Constraints.fixed(
808 width = animatedIndicatorWidth,
809 height = indicatorHeightProgress
810 )
811 )
812 )
813
814 return placeAnimatedLabelAndIcon(
815 iconPosition = iconPosition,
816 iconPositionProgress = iconPositionProgress,
817 labelPlaceable = labelPlaceable,
818 iconPlaceable = iconPlaceable,
819 indicatorRipplePlaceable = indicatorRipplePlaceable,
820 indicatorPlaceable = indicatorPlaceable,
821 topIconIndicatorWidth = topIconIndicatorWidth,
822 constraints = looseConstraints,
823 topIconIndicatorToLabelVerticalPadding = topIconIndicatorToLabelVerticalPadding,
824 topIconIndicatorVerticalPadding = topIconIndicatorVerticalPadding,
825 topIconIndicatorHorizontalPadding = topIconIndicatorHorizontalPadding,
826 startIconIndicatorHorizontalPadding = startIconIndicatorHorizontalPadding,
827 startIconIndicatorVerticalPadding = startIconIndicatorVerticalPadding,
828 startIconToLabelHorizontalPadding = startIconToLabelHorizontalPadding,
829 itemHorizontalPadding = itemHorizontalPadding
830 )
831 }
832
maxIntrinsicWidthnull833 override fun IntrinsicMeasureScope.maxIntrinsicWidth(
834 measurables: List<IntrinsicMeasurable>,
835 height: Int
836 ): Int {
837 val iconWidth =
838 measurables.fastFirst { it.layoutId == IconLayoutIdTag }.maxIntrinsicWidth(height)
839 val labelWidth =
840 measurables.fastFirst { it.layoutId == LabelLayoutIdTag }.maxIntrinsicWidth(height)
841
842 if (iconPosition == NavigationItemIconPosition.Top) {
843 val paddings =
844 (topIconIndicatorHorizontalPadding * 2 + itemHorizontalPadding * 2).roundToPx()
845 return maxOf(labelWidth, (iconWidth + paddings))
846 } else {
847 val paddings =
848 (startIconIndicatorHorizontalPadding * 2 +
849 startIconToLabelHorizontalPadding +
850 itemHorizontalPadding)
851 .roundToPx()
852 return iconWidth + labelWidth + paddings
853 }
854 }
855 }
856
857 /**
858 * Places the provided [Placeable]s in the correct position.
859 *
860 * @param iconPlaceable icon placeable inside this item
861 * @param indicatorRipplePlaceable indicator ripple placeable inside this item
862 * @param indicatorPlaceable indicator placeable inside this item
863 * @param constraints constraints of the item
864 */
MeasureScopenull865 private fun MeasureScope.placeIcon(
866 iconPlaceable: Placeable,
867 indicatorRipplePlaceable: Placeable,
868 indicatorPlaceable: Placeable,
869 constraints: Constraints
870 ): MeasureResult {
871 val width = constraints.constrainWidth(indicatorRipplePlaceable.width)
872 val height = constraints.constrainHeight(indicatorRipplePlaceable.height)
873
874 val indicatorX = (width - indicatorPlaceable.width) / 2
875 val indicatorY = (height - indicatorPlaceable.height) / 2
876 val iconX = (width - iconPlaceable.width) / 2
877 val iconY = (height - iconPlaceable.height) / 2
878 val rippleX = (width - indicatorRipplePlaceable.width) / 2
879 val rippleY = (height - indicatorRipplePlaceable.height) / 2
880
881 return layout(width, height) {
882 indicatorPlaceable.placeRelative(indicatorX, indicatorY)
883 iconPlaceable.placeRelative(iconX, iconY)
884 indicatorRipplePlaceable.placeRelative(rippleX, rippleY)
885 }
886 }
887
888 /**
889 * Places the provided [Placeable]s in the correct position.
890 *
891 * @param labelPlaceable text label placeable inside this item
892 * @param iconPlaceable icon placeable inside this item
893 * @param indicatorRipplePlaceable indicator ripple placeable inside this item
894 * @param indicatorPlaceable indicator placeable inside this item, if it exists
895 * @param constraints constraints of the item
896 * @param indicatorToLabelVerticalPadding the padding between the bottom of the indicator and the
897 * top of the label
898 * @param indicatorVerticalPadding vertical padding of the indicator
899 * @param topIconItemVerticalPadding vertical padding of the item
900 */
MeasureScopenull901 private fun MeasureScope.placeLabelAndTopIcon(
902 labelPlaceable: Placeable,
903 iconPlaceable: Placeable,
904 indicatorRipplePlaceable: Placeable,
905 indicatorPlaceable: Placeable,
906 constraints: Constraints,
907 indicatorToLabelVerticalPadding: Dp,
908 indicatorVerticalPadding: Dp,
909 topIconItemVerticalPadding: Dp,
910 ): MeasureResult {
911 val width =
912 constraints.constrainWidth(maxOf(labelPlaceable.width, indicatorRipplePlaceable.width))
913 val contentHeight =
914 indicatorRipplePlaceable.height +
915 indicatorToLabelVerticalPadding.toPx() +
916 labelPlaceable.height
917 val height =
918 constraints.constrainHeight(
919 (contentHeight + topIconItemVerticalPadding.toPx() * 2).roundToInt()
920 )
921
922 val iconY = (topIconItemVerticalPadding + indicatorVerticalPadding).roundToPx()
923 val iconX = (width - iconPlaceable.width) / 2
924 val indicatorX = (width - indicatorPlaceable.width) / 2
925 val indicatorY = iconY - indicatorVerticalPadding.roundToPx()
926 val labelX = (width - labelPlaceable.width) / 2
927 // Label should be fixed padding below icon.
928 val labelY =
929 iconY +
930 iconPlaceable.height +
931 (indicatorVerticalPadding + indicatorToLabelVerticalPadding).roundToPx()
932 val rippleX = (width - indicatorRipplePlaceable.width) / 2
933 val rippleY = indicatorY
934
935 return layout(width, height) {
936 indicatorPlaceable.placeRelative(indicatorX, indicatorY)
937 labelPlaceable.placeRelative(labelX, labelY)
938 iconPlaceable.placeRelative(iconX, iconY)
939 indicatorRipplePlaceable.placeRelative(rippleX, rippleY)
940 }
941 }
942
943 /**
944 * Places the provided [Placeable]s in the correct position.
945 *
946 * @param labelPlaceable text label placeable inside this item
947 * @param iconPlaceable icon placeable inside this item
948 * @param indicatorRipplePlaceable indicator ripple placeable inside this item
949 * @param indicatorPlaceable indicator placeable inside this item
950 * @param constraints constraints of the item
951 * @param startIconToLabelHorizontalPadding the padding between end of the icon and the start of the
952 * label of this item
953 */
MeasureScopenull954 private fun MeasureScope.placeLabelAndStartIcon(
955 labelPlaceable: Placeable,
956 iconPlaceable: Placeable,
957 indicatorRipplePlaceable: Placeable,
958 indicatorPlaceable: Placeable,
959 constraints: Constraints,
960 startIconToLabelHorizontalPadding: Dp
961 ): MeasureResult {
962 val width = constraints.constrainWidth(indicatorRipplePlaceable.width)
963 val height = constraints.constrainHeight(indicatorRipplePlaceable.height)
964
965 val indicatorX = (width - indicatorPlaceable.width) / 2
966 val indicatorY = (height - indicatorPlaceable.height) / 2
967 val iconY = (height - iconPlaceable.height) / 2
968 val labelY = (height - labelPlaceable.height) / 2
969 val itemContentWidth =
970 iconPlaceable.width + startIconToLabelHorizontalPadding.roundToPx() + labelPlaceable.width
971 val iconX = (width - itemContentWidth) / 2
972 val labelX = iconX + iconPlaceable.width + startIconToLabelHorizontalPadding.roundToPx()
973 val rippleX = (width - indicatorRipplePlaceable.width) / 2
974 val rippleY = (height - indicatorRipplePlaceable.height) / 2
975
976 return layout(width, height) {
977 indicatorPlaceable.placeRelative(indicatorX, indicatorY)
978 labelPlaceable.placeRelative(labelX, labelY)
979 iconPlaceable.placeRelative(iconX, iconY)
980 indicatorRipplePlaceable.placeRelative(rippleX, rippleY)
981 }
982 }
983
984 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
MeasureScopenull985 private fun MeasureScope.placeAnimatedLabelAndIcon(
986 iconPosition: NavigationItemIconPosition,
987 iconPositionProgress: () -> Float,
988 labelPlaceable: Placeable,
989 iconPlaceable: Placeable,
990 indicatorRipplePlaceable: Placeable,
991 indicatorPlaceable: Placeable,
992 topIconIndicatorWidth: Int,
993 constraints: Constraints,
994 topIconIndicatorToLabelVerticalPadding: Dp,
995 topIconIndicatorVerticalPadding: Dp,
996 topIconIndicatorHorizontalPadding: Dp,
997 startIconIndicatorHorizontalPadding: Dp,
998 startIconIndicatorVerticalPadding: Dp,
999 startIconToLabelHorizontalPadding: Dp,
1000 itemHorizontalPadding: Dp,
1001 ): MeasureResult {
1002 @Suppress("NAME_SHADOWING") val iconPositionProgress = iconPositionProgress()
1003 val isIconPositionTop = iconPosition == NavigationItemIconPosition.Top
1004 val widthTopIcon =
1005 constraints.constrainWidth(
1006 maxOf(
1007 labelPlaceable.width,
1008 (topIconIndicatorWidth + (itemHorizontalPadding * 2).roundToPx())
1009 )
1010 )
1011 val widthStartIcon =
1012 constraints.constrainWidth(
1013 indicatorRipplePlaceable.width + itemHorizontalPadding.roundToPx()
1014 )
1015 val width = widthTopIcon + (widthStartIcon - widthTopIcon) * iconPositionProgress
1016
1017 val heightTopIcon =
1018 constraints.constrainHeight(
1019 (indicatorRipplePlaceable.height +
1020 topIconIndicatorToLabelVerticalPadding.toPx() +
1021 labelPlaceable.height)
1022 .roundToInt()
1023 )
1024 val heightStartIcon = constraints.constrainHeight(indicatorRipplePlaceable.height)
1025 val height = lerp(heightTopIcon, heightStartIcon, iconPositionProgress)
1026
1027 val rippleX = itemHorizontalPadding.roundToPx()
1028 val indicatorXTopIcon = ((width - indicatorPlaceable.width) / 2).roundToInt()
1029 val indicatorXStartIcon = ((rippleX + width - indicatorPlaceable.width) / 2).roundToInt()
1030 val indicatorX =
1031 if (iconPositionProgress == 0f || iconPositionProgress == 1f) {
1032 // If not animating, indicator must expand from center.
1033 lerp(indicatorXTopIcon, indicatorXStartIcon, iconPositionProgress)
1034 } else {
1035 itemHorizontalPadding.roundToPx()
1036 }
1037
1038 val iconXTopIcon = rippleX + topIconIndicatorHorizontalPadding.roundToPx()
1039 val iconXStartIcon = rippleX + startIconIndicatorHorizontalPadding.roundToPx()
1040
1041 val iconYTopIcon = topIconIndicatorVerticalPadding.roundToPx()
1042 val iconYStartIcon = startIconIndicatorVerticalPadding.roundToPx()
1043
1044 val iconX = lerp(iconXTopIcon, iconXStartIcon, iconPositionProgress)
1045 val iconY = lerp(iconYTopIcon, iconYStartIcon, iconPositionProgress)
1046
1047 val labelXTopIcon =
1048 ((iconPlaceable.width +
1049 (topIconIndicatorHorizontalPadding * 2 + itemHorizontalPadding * 2).roundToPx()) -
1050 labelPlaceable.width) / 2
1051 val labelYTopIcon =
1052 iconY +
1053 iconPlaceable.height +
1054 (topIconIndicatorToLabelVerticalPadding + topIconIndicatorToLabelVerticalPadding)
1055 .roundToPx()
1056
1057 val labelXStartIconHorizontalOffset =
1058 if (isIconPositionTop && iconPositionProgress > 0f) {
1059 0f
1060 } else {
1061 itemHorizontalPadding.roundToPx() * (1f - iconPositionProgress)
1062 }
1063 val labelXStartIcon =
1064 iconX + iconPlaceable.width + startIconToLabelHorizontalPadding.roundToPx() -
1065 labelXStartIconHorizontalOffset
1066 val labelYStartIcon = (height - labelPlaceable.height) / 2
1067 val labelX =
1068 if (iconPositionProgress < 0.5f) labelXTopIcon else labelXStartIcon * iconPositionProgress
1069 val labelY = if (iconPositionProgress < 0.5f) labelYTopIcon else labelYStartIcon
1070 return layout(width.roundToInt(), height) {
1071 indicatorPlaceable.placeRelativeWithLayer(indicatorX, 0)
1072 iconPlaceable.placeRelativeWithLayer(iconX, iconY)
1073 labelPlaceable.placeRelativeWithLayer(
1074 x = labelX.toInt(),
1075 y = labelY,
1076 layerBlock = {
1077 alpha = 4 * (iconPositionProgress - 0.5f) * (iconPositionProgress - 0.5f)
1078 }
1079 )
1080 indicatorRipplePlaceable.placeRelativeWithLayer(rippleX, 0)
1081 }
1082 }
1083
1084 @Composable
StyledLabelnull1085 private fun StyledLabel(
1086 selected: Boolean,
1087 labelTextStyle: TextStyle,
1088 colors: NavigationItemColors,
1089 enabled: Boolean,
1090 content: @Composable () -> Unit,
1091 ) {
1092 val textColor = colors.textColor(selected = selected, enabled = enabled)
1093 ProvideContentColorTextStyle(
1094 contentColor = textColor,
1095 textStyle = labelTextStyle,
1096 content = content
1097 )
1098 }
1099
1100 @Composable
animateIndicatorProgressAsStatenull1101 private fun animateIndicatorProgressAsState(selected: Boolean) =
1102 animateFloatAsState(
1103 targetValue = if (selected) 1f else 0f,
1104 // TODO Load the motionScheme tokens from the component tokens file
1105 animationSpec = MotionSchemeKeyTokens.DefaultSpatial.value()
1106 )
1107
1108 @Composable
1109 private fun IndicatorRipple(interactionSource: InteractionSource, indicatorShape: Shape) {
1110 Box(
1111 Modifier.layoutId(IndicatorRippleLayoutIdTag)
1112 .clip(indicatorShape)
1113 .indication(interactionSource, ripple())
1114 )
1115 }
1116
1117 @Composable
Indicatornull1118 private fun Indicator(
1119 indicatorColor: Color,
1120 indicatorShape: Shape,
1121 indicatorAnimationProgress: () -> Float,
1122 ) {
1123 Box(
1124 Modifier.layoutId(IndicatorLayoutIdTag)
1125 .graphicsLayer { alpha = indicatorAnimationProgress() }
1126 .background(
1127 color = indicatorColor,
1128 shape = indicatorShape,
1129 )
1130 )
1131 }
1132
1133 private const val IndicatorRippleLayoutIdTag: String = "indicatorRipple"
1134 private const val IndicatorLayoutIdTag: String = "indicator"
1135 private const val IconLayoutIdTag: String = "icon"
1136 private const val LabelLayoutIdTag: String = "label"
1137
1138 private val IndicatorVerticalOffset: Dp = 12.dp
1139