Merge "Fix NavigationBar layout in edge cases" into androidx-main
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/NavigationBarScreenshotTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/NavigationBarScreenshotTest.kt
index 3693245..3c05d96 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/NavigationBarScreenshotTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/NavigationBarScreenshotTest.kt
@@ -21,6 +21,7 @@
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.runtime.Composable
@@ -33,6 +34,7 @@
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
@@ -112,6 +114,25 @@
}
@Test
+ fun lightTheme_customHeight() {
+ val interactionSource = MutableInteractionSource()
+
+ var scope: CoroutineScope? = null
+
+ composeTestRule.setMaterialContent(lightColorScheme()) {
+ scope = rememberCoroutineScope()
+ DefaultNavigationBar(interactionSource, Modifier.height(64.dp))
+ }
+
+ assertNavigationBarMatches(
+ scope = scope!!,
+ interactionSource = interactionSource,
+ interaction = null,
+ goldenIdentifier = "navigationBar_lightTheme_customHeight"
+ )
+ }
+
+ @Test
fun darkTheme_defaultColors() {
val interactionSource = MutableInteractionSource()
@@ -211,14 +232,16 @@
*
* @param interactionSource the [MutableInteractionSource] for the first [NavigationBarItem], to
* control its visual state.
+ * @param modifier the [Modifier] applied to the navigation bar
* @param setUnselectedItemsAsDisabled when true, marks unselected items as disabled
*/
@Composable
private fun DefaultNavigationBar(
interactionSource: MutableInteractionSource,
+ modifier: Modifier = Modifier,
setUnselectedItemsAsDisabled: Boolean = false,
) {
- Box(Modifier.semantics(mergeDescendants = true) {}.testTag(Tag)) {
+ Box(modifier.semantics(mergeDescendants = true) {}.testTag(Tag)) {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Filled.Favorite, null) },
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 e9a0168..2f6c21f 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.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.tokens.NavigationBarTokens
@@ -266,42 +267,43 @@
@Test
fun navigationBarItemContent_withLabel_sizeAndPosition() {
rule.setMaterialContent(lightColorScheme()) {
- Box {
- NavigationBar {
- NavigationBarItem(
- modifier = Modifier.testTag("item"),
- icon = {
- Icon(Icons.Filled.Favorite, null, Modifier.testTag("icon"))
- },
- label = {
- Text("ItemText")
- },
- selected = true,
- onClick = {}
- )
- }
+ NavigationBar {
+ NavigationBarItem(
+ modifier = Modifier.testTag("item"),
+ icon = {
+ Icon(Icons.Filled.Favorite, null, Modifier.testTag("icon"))
+ },
+ label = {
+ Text("ItemText")
+ },
+ selected = true,
+ onClick = {}
+ )
}
}
val itemBounds = rule.onNodeWithTag("item").getUnclippedBoundsInRoot()
val iconBounds = rule.onNodeWithTag("icon", useUnmergedTree = true)
.getUnclippedBoundsInRoot()
- val textBounds = rule.onNodeWithText("ItemText", useUnmergedTree = true)
- .getUnclippedBoundsInRoot()
- // Distance from the bottom of the item to the text bottom, and from the top of the icon to
- // the top of the item
- val verticalPadding = NavigationBarItemVerticalPadding
-
- val itemBottom = itemBounds.height + itemBounds.top
- // Text bottom should be `verticalPadding` from the bottom of the item
- textBounds.bottom.assertIsEqualTo(itemBottom - verticalPadding)
+ // Distance from the top of the item to the top of the icon for the default height
+ val verticalPadding = 16.dp
rule.onNodeWithTag("icon", useUnmergedTree = true)
- // The icon should be centered in the item
+ // The icon should be horizontally centered in the item
.assertLeftPositionInRootIsEqualTo((itemBounds.width - iconBounds.width) / 2)
// The top of the icon is `verticalPadding` below the top of the item
.assertTopPositionInRootIsEqualTo(itemBounds.top + verticalPadding)
+
+ val iconBottom = iconBounds.top + iconBounds.height
+ // Text should be `IndicatorVerticalPadding + NavigationBarIndicatorToLabelPadding` from the
+ // bottom of the icon
+ rule.onNodeWithText("ItemText", useUnmergedTree = true)
+ .getUnclippedBoundsInRoot()
+ .top
+ .assertIsEqualTo(
+ iconBottom + IndicatorVerticalPadding + NavigationBarIndicatorToLabelPadding
+ )
}
@Test
@@ -367,6 +369,49 @@
}
@Test
+ fun navigationBarItemContent_customHeight_withLabel_sizeAndPosition() {
+ val defaultHeight = NavigationBarTokens.ContainerHeight
+ val customHeight = 64.dp
+
+ rule.setMaterialContent(lightColorScheme()) {
+ NavigationBar(Modifier.height(customHeight)) {
+ NavigationBarItem(
+ modifier = Modifier.testTag("item"),
+ icon = {
+ Icon(Icons.Filled.Favorite, null, Modifier.testTag("icon"))
+ },
+ label = { Text("Label") },
+ selected = true,
+ onClick = {}
+ )
+ }
+ }
+
+ // Vertical padding is removed symmetrically from top and bottom for smaller heights
+ val verticalPadding = 16.dp - (defaultHeight - customHeight) / 2
+
+ val itemBounds = rule.onNodeWithTag("item").getUnclippedBoundsInRoot()
+ val iconBounds = rule.onNodeWithTag("icon", useUnmergedTree = true)
+ .getUnclippedBoundsInRoot()
+
+ rule.onNodeWithTag("icon", useUnmergedTree = true)
+ // The icon should be horizontally centered in the item
+ .assertLeftPositionInRootIsEqualTo((itemBounds.width - iconBounds.width) / 2)
+ // The top of the icon is `verticalPadding` below the top of the item
+ .assertTopPositionInRootIsEqualTo(itemBounds.top + verticalPadding)
+
+ val iconBottom = iconBounds.top + iconBounds.height
+ // Text should be `IndicatorVerticalPadding + NavigationBarIndicatorToLabelPadding` from the
+ // bottom of the item
+ rule.onNodeWithText("Label", useUnmergedTree = true)
+ .getUnclippedBoundsInRoot()
+ .top
+ .assertIsEqualTo(
+ iconBottom + IndicatorVerticalPadding + NavigationBarIndicatorToLabelPadding
+ )
+ }
+
+ @Test
fun navigationBar_selectNewItem() {
rule.setMaterialContent(lightColorScheme()) {
var selectedItem by remember { mutableStateOf(0) }
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 2746436..5f1bf66 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
@@ -535,8 +535,8 @@
* [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
- * the spec.
+ * near the top of the item and the [labelPlaceable] will be placed beneath it with padding,
+ * according to the spec.
*
* When [animationProgress] is 1 (representing the selected state), the positions will be the same
* as above.
@@ -573,11 +573,13 @@
): MeasureResult {
val height = constraints.maxHeight
- // Label should be `ItemVerticalPadding` from the bottom
- val labelY = height - labelPlaceable.height - NavigationBarItemVerticalPadding.roundToPx()
+ val contentTotalHeight = iconPlaceable.height + IndicatorVerticalPadding.roundToPx() +
+ NavigationBarIndicatorToLabelPadding.roundToPx() + labelPlaceable.height
+ val contentVerticalPadding = ((height - contentTotalHeight) / 2)
+ .coerceAtLeast(IndicatorVerticalPadding.roundToPx())
- // Icon (when selected) should be `ItemVerticalPadding` from the top
- val selectedIconY = NavigationBarItemVerticalPadding.roundToPx()
+ // Icon (when selected) should be `contentVerticalPadding` from top
+ val selectedIconY = contentVerticalPadding
val unselectedIconY =
if (alwaysShowLabel) selectedIconY else (height - iconPlaceable.height) / 2
@@ -588,6 +590,10 @@
// animationProgress.
val offset = (iconDistance * (1 - animationProgress)).roundToInt()
+ // Label should be fixed padding below icon
+ val labelY = selectedIconY + iconPlaceable.height + IndicatorVerticalPadding.roundToPx() +
+ NavigationBarIndicatorToLabelPadding.roundToPx()
+
val containerWidth = constraints.maxWidth
val labelX = (containerWidth - labelPlaceable.width) / 2
@@ -626,12 +632,13 @@
internal val NavigationBarItemHorizontalPadding: Dp = 8.dp
/*@VisibleForTesting*/
-internal val NavigationBarItemVerticalPadding: Dp = 16.dp
+internal val NavigationBarIndicatorToLabelPadding: Dp = 4.dp
private val IndicatorHorizontalPadding: Dp =
(NavigationBarTokens.ActiveIndicatorWidth - NavigationBarTokens.IconSize) / 2
-private val IndicatorVerticalPadding: Dp =
+/*@VisibleForTesting*/
+internal val IndicatorVerticalPadding: Dp =
(NavigationBarTokens.ActiveIndicatorHeight - NavigationBarTokens.IconSize) / 2
private val IndicatorVerticalOffset: Dp = 12.dp
\ No newline at end of file