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