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