Clip navigation bar/rail ripple to indicator pill
Bug: b/228589523
Test: manual
Change-Id: I43bc69e2e6ad888b8d443f75b8ca2be815d92dbf
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/NavigationBarTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/NavigationBarTest.kt
index 5782d58..8f0d760 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/NavigationBarTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/NavigationBarTest.kt
@@ -19,6 +19,7 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material3.tokens.NavigationBarTokens
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,7 +60,6 @@
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import androidx.compose.material3.tokens.NavigationBarTokens
@LargeTest
@RunWith(AndroidJUnit4::class)
@@ -174,17 +174,20 @@
rule.runOnIdleWithDensity {
val totalWidth = parentCoords.size.width
+ val availableWidth =
+ totalWidth.toFloat() - (NavigationBarItemHorizontalPadding.toPx() * 3)
- val expectedItemWidth = totalWidth / 4
- val expectedItemHeight = NavigationBarTokens.ContainerHeight.roundToPx()
+ val expectedItemWidth = (availableWidth / 4)
+ val expectedItemHeight = NavigationBarTokens.ContainerHeight.toPx()
Truth.assertThat(itemCoords.size).isEqualTo(4)
itemCoords.forEach { (index, coord) ->
- Truth.assertThat(coord.size.width).isEqualTo(expectedItemWidth)
- Truth.assertThat(coord.size.height).isEqualTo(expectedItemHeight)
- Truth.assertThat(coord.positionInWindow().x)
- .isEqualTo((expectedItemWidth * index).toFloat())
+ // Rounding differences for width can occur on smaller screens
+ Truth.assertThat(coord.size.width.toFloat()).isWithin(1f).of(expectedItemWidth)
+ Truth.assertThat(coord.size.height).isEqualTo(expectedItemHeight.toInt())
+ Truth.assertThat(coord.positionInWindow().x).isWithin(1f)
+ .of((expectedItemWidth + NavigationBarItemHorizontalPadding.toPx()) * index)
}
}
}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MappedInteractionSource.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MappedInteractionSource.kt
new file mode 100644
index 0000000..3004270
--- /dev/null
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MappedInteractionSource.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3
+
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.ui.geometry.Offset
+import kotlinx.coroutines.flow.map
+
+/**
+ * Adapts an [InteractionSource] from one component to another by mapping any interactions by a
+ * given offset. Namely used for the pill indicator in [NavigationBarItem] and [NavigationRailItem].
+ */
+internal class MappedInteractionSource(
+ underlyingInteractionSource: InteractionSource,
+ private val delta: Offset
+) : InteractionSource {
+ private val mappedPresses =
+ mutableMapOf<PressInteraction.Press, PressInteraction.Press>()
+
+ override val interactions = underlyingInteractionSource.interactions.map { interaction ->
+ when (interaction) {
+ is PressInteraction.Press -> {
+ val mappedPress = mapPress(interaction)
+ mappedPresses[interaction] = mappedPress
+ mappedPress
+ }
+ is PressInteraction.Cancel -> {
+ val mappedPress = mappedPresses.remove(interaction.press)
+ if (mappedPress == null) {
+ interaction
+ } else {
+ PressInteraction.Cancel(mappedPress)
+ }
+ }
+ is PressInteraction.Release -> {
+ val mappedPress = mappedPresses.remove(interaction.press)
+ if (mappedPress == null) {
+ interaction
+ } else {
+ PressInteraction.Release(mappedPress)
+ }
+ }
+ else -> interaction
+ }
+ }
+
+ private fun mapPress(press: PressInteraction.Press): PressInteraction.Press =
+ PressInteraction.Press(press.pressPosition - delta)
+}
\ No newline at end of file
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationBar.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationBar.kt
index 5fc7b6f..85fc603 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationBar.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationBar.kt
@@ -20,6 +20,7 @@
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
+import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -38,10 +39,14 @@
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.Layout
@@ -49,6 +54,8 @@
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
@@ -99,7 +106,7 @@
) {
Row(
modifier = Modifier.fillMaxWidth().height(NavigationBarHeight).selectableGroup(),
- horizontalArrangement = Arrangement.SpaceBetween,
+ horizontalArrangement = Arrangement.spacedBy(NavigationBarItemHorizontalPadding),
content = content
)
}
@@ -166,6 +173,8 @@
}
}
+ var itemWidth by remember { mutableStateOf(0) }
+
Box(
modifier
.selectable(
@@ -174,9 +183,12 @@
enabled = enabled,
role = Role.Tab,
interactionSource = interactionSource,
- indication = rememberRipple(),
+ indication = null,
)
- .weight(1f),
+ .weight(1f)
+ .onSizeChanged {
+ itemWidth = it.width
+ },
contentAlignment = Alignment.Center
) {
val animationProgress: Float by animateFloatAsState(
@@ -184,6 +196,30 @@
animationSpec = tween(ItemAnimationDurationMillis)
)
+ // The entire item is selectable, but only the indicator pill shows the ripple. To achieve
+ // this, we re-map the coordinates of the item's InteractionSource into the coordinates of
+ // the indicator.
+ val deltaOffset: Offset
+ with(LocalDensity.current) {
+ val indicatorWidth = NavigationBarTokens.ActiveIndicatorWidth.roundToPx()
+ deltaOffset = Offset(
+ (itemWidth - indicatorWidth).toFloat() / 2,
+ IndicatorVerticalOffset.toPx()
+ )
+ }
+ val offsetInteractionSource = remember(interactionSource, deltaOffset) {
+ MappedInteractionSource(interactionSource, deltaOffset)
+ }
+
+ // The indicator has a width-expansion animation which interferes with the timing of the
+ // ripple, which is why they are separate composables
+ val indicatorRipple = @Composable {
+ Box(
+ Modifier.layoutId(IndicatorRippleLayoutIdTag)
+ .clip(NavigationBarTokens.ActiveIndicatorShape.toShape())
+ .indication(offsetInteractionSource, rememberRipple())
+ )
+ }
val indicator = @Composable {
Box(
Modifier.layoutId(IndicatorLayoutIdTag)
@@ -195,6 +231,7 @@
}
NavigationBarItemBaselineLayout(
+ indicatorRipple = indicatorRipple,
indicator = indicator,
icon = styledIcon,
label = styledLabel,
@@ -297,6 +334,7 @@
/**
* Base layout for a [NavigationBarItem].
*
+ * @param indicatorRipple indicator ripple for this item when it is selected
* @param indicator indicator for this item when it is selected
* @param icon icon for this item
* @param label text label for this item
@@ -308,6 +346,7 @@
*/
@Composable
private fun NavigationBarItemBaselineLayout(
+ indicatorRipple: @Composable () -> Unit,
indicator: @Composable () -> Unit,
icon: @Composable () -> Unit,
label: @Composable (() -> Unit)?,
@@ -315,6 +354,7 @@
animationProgress: Float,
) {
Layout({
+ indicatorRipple()
if (animationProgress > 0) {
indicator()
}
@@ -325,7 +365,7 @@
Box(
Modifier.layoutId(LabelLayoutIdTag)
.alpha(if (alwaysShowLabel) 1f else animationProgress)
- .padding(horizontal = NavigationBarItemHorizontalPadding)
+ .padding(horizontal = NavigationBarItemHorizontalPadding / 2)
) { label() }
}
}) { measurables, constraints ->
@@ -335,6 +375,15 @@
val totalIndicatorWidth = iconPlaceable.width + (IndicatorHorizontalPadding * 2).roundToPx()
val animatedIndicatorWidth = (totalIndicatorWidth * animationProgress).roundToInt()
val indicatorHeight = iconPlaceable.height + (IndicatorVerticalPadding * 2).roundToPx()
+ val indicatorRipplePlaceable =
+ measurables
+ .first { it.layoutId == IndicatorRippleLayoutIdTag }
+ .measure(
+ Constraints.fixed(
+ width = totalIndicatorWidth,
+ height = indicatorHeight
+ )
+ )
val indicatorPlaceable =
measurables
.firstOrNull { it.layoutId == IndicatorLayoutIdTag }
@@ -357,11 +406,12 @@
}
if (label == null) {
- placeIcon(iconPlaceable, indicatorPlaceable, constraints)
+ placeIcon(iconPlaceable, indicatorRipplePlaceable, indicatorPlaceable, constraints)
} else {
placeLabelAndIcon(
labelPlaceable!!,
iconPlaceable,
+ indicatorRipplePlaceable,
indicatorPlaceable,
constraints,
alwaysShowLabel,
@@ -372,11 +422,11 @@
}
/**
- * Places the provided [iconPlaceable], and possibly [indicatorPlaceable] if it exists, in the
- * center of the provided [constraints].
+ * Places the provided [Placeable]s in the center of the provided [constraints].
*/
private fun MeasureScope.placeIcon(
iconPlaceable: Placeable,
+ indicatorRipplePlaceable: Placeable,
indicatorPlaceable: Placeable?,
constraints: Constraints
): MeasureResult {
@@ -386,6 +436,9 @@
val iconX = (width - iconPlaceable.width) / 2
val iconY = (height - iconPlaceable.height) / 2
+ val rippleX = (width - indicatorRipplePlaceable.width) / 2
+ val rippleY = (height - indicatorRipplePlaceable.height) / 2
+
return layout(width, height) {
indicatorPlaceable?.let {
val indicatorX = (width - it.width) / 2
@@ -393,12 +446,13 @@
it.placeRelative(indicatorX, indicatorY)
}
iconPlaceable.placeRelative(iconX, iconY)
+ indicatorRipplePlaceable.placeRelative(rippleX, rippleY)
}
}
/**
- * Places the provided [labelPlaceable], [iconPlaceable], and [indicatorPlaceable] in the correct
- * position, depending on [alwaysShowLabel] and [animationProgress].
+ * Places the provided [Placeable]s in the correct position, depending on [alwaysShowLabel] and
+ * [animationProgress].
*
* When [alwaysShowLabel] is true, the positions do not move. The [iconPlaceable] will be placed
* near the top of the item and the [labelPlaceable] will be placed near the bottom, according to
@@ -413,11 +467,12 @@
* When [animationProgress] is animating between these values, [iconPlaceable] and [labelPlaceable]
* will be placed at a corresponding interpolated position.
*
- * [indicatorPlaceable] will always be placed in such a way that it shares the same center as
- * [iconPlaceable].
+ * [indicatorRipplePlaceable] and [indicatorPlaceable] will always be placed in such a way that to
+ * share the same center as [iconPlaceable].
*
* @param labelPlaceable text label placeable inside this item
* @param iconPlaceable icon placeable inside this item
+ * @param indicatorRipplePlaceable indicator ripple placeable inside this item
* @param indicatorPlaceable indicator placeable inside this item, if it exists
* @param constraints constraints of the item
* @param alwaysShowLabel whether to always show the label for this item. If true, icon and label
@@ -430,6 +485,7 @@
private fun MeasureScope.placeLabelAndIcon(
labelPlaceable: Placeable,
iconPlaceable: Placeable,
+ indicatorRipplePlaceable: Placeable,
indicatorPlaceable: Placeable?,
constraints: Constraints,
alwaysShowLabel: Boolean,
@@ -458,6 +514,9 @@
val labelX = (containerWidth - labelPlaceable.width) / 2
val iconX = (containerWidth - iconPlaceable.width) / 2
+ val rippleX = (containerWidth - indicatorRipplePlaceable.width) / 2
+ val rippleY = selectedIconY - IndicatorVerticalPadding.roundToPx()
+
return layout(containerWidth, height) {
indicatorPlaceable?.let {
val indicatorX = (containerWidth - it.width) / 2
@@ -468,9 +527,12 @@
labelPlaceable.placeRelative(labelX, labelY + offset)
}
iconPlaceable.placeRelative(iconX, selectedIconY + offset)
+ indicatorRipplePlaceable.placeRelative(rippleX, rippleY + offset)
}
}
+private const val IndicatorRippleLayoutIdTag: String = "indicatorRipple"
+
private const val IndicatorLayoutIdTag: String = "indicator"
private const val IconLayoutIdTag: String = "icon"
@@ -481,7 +543,8 @@
private const val ItemAnimationDurationMillis: Int = 100
-private val NavigationBarItemHorizontalPadding: Dp = 4.dp
+/*@VisibleForTesting*/
+internal val NavigationBarItemHorizontalPadding: Dp = 8.dp
/*@VisibleForTesting*/
internal val NavigationBarItemVerticalPadding: Dp = 16.dp
@@ -490,4 +553,6 @@
(NavigationBarTokens.ActiveIndicatorWidth - NavigationBarTokens.IconSize) / 2
private val IndicatorVerticalPadding: Dp =
- (NavigationBarTokens.ActiveIndicatorHeight - NavigationBarTokens.IconSize) / 2
\ No newline at end of file
+ (NavigationBarTokens.ActiveIndicatorHeight - NavigationBarTokens.IconSize) / 2
+
+private val IndicatorVerticalOffset: Dp = 12.dp
\ No newline at end of file
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationRail.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationRail.kt
index af24fb2..8616b23 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationRail.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationRail.kt
@@ -20,6 +20,7 @@
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
+import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -45,12 +46,15 @@
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
@@ -179,7 +183,7 @@
enabled = enabled,
role = Role.Tab,
interactionSource = interactionSource,
- indication = rememberRipple(),
+ indication = null,
)
.size(width = NavigationRailItemWidth, height = NavigationRailItemHeight),
contentAlignment = Alignment.Center
@@ -189,21 +193,46 @@
animationSpec = tween(ItemAnimationDurationMillis)
)
+ // The entire item is selectable, but only the indicator pill shows the ripple. To achieve
+ // this, we re-map the coordinates of the item's InteractionSource into the coordinates of
+ // the indicator.
+ val deltaOffset: Offset
+ with(LocalDensity.current) {
+ val itemWidth = NavigationRailItemWidth.roundToPx()
+ val indicatorWidth = NavigationRailTokens.ActiveIndicatorWidth.roundToPx()
+ deltaOffset = Offset((itemWidth - indicatorWidth).toFloat() / 2, 0f)
+ }
+ val offsetInteractionSource = remember(interactionSource, deltaOffset) {
+ MappedInteractionSource(interactionSource, deltaOffset)
+ }
+
+ val indicatorShape = if (label != null) {
+ NavigationRailTokens.ActiveIndicatorShape.toShape()
+ } else {
+ NavigationRailTokens.NoLabelActiveIndicatorShape.toShape()
+ }
+
+ // The indicator has a width-expansion animation which interferes with the timing of the
+ // ripple, which is why they are separate composables
+ val indicatorRipple = @Composable {
+ Box(
+ Modifier.layoutId(IndicatorRippleLayoutIdTag)
+ .clip(indicatorShape)
+ .indication(offsetInteractionSource, rememberRipple())
+ )
+ }
val indicator = @Composable {
Box(
Modifier.layoutId(IndicatorLayoutIdTag)
.background(
color = colors.indicatorColor.copy(alpha = animationProgress),
- shape = if (label != null) {
- NavigationRailTokens.ActiveIndicatorShape.toShape()
- } else {
- NavigationRailTokens.NoLabelActiveIndicatorShape.toShape()
- }
+ shape = indicatorShape
)
)
}
NavigationRailItemBaselineLayout(
+ indicatorRipple = indicatorRipple,
indicator = indicator,
icon = styledIcon,
label = styledLabel,
@@ -306,6 +335,7 @@
/**
* Base layout for a [NavigationRailItem].
*
+ * @param indicatorRipple indicator ripple for this item when it is selected
* @param indicator indicator for this item when it is selected
* @param icon icon for this item
* @param label text label for this item
@@ -317,6 +347,7 @@
*/
@Composable
private fun NavigationRailItemBaselineLayout(
+ indicatorRipple: @Composable () -> Unit,
indicator: @Composable () -> Unit,
icon: @Composable () -> Unit,
label: @Composable (() -> Unit)?,
@@ -324,6 +355,7 @@
animationProgress: Float,
) {
Layout({
+ indicatorRipple()
if (animationProgress > 0) {
indicator()
}
@@ -349,6 +381,15 @@
}
val indicatorHeight = iconPlaceable.height + (indicatorVerticalPadding * 2).roundToPx()
+ val indicatorRipplePlaceable =
+ measurables
+ .first { it.layoutId == IndicatorRippleLayoutIdTag }
+ .measure(
+ Constraints.fixed(
+ width = totalIndicatorWidth,
+ height = indicatorHeight
+ )
+ )
val indicatorPlaceable =
measurables
.firstOrNull { it.layoutId == IndicatorLayoutIdTag }
@@ -371,11 +412,12 @@
}
if (label == null) {
- placeIcon(iconPlaceable, indicatorPlaceable, constraints)
+ placeIcon(iconPlaceable, indicatorRipplePlaceable, indicatorPlaceable, constraints)
} else {
placeLabelAndIcon(
labelPlaceable!!,
iconPlaceable,
+ indicatorRipplePlaceable,
indicatorPlaceable,
constraints,
alwaysShowLabel,
@@ -386,11 +428,11 @@
}
/**
- * Places the provided [iconPlaceable], and possibly [indicatorPlaceable] if it exists, in the
- * center of the provided [constraints].
+ * Places the provided [Placeable]s in the center of the provided [constraints].
*/
private fun MeasureScope.placeIcon(
iconPlaceable: Placeable,
+ indicatorRipplePlaceable: Placeable,
indicatorPlaceable: Placeable?,
constraints: Constraints,
): MeasureResult {
@@ -400,6 +442,9 @@
val iconX = (width - iconPlaceable.width) / 2
val iconY = (height - iconPlaceable.height) / 2
+ val rippleX = (width - indicatorRipplePlaceable.width) / 2
+ val rippleY = (height - indicatorRipplePlaceable.height) / 2
+
return layout(width, height) {
indicatorPlaceable?.let {
val indicatorX = (width - it.width) / 2
@@ -407,12 +452,13 @@
it.placeRelative(indicatorX, indicatorY)
}
iconPlaceable.placeRelative(iconX, iconY)
+ indicatorRipplePlaceable.placeRelative(rippleX, rippleY)
}
}
/**
- * Places the provided [labelPlaceable], [iconPlaceable], and [indicatorPlaceable] in the correct
- * position, depending on [alwaysShowLabel] and [animationProgress].
+ * Places the provided [Placeable]s in the correct position, depending on [alwaysShowLabel] and
+ * [animationProgress].
*
* When [alwaysShowLabel] is true, the positions do not move. The [iconPlaceable] will be placed
* near the top of the item and the [labelPlaceable] will be placed near the bottom, according to
@@ -427,11 +473,12 @@
* When [animationProgress] is animating between these values, [iconPlaceable] and [labelPlaceable]
* will be placed at a corresponding interpolated position.
*
- * [indicatorPlaceable] will always be placed in such a way that it shares the same center as
- * [iconPlaceable].
+ * [indicatorRipplePlaceable] and [indicatorPlaceable] will always be placed in such a way that to
+ * share the same center as [iconPlaceable].
*
* @param labelPlaceable text label placeable inside this item
* @param iconPlaceable icon placeable inside this item
+ * @param indicatorRipplePlaceable indicator ripple placeable inside this item
* @param indicatorPlaceable indicator placeable inside this item, if it exists
* @param constraints constraints of the item
* @param alwaysShowLabel whether to always show the label for this item. If true, icon and label
@@ -444,6 +491,7 @@
private fun MeasureScope.placeLabelAndIcon(
labelPlaceable: Placeable,
iconPlaceable: Placeable,
+ indicatorRipplePlaceable: Placeable,
indicatorPlaceable: Placeable?,
constraints: Constraints,
alwaysShowLabel: Boolean,
@@ -469,6 +517,8 @@
val width = constraints.maxWidth
val labelX = (width - labelPlaceable.width) / 2
val iconX = (width - iconPlaceable.width) / 2
+ val rippleX = (width - indicatorRipplePlaceable.width) / 2
+ val rippleY = selectedIconY - IndicatorVerticalPaddingWithLabel.roundToPx()
return layout(width, height) {
indicatorPlaceable?.let {
@@ -480,9 +530,12 @@
labelPlaceable.placeRelative(labelX, labelY + offset)
}
iconPlaceable.placeRelative(iconX, selectedIconY + offset)
+ indicatorRipplePlaceable.placeRelative(rippleX, rippleY + offset)
}
}
+private const val IndicatorRippleLayoutIdTag: String = "indicatorRipple"
+
private const val IndicatorLayoutIdTag: String = "indicator"
private const val IconLayoutIdTag: String = "icon"