[go: nahoru, domu]

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"