Fix TabRow inverted indicator for RtL layout
Bug: b/359245765
Test: run TabTest.kt
Change-Id: I73661a6b182ae5a5782ca8d827802a8e1e29237d
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TabTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TabTest.kt
index 57d4a35..f430e5c 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TabTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TabTest.kt
@@ -40,6 +40,7 @@
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.InspectableValue
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
@@ -64,6 +65,7 @@
import androidx.compose.ui.test.onParent
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.height
import androidx.compose.ui.unit.sp
@@ -302,6 +304,62 @@
}
@Test
+ fun fixedTabRowRt_indicatorPosition_rtl() {
+ val indicatorHeight = 1.dp
+
+ rule.setMaterialContent(lightColorScheme()) {
+ var state by remember { mutableStateOf(0) }
+ val titles = listOf("TAB 1", "TAB 2")
+
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Box(Modifier.testTag("tabRow")) {
+ SecondaryTabRow(
+ selectedTabIndex = state,
+ indicator = {
+ Box(
+ Modifier.tabIndicatorOffset(state)
+ .fillMaxWidth()
+ .height(indicatorHeight)
+ .background(color = Color.Red)
+ .testTag("indicator")
+ )
+ },
+ ) {
+ titles.forEachIndexed { index, title ->
+ Tab(
+ selected = state == index,
+ onClick = { state = index },
+ text = { Text(title) },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ val tabRowBounds = rule.onNodeWithTag("tabRow").getUnclippedBoundsInRoot()
+
+ rule
+ .onNodeWithTag("indicator", true)
+ .assertPositionInRootIsEqualTo(
+ expectedLeft = (tabRowBounds.width / 2),
+ expectedTop = tabRowBounds.height - indicatorHeight,
+ )
+
+ // Click the second tab
+ rule.onAllNodes(isSelectable())[1].performClick()
+
+ // Indicator should now be placed in the bottom left of the second tab.
+ // For RTL layout, its x coordinate should be at the start of the TabRow.
+ rule
+ .onNodeWithTag("indicator", true)
+ .assertPositionInRootIsEqualTo(
+ expectedLeft = 0.dp,
+ expectedTop = tabRowBounds.height - indicatorHeight,
+ )
+ }
+
+ @Test
fun tabRow_indicatorHeight() {
val indicatorHeight = 1.dp
val titles = listOf("TAB 1", "TAB 2")
@@ -550,6 +608,69 @@
}
@Test
+ fun scrollableTabRow_indicatorPosition_rtl() {
+ val indicatorHeight = 1.dp
+ val minimumTabWidth = 90.dp
+
+ rule.setMaterialContent(lightColorScheme()) {
+ var state by remember { mutableStateOf(0) }
+ val titles = listOf("TAB 1", "TAB 2")
+
+ val indicator: @Composable TabIndicatorScope.(index: Int) -> Unit = { index ->
+ Box(
+ Modifier.tabIndicatorOffset(index)
+ .fillMaxWidth()
+ .height(indicatorHeight)
+ .background(color = Color.Red)
+ .testTag("indicator")
+ )
+ }
+
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Box {
+ SecondaryScrollableTabRow(
+ modifier = Modifier.testTag("tabRow"),
+ selectedTabIndex = state,
+ indicator = { indicator(state) },
+ ) {
+ titles.forEachIndexed { index, title ->
+ Tab(
+ selected = state == index,
+ onClick = { state = index },
+ text = { Text(title) },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ val tabRowBounds = rule.onNodeWithTag("tabRow").getUnclippedBoundsInRoot()
+ val tabRowPadding = 52.dp
+ // Indicator should be placed in the bottom left of the first tab
+ rule
+ .onNodeWithTag("indicator", true)
+ .assertPositionInRootIsEqualTo(
+ // Tabs in a scrollable tab row are offset 52.dp from each end
+ expectedLeft = tabRowBounds.width - tabRowPadding - minimumTabWidth,
+ expectedTop = tabRowBounds.height - indicatorHeight,
+ )
+
+ // Click the second tab
+ rule.onAllNodes(isSelectable())[1].performClick()
+
+ // Indicator should now be placed in the bottom left of the second tab.
+ // For RTL layout, its x coordinate should be at the start of the TabRow.
+ rule
+ .onNodeWithTag("indicator", true)
+ .assertPositionInRootIsEqualTo(
+ expectedLeft =
+ tabRowBounds.width - tabRowPadding - minimumTabWidth - minimumTabWidth,
+ expectedTop = tabRowBounds.height - indicatorHeight,
+ )
+ }
+
+ @Test
fun scrollableTabRow_dividerHeight() {
rule.setMaterialContent(lightColorScheme()) {
val titles = listOf("TAB 1", "TAB 2")
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt
index 0b191f1..7ed7cc6 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt
@@ -67,6 +67,7 @@
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastFold
import androidx.compose.ui.util.fastForEach
@@ -888,7 +889,7 @@
var tabPositionsState: State<List<TabPosition>>,
var selectedTabIndex: Int,
var followContentSize: Boolean,
- var animationSpec: FiniteAnimationSpec<Dp>
+ var animationSpec: FiniteAnimationSpec<Dp>,
) : Modifier.Node(), LayoutModifierNode {
private var offsetAnimatable: Animatable<Dp, AnimationVector1D>? = null
@@ -939,7 +940,12 @@
initialOffset = indicatorOffset
}
- val offset = offsetAnimatable?.value ?: indicatorOffset
+ val offset =
+ if (layoutDirection == LayoutDirection.Ltr) {
+ offsetAnimatable?.value ?: indicatorOffset
+ } else {
+ -(offsetAnimatable?.value ?: indicatorOffset)
+ }
val width = widthAnimatable?.value ?: currentTabWidth