Adds a Material3 SmallCenteredTopAppBar support.
- SmallCenteredTopAppBar implementation that horizontally center the top
app bar title.
- EnterAlwaysScrollBehavior that can be attached to the app bar and have
it collapsed when scrolling up, and immediately expand when scrolling
down.
- Sample & tests.
Bug: 198144133
Test: Manual & AppBarTest
Relnote: "Adds Material 3 small-centered top app bar support with an enter-always scroll behavior."
Change-Id: I787a809602b41b9f3c0b859df48daa4c370165d5
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index 7ae7581..05ecf1a 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -2,6 +2,7 @@
package androidx.compose.material3 {
public final class AppBarKt {
+ method @androidx.compose.runtime.Composable public static void SmallCenteredTopAppBar(kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> navigationIcon, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> actions, optional androidx.compose.material3.TopAppBarColors colors, optional androidx.compose.material3.TopAppBarScrollBehavior? scrollBehavior);
method @androidx.compose.runtime.Composable public static void SmallTopAppBar(kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> navigationIcon, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> actions, optional androidx.compose.material3.TopAppBarColors colors, optional androidx.compose.material3.TopAppBarScrollBehavior? scrollBehavior);
}
@@ -128,7 +129,9 @@
}
public final class TopAppBarDefaults {
+ method public androidx.compose.material3.TopAppBarScrollBehavior enterAlwaysScrollBehavior(optional kotlin.jvm.functions.Function0<java.lang.Boolean> canScroll);
method public androidx.compose.material3.TopAppBarScrollBehavior pinnedScrollBehavior(optional kotlin.jvm.functions.Function0<java.lang.Boolean> canScroll);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.TopAppBarColors smallCenteredTopAppBarColors(optional long containerColor, optional long scrolledContainerColor, optional long navigationIconColor, optional long titleColor, optional long actionIconsColor);
method @androidx.compose.runtime.Composable public androidx.compose.material3.TopAppBarColors smallTopAppBarColors(optional long containerColor, optional long scrolledContainerColor, optional long navigationIconColor, optional long titleColor, optional long actionIconsColor);
field public static final androidx.compose.material3.TopAppBarDefaults INSTANCE;
}
diff --git a/compose/material3/material3/api/public_plus_experimental_current.txt b/compose/material3/material3/api/public_plus_experimental_current.txt
index e5e7d9c..28ecd4a 100644
--- a/compose/material3/material3/api/public_plus_experimental_current.txt
+++ b/compose/material3/material3/api/public_plus_experimental_current.txt
@@ -2,6 +2,7 @@
package androidx.compose.material3 {
public final class AppBarKt {
+ method @androidx.compose.runtime.Composable public static void SmallCenteredTopAppBar(kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> navigationIcon, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> actions, optional androidx.compose.material3.TopAppBarColors colors, optional androidx.compose.material3.TopAppBarScrollBehavior? scrollBehavior);
method @androidx.compose.runtime.Composable public static void SmallTopAppBar(kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> navigationIcon, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> actions, optional androidx.compose.material3.TopAppBarColors colors, optional androidx.compose.material3.TopAppBarScrollBehavior? scrollBehavior);
}
@@ -143,7 +144,9 @@
}
public final class TopAppBarDefaults {
+ method public androidx.compose.material3.TopAppBarScrollBehavior enterAlwaysScrollBehavior(optional kotlin.jvm.functions.Function0<java.lang.Boolean> canScroll);
method public androidx.compose.material3.TopAppBarScrollBehavior pinnedScrollBehavior(optional kotlin.jvm.functions.Function0<java.lang.Boolean> canScroll);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.TopAppBarColors smallCenteredTopAppBarColors(optional long containerColor, optional long scrolledContainerColor, optional long navigationIconColor, optional long titleColor, optional long actionIconsColor);
method @androidx.compose.runtime.Composable public androidx.compose.material3.TopAppBarColors smallTopAppBarColors(optional long containerColor, optional long scrolledContainerColor, optional long navigationIconColor, optional long titleColor, optional long actionIconsColor);
field public static final androidx.compose.material3.TopAppBarDefaults INSTANCE;
}
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index 7ae7581..05ecf1a 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -2,6 +2,7 @@
package androidx.compose.material3 {
public final class AppBarKt {
+ method @androidx.compose.runtime.Composable public static void SmallCenteredTopAppBar(kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> navigationIcon, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> actions, optional androidx.compose.material3.TopAppBarColors colors, optional androidx.compose.material3.TopAppBarScrollBehavior? scrollBehavior);
method @androidx.compose.runtime.Composable public static void SmallTopAppBar(kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> navigationIcon, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> actions, optional androidx.compose.material3.TopAppBarColors colors, optional androidx.compose.material3.TopAppBarScrollBehavior? scrollBehavior);
}
@@ -128,7 +129,9 @@
}
public final class TopAppBarDefaults {
+ method public androidx.compose.material3.TopAppBarScrollBehavior enterAlwaysScrollBehavior(optional kotlin.jvm.functions.Function0<java.lang.Boolean> canScroll);
method public androidx.compose.material3.TopAppBarScrollBehavior pinnedScrollBehavior(optional kotlin.jvm.functions.Function0<java.lang.Boolean> canScroll);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.TopAppBarColors smallCenteredTopAppBarColors(optional long containerColor, optional long scrolledContainerColor, optional long navigationIconColor, optional long titleColor, optional long actionIconsColor);
method @androidx.compose.runtime.Composable public androidx.compose.material3.TopAppBarColors smallTopAppBarColors(optional long containerColor, optional long scrolledContainerColor, optional long navigationIconColor, optional long titleColor, optional long actionIconsColor);
field public static final androidx.compose.material3.TopAppBarDefaults INSTANCE;
}
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
index 976fa17..0457449 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
@@ -20,7 +20,9 @@
import androidx.compose.material3.catalog.library.util.SampleSourceUrl
import androidx.compose.material3.samples.ColorSchemeSample
+import androidx.compose.material3.samples.EnterAlwaysSmallTopAppBar
import androidx.compose.material3.samples.PinnedSmallTopAppBar
+import androidx.compose.material3.samples.SimpleCenteredTopAppBar
import androidx.compose.material3.samples.SimpleSmallTopAppBar
import androidx.compose.runtime.Composable
@@ -52,8 +54,18 @@
sourceUrl = TopAppBarExampleSourceUrl,
) { SimpleSmallTopAppBar() },
Example(
+ name = ::SimpleCenteredTopAppBar.name,
+ description = TopAppBarExampleDescription,
+ sourceUrl = TopAppBarExampleSourceUrl,
+ ) { SimpleCenteredTopAppBar() },
+ Example(
name = ::PinnedSmallTopAppBar.name,
description = TopAppBarExampleDescription,
sourceUrl = TopAppBarExampleSourceUrl,
) { PinnedSmallTopAppBar() },
+ Example(
+ name = ::EnterAlwaysSmallTopAppBar.name,
+ description = TopAppBarExampleDescription,
+ sourceUrl = TopAppBarExampleSourceUrl,
+ ) { EnterAlwaysSmallTopAppBar() },
)
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/AppBarSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/AppBarSamples.kt
index 22be1a7..17aee6d 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/AppBarSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/AppBarSamples.kt
@@ -29,6 +29,7 @@
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SmallCenteredTopAppBar
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
@@ -57,7 +58,49 @@
}
},
actions = {
- // RowScope here, so these icons will be placed horizontally
+ IconButton( /* doSomething() */ }) {
+ Icon(Icons.Filled.Favorite, contentDescription = "Localized description")
+ }
+ }
+ )
+ },
+ content = { innerPadding ->
+ LazyColumn(
+ contentPadding = innerPadding,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ val list = (0..75).map { it.toString() }
+ items(count = list.size) {
+ Text(
+ text = list[it],
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
+ )
+ }
+ }
+ }
+ )
+}
+
+/**
+ * A sample for a simple use of [SmallCenteredTopAppBar].
+ *
+ * The top app bar here does not react to any scroll events in the content under it.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Sampled
+@Composable
+fun SimpleCenteredTopAppBar() {
+ Scaffold(
+ topBar = {
+ SmallCenteredTopAppBar(
+ title = { Text("Centered TopAppBar") },
+ navigationIcon = {
+ IconButton( /* doSomething() */ }) {
+ Icon(Icons.Filled.Menu, contentDescription = "Localized description")
+ }
+ },
+ actions = {
IconButton( /* doSomething() */ }) {
Icon(Icons.Filled.Favorite, contentDescription = "Localized description")
}
@@ -132,3 +175,48 @@
}
)
}
+
+/**
+ * A sample for a [SmallTopAppBar] that collapses when the content is scrolled up, and
+ * appears when the content scrolled down.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Sampled
+@Composable
+fun EnterAlwaysSmallTopAppBar() {
+ val scrollBehavior = remember { TopAppBarDefaults.enterAlwaysScrollBehavior() }
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ SmallTopAppBar(
+ title = { Text("Small TopAppBar") },
+ navigationIcon = {
+ IconButton( /* doSomething() */ }) {
+ Icon(Icons.Filled.Menu, contentDescription = "Localized description")
+ }
+ },
+ actions = {
+ IconButton( /* doSomething() */ }) {
+ Icon(Icons.Filled.Favorite, contentDescription = "Localized description")
+ }
+ },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ content = { innerPadding ->
+ LazyColumn(
+ contentPadding = innerPadding,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ val list = (0..75).map { it.toString() }
+ items(count = list.size) {
+ Text(
+ text = list[it],
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
+ )
+ }
+ }
+ }
+ )
+}
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/AppBarTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/AppBarTest.kt
index 0ade19d..5411019 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/AppBarTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/AppBarTest.kt
@@ -19,11 +19,13 @@
import android.os.Build
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.tokens.TopAppBarSmall
+import androidx.compose.material3.tokens.TopAppBarSmallCentered
import androidx.compose.runtime.Composable
import androidx.compose.testutils.assertContainsColor
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.test.assertHeightIsEqualTo
@@ -98,33 +100,7 @@
)
}
}
-
- val appBarBounds = rule.onNodeWithTag(TopAppBarTestTag).getUnclippedBoundsInRoot()
- val titleBounds = rule.onNodeWithTag(TitleTestTag).getUnclippedBoundsInRoot()
- val appBarBottomEdgeY = appBarBounds.top + appBarBounds.height
-
- rule.onNodeWithTag(NavigationIconTestTag)
- // Navigation icon should be 4.dp from the start
- .assertLeftPositionInRootIsEqualTo(AppBarStartAndEndPadding)
- // Navigation icon should be centered within the height of the app bar.
- .assertTopPositionInRootIsEqualTo(
- appBarBottomEdgeY - AppBarTopAndBottomPadding - FakeIconSize
- )
-
- rule.onNodeWithTag(TitleTestTag)
- // Title should be 56.dp from the start
- // 4.dp padding for the whole app bar + 48.dp icon size + 4.dp title padding.
- .assertLeftPositionInRootIsEqualTo(4.dp + FakeIconSize + 4.dp)
- // Title should be vertically centered
- .assertTopPositionInRootIsEqualTo((appBarBounds.height - titleBounds.height) / 2)
-
- rule.onNodeWithTag(ActionsTestTag)
- // Action should be placed at the end
- .assertLeftPositionInRootIsEqualTo(expectedActionPosition(appBarBounds.width))
- // Action should be 8.dp from the top
- .assertTopPositionInRootIsEqualTo(
- appBarBottomEdgeY - AppBarTopAndBottomPadding - FakeIconSize
- )
+ assertSmallDefaultPositioning()
}
@Test
@@ -141,17 +117,7 @@
)
}
}
-
- val appBarBounds = rule.onNodeWithTag(TopAppBarTestTag).getUnclippedBoundsInRoot()
-
- rule.onNodeWithTag(TitleTestTag)
- // Title should now be placed 16.dp from the start, as there is no navigation icon
- // 4.dp padding for the whole app bar + 12.dp inset
- .assertLeftPositionInRootIsEqualTo(4.dp + 12.dp)
-
- rule.onNodeWithTag(ActionsTestTag)
- // Action should still be placed at the end
- .assertLeftPositionInRootIsEqualTo(expectedActionPosition(appBarBounds.width))
+ assertSmallPositioningWithoutNavigation()
}
@Test
@@ -247,12 +213,251 @@
}
// Simulate scrolled content.
- scrollBehavior.contentOffset = -100f
+ rule.runOnIdle {
+ scrollBehavior.contentOffset = -100f
+ }
rule.waitForIdle()
rule.onNodeWithTag(TopAppBarTestTag).captureToImage()
.assertContainsColor(expectedScrolledContainerColor)
}
+ @Test
+ fun smallTopAppBar_scrolledPositioning() {
+ val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
+ val scrollOffsetDp = 20.dp
+ var scrollOffsetPx = 0f
+
+ rule.setMaterialContent {
+ SmallTopAppBar(
+ modifier = Modifier.testTag(TopAppBarTestTag),
+ title = {
+ Text("Title", Modifier.testTag(TitleTestTag))
+ scrollOffsetPx = with(LocalDensity.current) { scrollOffsetDp.toPx() }
+ },
+ scrollBehavior = scrollBehavior
+ )
+ }
+
+ // Simulate scrolled content.
+ rule.runOnIdle {
+ scrollBehavior.offset = -scrollOffsetPx
+ scrollBehavior.contentOffset = -scrollOffsetPx
+ }
+ rule.waitForIdle()
+ rule.onNodeWithTag(TopAppBarTestTag)
+ .assertHeightIsEqualTo(TopAppBarSmall.SmallContainerHeight - scrollOffsetDp)
+ }
+
+ @Test
+ fun smallCenteredTopAppBar_expandsToScreen() {
+ rule.setMaterialContentForSizeAssertions {
+ SmallCenteredTopAppBar(title = { Text("Title") })
+ }
+ .assertHeightIsEqualTo(TopAppBarSmallCentered.SmallCenteredContainerHeight)
+ .assertWidthIsEqualTo(rule.rootWidth())
+ }
+
+ @Test
+ fun smallCenteredTopAppBar_withTitle() {
+ val title = "Title"
+ rule.setMaterialContent {
+ Box(Modifier.testTag(TopAppBarTestTag)) {
+ SmallCenteredTopAppBar(title = { Text(title) })
+ }
+ }
+ rule.onNodeWithText(title).assertIsDisplayed()
+ }
+
+ @Test
+ fun smallCenteredTopAppBar_default_positioning() {
+ rule.setMaterialContent {
+ Box(Modifier.testTag(TopAppBarTestTag)) {
+ SmallTopAppBar(
+ navigationIcon = {
+ FakeIcon(Modifier.testTag(NavigationIconTestTag))
+ },
+ title = {
+ Text("Title", Modifier.testTag(TitleTestTag))
+ },
+ actions = {
+ FakeIcon(Modifier.testTag(ActionsTestTag))
+ }
+ )
+ }
+ }
+ assertSmallDefaultPositioning()
+ }
+
+ @Test
+ fun smallCenteredTopAppBar_noNavigationIcon_positioning() {
+ rule.setMaterialContent {
+ Box(Modifier.testTag(TopAppBarTestTag)) {
+ SmallTopAppBar(
+ title = {
+ Text("Title", Modifier.testTag(TitleTestTag))
+ },
+ actions = {
+ FakeIcon(Modifier.testTag(ActionsTestTag))
+ }
+ )
+ }
+ }
+ assertSmallPositioningWithoutNavigation()
+ }
+
+ @Test
+ fun smallCenteredTopAppBar_titleDefaultStyle() {
+ var textStyle: TextStyle? = null
+ var expectedTextStyle: TextStyle? = null
+ rule.setMaterialContent {
+ SmallCenteredTopAppBar(
+ title = {
+ Text("Title")
+ textStyle = LocalTextStyle.current
+ expectedTextStyle =
+ MaterialTheme.typography.fromToken(
+ TopAppBarSmallCentered.SmallCenteredHeadlineFont
+ )
+ }
+ )
+ }
+ assertThat(textStyle).isNotNull()
+ assertThat(textStyle).isEqualTo(expectedTextStyle)
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
+ fun smallCenteredTopAppBar_contentColor() {
+ var titleColor: Color = Color.Unspecified
+ var navigationIconColor: Color = Color.Unspecified
+ var actionsColor: Color = Color.Unspecified
+ var expectedTitleColor: Color = Color.Unspecified
+ var expectedNavigationIconColor: Color = Color.Unspecified
+ var expectedActionsColor: Color = Color.Unspecified
+ var expectedContainerColor: Color = Color.Unspecified
+
+ rule.setMaterialContent {
+ SmallCenteredTopAppBar(
+ modifier = Modifier.testTag(TopAppBarTestTag),
+ navigationIcon = {
+ FakeIcon(Modifier.testTag(NavigationIconTestTag))
+ navigationIconColor = LocalContentColor.current
+ expectedNavigationIconColor =
+ TopAppBarDefaults.smallCenteredTopAppBarColors()
+ .navigationIconColor(scrollFraction = 0f).value
+ // scrollFraction = 0f to indicate no scroll.
+ expectedContainerColor =
+ TopAppBarDefaults.smallCenteredTopAppBarColors()
+ .containerColor(scrollFraction = 0f).value
+ },
+ title = {
+ Text("Title", Modifier.testTag(TitleTestTag))
+ titleColor = LocalContentColor.current
+ expectedTitleColor =
+ TopAppBarDefaults.smallCenteredTopAppBarColors()
+ .titleColor(scrollFraction = 0f).value
+ },
+ actions = {
+ FakeIcon(Modifier.testTag(ActionsTestTag))
+ actionsColor = LocalContentColor.current
+ expectedActionsColor =
+ TopAppBarDefaults.smallCenteredTopAppBarColors()
+ .actionIconColor(scrollFraction = 0f).value
+ }
+ )
+ }
+ assertThat(navigationIconColor).isNotNull()
+ assertThat(titleColor).isNotNull()
+ assertThat(actionsColor).isNotNull()
+ assertThat(navigationIconColor).isEqualTo(expectedNavigationIconColor)
+ assertThat(titleColor).isEqualTo(expectedTitleColor)
+ assertThat(actionsColor).isEqualTo(expectedActionsColor)
+
+ rule.onNodeWithTag(TopAppBarTestTag).captureToImage()
+ .assertContainsColor(expectedContainerColor)
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
+ fun smallCenteredTopAppBar_scrolledContentColor() {
+ var expectedScrolledContainerColor: Color = Color.Unspecified
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ rule.setMaterialContent {
+ SmallCenteredTopAppBar(
+ modifier = Modifier.testTag(TopAppBarTestTag),
+ title = {
+ Text("Title", Modifier.testTag(TitleTestTag))
+ // scrollFraction = 1f to indicate a scroll.
+ expectedScrolledContainerColor =
+ TopAppBarDefaults.smallCenteredTopAppBarColors()
+ .containerColor(scrollFraction = 1f).value
+ },
+ scrollBehavior = scrollBehavior
+ )
+ }
+
+ // Simulate scrolled content.
+ rule.runOnIdle {
+ scrollBehavior.contentOffset = -100f
+ }
+ rule.waitForIdle()
+ rule.onNodeWithTag(TopAppBarTestTag).captureToImage()
+ .assertContainsColor(expectedScrolledContainerColor)
+ }
+
+ /**
+ * Checks the app bar's components positioning when it's a [SmallTopAppBar], a
+ * [SmallCenteredTopAppBar], or a larger app bar that is scrolled up and collapsed into a small
+ * configuration and there is no navigation icon.
+ */
+ private fun assertSmallPositioningWithoutNavigation() {
+ val appBarBounds = rule.onNodeWithTag(TopAppBarTestTag).getUnclippedBoundsInRoot()
+
+ rule.onNodeWithTag(TitleTestTag)
+ // Title should now be placed 16.dp from the start, as there is no navigation icon
+ // 4.dp padding for the whole app bar + 12.dp inset
+ .assertLeftPositionInRootIsEqualTo(4.dp + 12.dp)
+
+ rule.onNodeWithTag(ActionsTestTag)
+ // Action should still be placed at the end
+ .assertLeftPositionInRootIsEqualTo(expectedActionPosition(appBarBounds.width))
+ }
+
+ /**
+ * Checks the app bar's components positioning when it's a [SmallTopAppBar], a
+ * [SmallCenteredTopAppBar], or a larger app bar that is scrolled up and collapsed into a small
+ * configuration.
+ */
+ private fun assertSmallDefaultPositioning() {
+ val appBarBounds = rule.onNodeWithTag(TopAppBarTestTag).getUnclippedBoundsInRoot()
+ val titleBounds = rule.onNodeWithTag(TitleTestTag).getUnclippedBoundsInRoot()
+ val appBarBottomEdgeY = appBarBounds.top + appBarBounds.height
+
+ rule.onNodeWithTag(NavigationIconTestTag)
+ // Navigation icon should be 4.dp from the start
+ .assertLeftPositionInRootIsEqualTo(AppBarStartAndEndPadding)
+ // Navigation icon should be centered within the height of the app bar.
+ .assertTopPositionInRootIsEqualTo(
+ appBarBottomEdgeY - AppBarTopAndBottomPadding - FakeIconSize
+ )
+
+ rule.onNodeWithTag(TitleTestTag)
+ // Title should be 56.dp from the start
+ // 4.dp padding for the whole app bar + 48.dp icon size + 4.dp title padding.
+ .assertLeftPositionInRootIsEqualTo(4.dp + FakeIconSize + 4.dp)
+ // Title should be vertically centered
+ .assertTopPositionInRootIsEqualTo((appBarBounds.height - titleBounds.height) / 2)
+
+ rule.onNodeWithTag(ActionsTestTag)
+ // Action should be placed at the end
+ .assertLeftPositionInRootIsEqualTo(expectedActionPosition(appBarBounds.width))
+ // Action should be 8.dp from the top
+ .assertTopPositionInRootIsEqualTo(
+ appBarBottomEdgeY - AppBarTopAndBottomPadding - FakeIconSize
+ )
+ }
+
/**
* An [IconButton] with an [Icon] inside for testing positions.
*
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt
index 8945dfc..b4ccdca 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt
@@ -25,6 +25,7 @@
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.tokens.TopAppBarSmall
+import androidx.compose.material3.tokens.TopAppBarSmallCentered
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
@@ -62,6 +63,7 @@
* A top app bar that uses a [scrollBehavior] to customize its nested scrolling behavior when
* working in conjunction with a scrolling content looks like:
* @sample androidx.compose.material3.samples.PinnedSmallTopAppBar
+ * @sample androidx.compose.material3.samples.EnterAlwaysSmallTopAppBar
*
* @param title the title to be displayed in the top app bar
* @param modifier the [Modifier] to be applied to this top app bar
@@ -98,6 +100,52 @@
}
/**
+ * Material Design small top app bar with a header title that is horizontally aligned to the center.
+ *
+ * The top app bar displays information and actions relating to the current screen.
+ *
+ * This SmallCenteredTopAppBar has slots for a title, navigation icon, and actions.
+ *
+ * A small centered top app bar that uses a [scrollBehavior] to customize its nested scrolling
+ * behavior when working in conjunction with a scrolling content looks like:
+ * @sample androidx.compose.material3.samples.SimpleCenteredTopAppBar
+ *
+ * @param title the title to be displayed in the top app bar
+ * @param modifier the [Modifier] to be applied to this top app bar
+ * @param navigationIcon The navigation icon displayed at the start of the top app bar. This should
+ * typically be an [IconButton] or [IconToggleButton].
+ * @param actions the actions displayed at the end of the top app bar. This should typically be
+ * [IconButton]s. The default layout here is a [Row], so icons inside will be placed horizontally.
+ * @param colors a [TopAppBarColors] that will be used to resolve the colors used for this top app
+ * bar in different states. See [TopAppBarDefaults.smallCenteredTopAppBarColors].
+ * @param scrollBehavior a [TopAppBarScrollBehavior] which holds various offset values that will be
+ * applied by this top app bar to set up its height and colors. A scroll behavior is designed to
+ * work in conjunction with a scrolled content to change the top app bar appearance as the content
+ * scrolls. See [TopAppBarScrollBehavior.nestedScrollConnection].
+ */
+@Composable
+fun SmallCenteredTopAppBar(
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ navigationIcon: @Composable () -> Unit = {},
+ actions: @Composable RowScope.() -> Unit = {},
+ colors: TopAppBarColors = TopAppBarDefaults.smallCenteredTopAppBarColors(),
+ scrollBehavior: TopAppBarScrollBehavior? = null
+) {
+ SingleRowTopAppBar(
+ modifier = modifier,
+ title = title,
+ titleTextStyle =
+ MaterialTheme.typography.fromToken(TopAppBarSmall.SmallHeadlineFont),
+ centeredTitle = true,
+ navigationIcon = navigationIcon,
+ actions = actions,
+ colors = colors,
+ scrollBehavior = scrollBehavior
+ )
+}
+
+/**
* A TopAppBarScrollBehavior defines how an app bar should behave when the content under it is
* scrolled.
*
@@ -247,14 +295,79 @@
}
/**
+ * Creates a [TopAppBarColors] for small-centered top app bars. The default implementation
+ * animates between the provided colors according to the Material specification.
+ *
+ * @param containerColor the container color
+ * @param scrolledContainerColor the container color when content is scrolled behind it
+ * @param navigationIconColor the content used color for the navigation icon
+ * @param titleColor the content color used for the title
+ * @param actionIconsColor the content color used for actions
+ * @return the resulting [TopAppBarColors] used for the top app bar
+ */
+ @Composable
+ fun smallCenteredTopAppBarColors(
+ containerColor: Color =
+ MaterialTheme.colorScheme.fromToken(TopAppBarSmallCentered.SmallCenteredContainerColor),
+ scrolledContainerColor: Color = MaterialTheme.colorScheme.applyTonalElevation(
+ backgroundColor = containerColor,
+ elevation = TopAppBarSmall.SmallOnScrollContainerElevation
+ ),
+ navigationIconColor: Color =
+ MaterialTheme.colorScheme.fromToken(
+ TopAppBarSmallCentered.SmallCenteredLeadingIconColor
+ ),
+ titleColor: Color =
+ MaterialTheme.colorScheme.fromToken(
+ TopAppBarSmallCentered.SmallCenteredHeadlineColor
+ ),
+ actionIconsColor: Color =
+ MaterialTheme.colorScheme.fromToken(
+ TopAppBarSmallCentered.SmallCenteredTrailingIconColor
+ )
+ ): TopAppBarColors {
+ return remember(
+ containerColor,
+ scrolledContainerColor,
+ navigationIconColor,
+ titleColor,
+ actionIconsColor
+ ) {
+ AnimatingTopAppBarColors(
+ containerColor,
+ scrolledContainerColor,
+ navigationIconColor,
+ titleColor,
+ actionIconsColor
+ )
+ }
+ }
+
+ /**
* Returns a pinned [TopAppBarScrollBehavior] that tracks nested-scroll callbacks and
* updates its [TopAppBarScrollBehavior.contentOffset] accordingly.
*
- * @param canScroll a callback used to determine whether scroll events are to be
- * handled by this pinned [TopAppBarScrollBehavior]
+ * @param canScroll a callback used to determine whether scroll events are to be handled by this
+ * pinned [TopAppBarScrollBehavior]
*/
fun pinnedScrollBehavior(canScroll: () -> Boolean = { true }): TopAppBarScrollBehavior =
PinnedScrollBehavior(canScroll)
+
+ /**
+ * Returns a [TopAppBarScrollBehavior] that tracks nested-scroll callbacks and
+ * updates its [TopAppBarScrollBehavior.offset] and [TopAppBarScrollBehavior.contentOffset]
+ * accordingly.
+ *
+ * This scroll-connection updates the `offset` value immediately whenever the content is pulled
+ * up or down. A top-bar that is set up with this [TopAppBarScrollBehavior] managed by this
+ * [EnterAlwaysScrollBehavior] will immediately collapse when the scaffold's content is pulled
+ * up, and will immediately appear when the content is pulled down.
+ *
+ * @param canScroll a callback used to determine whether scroll events are to be handled by this
+ * [EnterAlwaysScrollBehavior]
+ */
+ fun enterAlwaysScrollBehavior(canScroll: () -> Boolean = { true }): TopAppBarScrollBehavior =
+ EnterAlwaysScrollBehavior(canScroll)
}
/**
@@ -321,15 +434,6 @@
}
}
-@Composable
-private fun ActionsInRow(content: @Composable (RowScope.() -> Unit)) {
- Row(
- horizontalArrangement = Arrangement.End,
- verticalAlignment = Alignment.CenterVertically,
- content = content
- )
-}
-
/**
* The base [Layout] for all top app bars. This function lays out a top app bar navigation icon
* (leading icon), a title (header), and action icons (trailing icons). Note that the navigation and
@@ -544,6 +648,68 @@
}
}
+/**
+ * A [TopAppBarScrollBehavior] that adjusts its properties to affect the height of the top app bar.
+ *
+ * A top-bar that is set up with this [TopAppBarScrollBehavior] will immediately collapse when the
+ * scaffold's content is pulled up, and will immediately appear when the content is pulled down.
+ *
+ * @param canScroll a callback that can be used to determine whether scroll events are to be
+ * handled by this [EnterAlwaysScrollBehavior]
+ */
+private class EnterAlwaysScrollBehavior(val canScroll: () -> Boolean = { true }) :
+ TopAppBarScrollBehavior {
+ override val scrollFraction: Float
+ get() = if (offsetLimit != 0f) {
+ 1 - ((offsetLimit - contentOffset).coerceIn(
+ minimumValue = offsetLimit,
+ maximumValue = 0f
+ ) / offsetLimit)
+ } else {
+ 0f
+ }
+ override var offsetLimit by mutableStateOf(-Float.MAX_VALUE)
+ override var offset by mutableStateOf(0f)
+ override var contentOffset by mutableStateOf(0f)
+ override var nestedScrollConnection =
+ object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ if (!canScroll()) return Offset.Zero
+ val newOffset = (offset + available.y)
+ val coerced = newOffset.coerceIn(minimumValue = offsetLimit, maximumValue = 0f)
+ return if (newOffset == coerced) {
+ // Nothing coerced, meaning we're in the middle of top-bar collapse or
+ // expand.
+ offset = coerced
+ available
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource
+ ): Offset {
+ if (!canScroll()) return Offset.Zero
+ contentOffset += consumed.y
+ if (offset == 0f || offset == offsetLimit) {
+ if (consumed.y == 0f && available.y > 0f) {
+ // Reset the total offset to zero when scrolling all the way down.
+ // This will eliminate some float precision inaccuracies.
+ contentOffset = 0f
+ }
+ }
+ offset = (offset + consumed.y).coerceIn(
+ minimumValue = offsetLimit,
+ maximumValue = 0f
+ )
+ return Offset.Zero
+ }
+ }
+}
+
private val TopAppBarHorizontalPadding = 4.dp
// A title inset when the App-Bar is a Medium or Large one. Also used to size a spacer when the