| /* |
| * Copyright 2021 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 android.os.Build |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.PaddingValues |
| import androidx.compose.foundation.layout.WindowInsets |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.lazy.LazyColumn |
| import androidx.compose.foundation.lazy.LazyListState |
| import androidx.compose.foundation.lazy.LazyRow |
| import androidx.compose.foundation.lazy.rememberLazyListState |
| import androidx.compose.material.icons.Icons |
| import androidx.compose.material.icons.filled.Add |
| import androidx.compose.material3.tokens.BottomAppBarTokens |
| import androidx.compose.material3.tokens.TopAppBarLargeTokens |
| import androidx.compose.material3.tokens.TopAppBarMediumTokens |
| import androidx.compose.material3.tokens.TopAppBarSmallCenteredTokens |
| import androidx.compose.material3.tokens.TopAppBarSmallTokens |
| import androidx.compose.runtime.Composable |
| import androidx.compose.testutils.assertContainsColor |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.graphics.compositeOver |
| import androidx.compose.ui.graphics.painter.ColorPainter |
| import androidx.compose.ui.input.nestedscroll.nestedScroll |
| import androidx.compose.ui.layout.layout |
| import androidx.compose.ui.platform.LocalDensity |
| import androidx.compose.ui.platform.testTag |
| import androidx.compose.ui.semantics.semantics |
| import androidx.compose.ui.test.assertCountEquals |
| import androidx.compose.ui.test.assertHeightIsEqualTo |
| import androidx.compose.ui.test.assertIsDisplayed |
| import androidx.compose.ui.test.assertIsEqualTo |
| import androidx.compose.ui.test.assertIsNotDisplayed |
| import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo |
| import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo |
| import androidx.compose.ui.test.assertWidthIsEqualTo |
| import androidx.compose.ui.test.captureToImage |
| import androidx.compose.ui.test.getUnclippedBoundsInRoot |
| import androidx.compose.ui.test.junit4.StateRestorationTester |
| import androidx.compose.ui.test.junit4.createComposeRule |
| import androidx.compose.ui.test.onAllNodesWithTag |
| import androidx.compose.ui.test.onFirst |
| import androidx.compose.ui.test.onLast |
| import androidx.compose.ui.test.onNodeWithTag |
| import androidx.compose.ui.test.onNodeWithText |
| import androidx.compose.ui.test.performTouchInput |
| import androidx.compose.ui.test.swipeLeft |
| import androidx.compose.ui.test.swipeRight |
| import androidx.compose.ui.text.TextStyle |
| import androidx.compose.ui.unit.Dp |
| import androidx.compose.ui.unit.IntSize |
| import androidx.compose.ui.unit.dp |
| import androidx.compose.ui.unit.height |
| import androidx.compose.ui.unit.width |
| import androidx.test.ext.junit.runners.AndroidJUnit4 |
| import androidx.test.filters.MediumTest |
| import androidx.test.filters.SdkSuppress |
| import com.google.common.truth.Truth.assertThat |
| import org.junit.Rule |
| import org.junit.Test |
| import org.junit.runner.RunWith |
| |
| @OptIn(ExperimentalMaterial3Api::class) |
| @MediumTest |
| @RunWith(AndroidJUnit4::class) |
| class AppBarTest { |
| |
| @get:Rule |
| val rule = createComposeRule() |
| |
| @Test |
| fun smallTopAppBar_expandsToScreen() { |
| rule |
| .setMaterialContentForSizeAssertions { |
| SmallTopAppBar(title = { Text("Title") }) |
| } |
| .assertHeightIsEqualTo(TopAppBarSmallTokens.ContainerHeight) |
| .assertWidthIsEqualTo(rule.rootWidth()) |
| } |
| |
| @Test |
| fun smallTopAppBar_withTitle() { |
| val title = "Title" |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| SmallTopAppBar(title = { Text(title) }) |
| } |
| } |
| rule.onNodeWithText(title).assertIsDisplayed() |
| } |
| |
| @Test |
| fun smallTopAppBar_default_positioning() { |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| SmallTopAppBar( |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| } |
| ) |
| } |
| } |
| assertSmallDefaultPositioning() |
| } |
| |
| @Test |
| fun smallTopAppBar_noNavigationIcon_positioning() { |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| SmallTopAppBar( |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| } |
| ) |
| } |
| } |
| assertSmallPositioningWithoutNavigation() |
| } |
| |
| @Test |
| fun smallTopAppBar_titleDefaultStyle() { |
| var textStyle: TextStyle? = null |
| var expectedTextStyle: TextStyle? = null |
| rule.setMaterialContent(lightColorScheme()) { |
| SmallTopAppBar(title = { |
| Text("Title") |
| textStyle = LocalTextStyle.current |
| expectedTextStyle = |
| MaterialTheme.typography.fromToken(TopAppBarSmallTokens.HeadlineFont) |
| } |
| ) |
| } |
| assertThat(textStyle).isNotNull() |
| assertThat(textStyle).isEqualTo(expectedTextStyle) |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun smallTopAppBar_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(lightColorScheme()) { |
| SmallTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| navigationIconColor = LocalContentColor.current |
| expectedNavigationIconColor = |
| TopAppBarDefaults.smallTopAppBarColors().navigationIconContentColor |
| // fraction = 0f to indicate no scroll. |
| expectedContainerColor = TopAppBarDefaults |
| .smallTopAppBarColors() |
| .containerColor(colorTransitionFraction = 0f) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| titleColor = LocalContentColor.current |
| expectedTitleColor = TopAppBarDefaults |
| .smallTopAppBarColors().titleContentColor |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| actionsColor = LocalContentColor.current |
| expectedActionsColor = TopAppBarDefaults |
| .smallTopAppBarColors().actionIconContentColor |
| } |
| ) |
| } |
| 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) |
| } |
| |
| @OptIn(ExperimentalMaterial3Api::class) |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun smallTopAppBar_scrolledContentColor() { |
| var expectedScrolledContainerColor: Color = Color.Unspecified |
| lateinit var scrollBehavior: TopAppBarScrollBehavior |
| rule.setMaterialContent(lightColorScheme()) { |
| scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() |
| SmallTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| // fraction = 1f to indicate a scroll. |
| expectedScrolledContainerColor = |
| TopAppBarDefaults.smallTopAppBarColors() |
| .containerColor(colorTransitionFraction = 1f) |
| }, |
| scrollBehavior = scrollBehavior |
| ) |
| } |
| |
| // Simulate scrolled content. |
| rule.runOnIdle { |
| scrollBehavior.state.contentOffset = -100f |
| } |
| rule.waitForIdle() |
| rule.onNodeWithTag(TopAppBarTestTag).captureToImage() |
| .assertContainsColor(expectedScrolledContainerColor) |
| } |
| |
| @OptIn(ExperimentalMaterial3Api::class) |
| @Test |
| fun smallTopAppBar_scrolledPositioning() { |
| lateinit var scrollBehavior: TopAppBarScrollBehavior |
| val scrollHeightOffsetDp = 20.dp |
| var scrollHeightOffsetPx = 0f |
| |
| rule.setMaterialContent(lightColorScheme()) { |
| scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() |
| scrollHeightOffsetPx = with(LocalDensity.current) { scrollHeightOffsetDp.toPx() } |
| SmallTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { Text("Title", Modifier.testTag(TitleTestTag)) }, |
| scrollBehavior = scrollBehavior |
| ) |
| } |
| |
| // Simulate scrolled content. |
| rule.runOnIdle { |
| scrollBehavior.state.heightOffset = -scrollHeightOffsetPx |
| scrollBehavior.state.contentOffset = -scrollHeightOffsetPx |
| } |
| rule.waitForIdle() |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo(TopAppBarSmallTokens.ContainerHeight - scrollHeightOffsetDp) |
| } |
| |
| @Test |
| fun centerAlignedTopAppBar_expandsToScreen() { |
| rule.setMaterialContentForSizeAssertions { |
| CenterAlignedTopAppBar(title = { Text("Title") }) |
| } |
| .assertHeightIsEqualTo(TopAppBarSmallCenteredTokens.ContainerHeight) |
| .assertWidthIsEqualTo(rule.rootWidth()) |
| } |
| |
| @Test |
| fun centerAlignedTopAppBar_withTitle() { |
| val title = "Title" |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| CenterAlignedTopAppBar(title = { Text(title) }) |
| } |
| } |
| rule.onNodeWithText(title).assertIsDisplayed() |
| } |
| |
| @Test |
| fun centerAlignedTopAppBar_default_positioning() { |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| CenterAlignedTopAppBar( |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| } |
| ) |
| } |
| } |
| assertSmallDefaultPositioning(isCenteredTitle = true) |
| } |
| |
| @Test |
| fun centerAlignedTopAppBar_default_positioning_respectsWindowInsets() { |
| val padding = 10.dp |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| CenterAlignedTopAppBar( |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| }, |
| windowInsets = WindowInsets(padding, padding, padding, padding) |
| ) |
| } |
| } |
| val appBarBounds = rule.onNodeWithTag(TopAppBarTestTag).getUnclippedBoundsInRoot() |
| val appBarBottomEdgeY = appBarBounds.top + appBarBounds.height |
| |
| rule.onNodeWithTag(NavigationIconTestTag) |
| // Navigation icon should be 4.dp from the start |
| .assertLeftPositionInRootIsEqualTo(AppBarStartAndEndPadding + padding) |
| // Navigation icon should be centered within the height of the app bar. |
| .assertTopPositionInRootIsEqualTo( |
| appBarBottomEdgeY - AppBarTopAndBottomPadding - padding - FakeIconSize |
| ) |
| } |
| |
| @Test |
| fun centerAlignedTopAppBar_noNavigationIcon_positioning() { |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| CenterAlignedTopAppBar( |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| } |
| ) |
| } |
| } |
| assertSmallPositioningWithoutNavigation(isCenteredTitle = true) |
| } |
| |
| @Test |
| fun centerAlignedTopAppBar_titleDefaultStyle() { |
| var textStyle: TextStyle? = null |
| var expectedTextStyle: TextStyle? = null |
| rule.setMaterialContent(lightColorScheme()) { |
| CenterAlignedTopAppBar( |
| title = { |
| Text("Title") |
| textStyle = LocalTextStyle.current |
| expectedTextStyle = |
| MaterialTheme.typography.fromToken( |
| TopAppBarSmallCenteredTokens.HeadlineFont |
| ) |
| } |
| ) |
| } |
| assertThat(textStyle).isNotNull() |
| assertThat(textStyle).isEqualTo(expectedTextStyle) |
| } |
| |
| @Test |
| fun centerAlignedTopAppBar_measureWithNonZeroMinWidth() { |
| var appBarSize = IntSize.Zero |
| rule.setMaterialContent(lightColorScheme()) { |
| CenterAlignedTopAppBar( |
| modifier = Modifier.layout { measurable, constraints -> |
| val placeable = measurable.measure( |
| constraints.copy(minWidth = constraints.maxWidth) |
| ) |
| appBarSize = IntSize(placeable.width, placeable.height) |
| layout(placeable.width, placeable.height) { |
| placeable.place(0, 0) |
| } |
| }, |
| title = { |
| Text("Title") |
| } |
| ) |
| } |
| |
| assertThat(appBarSize).isNotEqualTo(IntSize.Zero) |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun centerAlignedTopAppBar_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(lightColorScheme()) { |
| CenterAlignedTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| navigationIconColor = LocalContentColor.current |
| expectedNavigationIconColor = |
| TopAppBarDefaults.centerAlignedTopAppBarColors() |
| .navigationIconContentColor |
| // fraction = 0f to indicate no scroll. |
| expectedContainerColor = |
| TopAppBarDefaults.centerAlignedTopAppBarColors() |
| .containerColor(colorTransitionFraction = 0f) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| titleColor = LocalContentColor.current |
| expectedTitleColor = |
| TopAppBarDefaults.centerAlignedTopAppBarColors() |
| .titleContentColor |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| actionsColor = LocalContentColor.current |
| expectedActionsColor = |
| TopAppBarDefaults.centerAlignedTopAppBarColors() |
| .actionIconContentColor |
| } |
| ) |
| } |
| 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) |
| } |
| |
| @OptIn(ExperimentalMaterial3Api::class) |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun centerAlignedTopAppBar_scrolledContentColor() { |
| var expectedScrolledContainerColor: Color = Color.Unspecified |
| lateinit var scrollBehavior: TopAppBarScrollBehavior |
| |
| rule.setMaterialContent(lightColorScheme()) { |
| scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() |
| CenterAlignedTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| // fraction = 1f to indicate a scroll. |
| expectedScrolledContainerColor = |
| TopAppBarDefaults.centerAlignedTopAppBarColors() |
| .containerColor(colorTransitionFraction = 1f) |
| }, |
| scrollBehavior = scrollBehavior |
| ) |
| } |
| |
| // Simulate scrolled content. |
| rule.runOnIdle { |
| scrollBehavior.state.contentOffset = -100f |
| } |
| rule.waitForIdle() |
| rule.onNodeWithTag(TopAppBarTestTag).captureToImage() |
| .assertContainsColor(expectedScrolledContainerColor) |
| } |
| |
| @Test |
| fun mediumTopAppBar_expandsToScreen() { |
| rule.setMaterialContentForSizeAssertions { |
| MediumTopAppBar(title = { Text("Medium Title") }) |
| } |
| .assertHeightIsEqualTo(TopAppBarMediumTokens.ContainerHeight) |
| .assertWidthIsEqualTo(rule.rootWidth()) |
| } |
| |
| @Test |
| fun mediumTopAppBar_expanded_positioning() { |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| MediumTopAppBar( |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| } |
| ) |
| } |
| } |
| |
| // The bottom text baseline should be 24.dp from the bottom of the app bar. |
| assertMediumOrLargeDefaultPositioning( |
| expectedAppBarHeight = TopAppBarMediumTokens.ContainerHeight, |
| bottomTextPadding = 24.dp |
| ) |
| } |
| |
| @Test |
| fun mediumTopAppBar_scrolled_positioning() { |
| val windowInsets = WindowInsets(13.dp, 13.dp, 13.dp, 13.dp) |
| val content = @Composable { scrollBehavior: TopAppBarScrollBehavior? -> |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| MediumTopAppBar( |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| }, |
| scrollBehavior = scrollBehavior, |
| windowInsets = windowInsets |
| ) |
| } |
| } |
| assertMediumOrLargeScrolledHeight( |
| TopAppBarMediumTokens.ContainerHeight, |
| TopAppBarSmallTokens.ContainerHeight, |
| windowInsets, |
| content |
| ) |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun mediumTopAppBar_scrolledContainerColor() { |
| val content = @Composable { scrollBehavior: TopAppBarScrollBehavior? -> |
| MediumTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| scrollBehavior = scrollBehavior |
| ) |
| } |
| |
| assertMediumOrLargeScrolledColors( |
| TopAppBarMediumTokens.ContainerHeight, |
| TopAppBarSmallTokens.ContainerHeight, |
| content |
| ) |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun mediumTopAppBar_semantics() { |
| val content = @Composable { scrollBehavior: TopAppBarScrollBehavior? -> |
| MediumTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| scrollBehavior = scrollBehavior |
| ) |
| } |
| |
| assertMediumOrLargeScrolledSemantics( |
| TopAppBarMediumTokens.ContainerHeight, |
| TopAppBarSmallTokens.ContainerHeight, |
| content |
| ) |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun largeTopAppBar_semantics() { |
| val content = @Composable { scrollBehavior: TopAppBarScrollBehavior? -> |
| LargeTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| scrollBehavior = scrollBehavior |
| ) |
| } |
| assertMediumOrLargeScrolledSemantics( |
| TopAppBarLargeTokens.ContainerHeight, |
| TopAppBarSmallTokens.ContainerHeight, |
| content |
| ) |
| } |
| |
| @Test |
| fun largeTopAppBar_expandsToScreen() { |
| rule.setMaterialContentForSizeAssertions { |
| LargeTopAppBar(title = { Text("Large Title") }) |
| } |
| .assertHeightIsEqualTo(TopAppBarLargeTokens.ContainerHeight) |
| .assertWidthIsEqualTo(rule.rootWidth()) |
| } |
| |
| @Test |
| fun largeTopAppBar_expanded_positioning() { |
| rule.setMaterialContent(lightColorScheme()) { |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| LargeTopAppBar( |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| } |
| ) |
| } |
| } |
| |
| // The bottom text baseline should be 28.dp from the bottom of the app bar. |
| assertMediumOrLargeDefaultPositioning( |
| expectedAppBarHeight = TopAppBarLargeTokens.ContainerHeight, |
| bottomTextPadding = 28.dp |
| ) |
| } |
| |
| @Test |
| fun largeTopAppBar_scrolled_positioning() { |
| val windowInsets = WindowInsets(4.dp, 4.dp, 4.dp, 4.dp) |
| val content = @Composable { scrollBehavior: TopAppBarScrollBehavior? -> |
| Box(Modifier.testTag(TopAppBarTestTag)) { |
| LargeTopAppBar( |
| navigationIcon = { |
| FakeIcon(Modifier.testTag(NavigationIconTestTag)) |
| }, |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| actions = { |
| FakeIcon(Modifier.testTag(ActionsTestTag)) |
| }, |
| scrollBehavior = scrollBehavior, |
| windowInsets = windowInsets |
| ) |
| } |
| } |
| assertMediumOrLargeScrolledHeight( |
| TopAppBarLargeTokens.ContainerHeight, |
| TopAppBarSmallTokens.ContainerHeight, |
| windowInsets, |
| content |
| ) |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun largeTopAppBar_scrolledContainerColor() { |
| val content = @Composable { scrollBehavior: TopAppBarScrollBehavior? -> |
| LargeTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title", Modifier.testTag(TitleTestTag)) |
| }, |
| scrollBehavior = scrollBehavior, |
| ) |
| } |
| assertMediumOrLargeScrolledColors( |
| TopAppBarLargeTokens.ContainerHeight, |
| TopAppBarSmallTokens.ContainerHeight, |
| content |
| ) |
| } |
| |
| @OptIn(ExperimentalMaterial3Api::class) |
| @Test |
| fun topAppBar_enterAlways_allowHorizontalScroll() { |
| lateinit var state: LazyListState |
| rule.setMaterialContent(lightColorScheme()) { |
| state = rememberLazyListState() |
| MultiPageContent(TopAppBarDefaults.enterAlwaysScrollBehavior(), state) |
| } |
| |
| rule.onNodeWithTag(LazyListTag).performTouchInput { swipeLeft() } |
| rule.runOnIdle { |
| assertThat(state.firstVisibleItemIndex).isEqualTo(1) |
| } |
| |
| rule.onNodeWithTag(LazyListTag).performTouchInput { swipeRight() } |
| rule.runOnIdle { |
| assertThat(state.firstVisibleItemIndex).isEqualTo(0) |
| } |
| } |
| |
| @OptIn(ExperimentalMaterial3Api::class) |
| @Test |
| fun topAppBar_exitUntilCollapsed_allowHorizontalScroll() { |
| lateinit var state: LazyListState |
| rule.setMaterialContent(lightColorScheme()) { |
| state = rememberLazyListState() |
| MultiPageContent(TopAppBarDefaults.exitUntilCollapsedScrollBehavior(), state) |
| } |
| |
| rule.onNodeWithTag(LazyListTag).performTouchInput { swipeLeft() } |
| rule.runOnIdle { |
| assertThat(state.firstVisibleItemIndex).isEqualTo(1) |
| } |
| |
| rule.onNodeWithTag(LazyListTag).performTouchInput { swipeRight() } |
| rule.runOnIdle { |
| assertThat(state.firstVisibleItemIndex).isEqualTo(0) |
| } |
| } |
| |
| @OptIn(ExperimentalMaterial3Api::class) |
| @Test |
| fun topAppBar_pinned_allowHorizontalScroll() { |
| lateinit var state: LazyListState |
| rule.setMaterialContent(lightColorScheme()) { |
| state = rememberLazyListState() |
| MultiPageContent( |
| TopAppBarDefaults.pinnedScrollBehavior(), |
| state |
| ) |
| } |
| |
| rule.onNodeWithTag(LazyListTag).performTouchInput { swipeLeft() } |
| rule.runOnIdle { |
| assertThat(state.firstVisibleItemIndex).isEqualTo(1) |
| } |
| |
| rule.onNodeWithTag(LazyListTag).performTouchInput { swipeRight() } |
| rule.runOnIdle { |
| assertThat(state.firstVisibleItemIndex).isEqualTo(0) |
| } |
| } |
| |
| @Test |
| fun topAppBar_smallPinnedDraggedAppBar() { |
| rule.setMaterialContentForSizeAssertions { |
| SmallTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title") |
| }, |
| scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() |
| ) |
| } |
| |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo(TopAppBarSmallTokens.ContainerHeight) |
| |
| // Drag the app bar up half its height. |
| rule.onNodeWithTag(TopAppBarTestTag).performTouchInput { |
| down(Offset(x = 0f, y = height / 2f)) |
| moveTo(Offset(x = 0f, y = 0f)) |
| } |
| rule.waitForIdle() |
| // Check that the app bar did not collapse. |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo(TopAppBarSmallTokens.ContainerHeight) |
| } |
| |
| @Test |
| fun topAppBar_mediumDraggedAppBar() { |
| rule.setMaterialContentForSizeAssertions { |
| MediumTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title") |
| }, |
| scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() |
| ) |
| } |
| |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo(TopAppBarMediumTokens.ContainerHeight) |
| |
| // Drag up the app bar. |
| rule.onNodeWithTag(TopAppBarTestTag).performTouchInput { |
| down(Offset(x = 0f, y = height - 20f)) |
| moveTo(Offset(x = 0f, y = 0f)) |
| } |
| rule.waitForIdle() |
| // Check that the app bar collapsed to its small size constraints (i.e. |
| // TopAppBarSmallTokens.ContainerHeight). |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo(TopAppBarSmallTokens.ContainerHeight) |
| } |
| |
| @Test |
| fun topAppBar_dragSnapToCollapsed() { |
| rule.setMaterialContentForSizeAssertions { |
| LargeTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { |
| Text("Title") |
| }, |
| scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() |
| ) |
| } |
| |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo(TopAppBarLargeTokens.ContainerHeight) |
| |
| // Slightly drag up the app bar. |
| rule.onNodeWithTag(TopAppBarTestTag).performTouchInput { |
| down(Offset(x = 0f, y = height - 20f)) |
| moveTo(Offset(x = 0f, y = height - 40f)) |
| up() |
| } |
| rule.waitForIdle() |
| |
| // Check that the app bar returned to its expanded size (i.e. fully expanded). |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo(TopAppBarLargeTokens.ContainerHeight) |
| |
| // Drag up the app bar to the point it should continue to collapse after. |
| rule.onNodeWithTag(TopAppBarTestTag).performTouchInput { |
| down(Offset(x = 0f, y = height - 20f)) |
| moveTo(Offset(x = 0f, y = 40f)) |
| up() |
| } |
| rule.waitForIdle() |
| |
| // Check that the app bar collapsed to its small size constraints (i.e. |
| // TopAppBarSmallTokens.ContainerHeight). |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo(TopAppBarSmallTokens.ContainerHeight) |
| } |
| |
| @Test |
| fun state_restoresTopAppBarState() { |
| val restorationTester = StateRestorationTester(rule) |
| var topAppBarState: TopAppBarState? = null |
| restorationTester.setContent { |
| topAppBarState = rememberTopAppBarState() |
| } |
| |
| rule.runOnIdle { |
| topAppBarState!!.heightOffsetLimit = -350f |
| topAppBarState!!.heightOffset = -300f |
| topAppBarState!!.contentOffset = -550f |
| } |
| |
| topAppBarState = null |
| |
| restorationTester.emulateSavedInstanceStateRestore() |
| |
| rule.runOnIdle { |
| assertThat(topAppBarState!!.heightOffsetLimit).isEqualTo(-350f) |
| assertThat(topAppBarState!!.heightOffset).isEqualTo(-300f) |
| assertThat(topAppBarState!!.contentOffset).isEqualTo(-550f) |
| } |
| } |
| |
| @Test |
| fun bottomAppBarWithFAB_heightIsFromSpec() { |
| rule |
| .setMaterialContentForSizeAssertions { |
| BottomAppBar( |
| actions = {}, |
| floatingActionButton = { |
| FloatingActionButton( |
| onClick = { /* do something */ }, |
| containerColor = BottomAppBarDefaults.bottomAppBarFabColor, |
| elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation() |
| ) { |
| Icon(Icons.Filled.Add, "Localized description") |
| } |
| }) |
| } |
| .assertHeightIsEqualTo(BottomAppBarTokens.ContainerHeight) |
| .assertWidthIsEqualTo(rule.rootWidth()) |
| } |
| |
| @Test |
| fun bottomAppBarWithFAB_respectsWindowInsets() { |
| rule |
| .setMaterialContentForSizeAssertions { |
| BottomAppBar( |
| actions = {}, |
| windowInsets = WindowInsets(10.dp, 10.dp, 10.dp, 10.dp), |
| floatingActionButton = { |
| FloatingActionButton( |
| onClick = { /* do something */ }, |
| containerColor = BottomAppBarDefaults.bottomAppBarFabColor, |
| elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation() |
| ) { |
| Icon(Icons.Filled.Add, "Localized description") |
| } |
| }) |
| } |
| .assertHeightIsEqualTo(BottomAppBarTokens.ContainerHeight + 20.dp) |
| .assertWidthIsEqualTo(rule.rootWidth()) |
| } |
| |
| @Test |
| fun bottomAppBar_widthExpandsToScreen() { |
| rule |
| .setMaterialContentForSizeAssertions { |
| BottomAppBar {} |
| } |
| .assertHeightIsEqualTo(BottomAppBarTokens.ContainerHeight) |
| .assertWidthIsEqualTo(rule.rootWidth()) |
| } |
| |
| @Test |
| fun bottomAppBar_default_positioning() { |
| rule.setMaterialContent(lightColorScheme()) { |
| BottomAppBar(Modifier.testTag("bar")) { |
| FakeIcon(Modifier.testTag("icon")) |
| } |
| } |
| |
| val appBarBounds = rule.onNodeWithTag("bar").getUnclippedBoundsInRoot() |
| val appBarBottomEdgeY = appBarBounds.top + appBarBounds.height |
| |
| val defaultPadding = BottomAppBarDefaults.ContentPadding |
| rule.onNodeWithTag("icon") |
| // Child icon should be 4.dp from the start |
| .assertLeftPositionInRootIsEqualTo(AppBarStartAndEndPadding) |
| // Child icon should be 10.dp from the top |
| .assertTopPositionInRootIsEqualTo( |
| defaultPadding.calculateTopPadding() + |
| (appBarBottomEdgeY - defaultPadding.calculateTopPadding() - FakeIconSize) / 2 |
| ) |
| } |
| |
| @Test |
| fun bottomAppBar_default_positioning_respectsContentPadding() { |
| val topPadding = 5.dp |
| rule.setMaterialContent(lightColorScheme()) { |
| BottomAppBar( |
| Modifier.testTag("bar"), |
| contentPadding = PaddingValues(top = topPadding, start = 3.dp) |
| ) { |
| FakeIcon(Modifier.testTag("icon")) |
| } |
| } |
| |
| val appBarBounds = rule.onNodeWithTag("bar").getUnclippedBoundsInRoot() |
| val appBarBottomEdgeY = appBarBounds.top + appBarBounds.height |
| |
| rule.onNodeWithTag("icon") |
| // Child icon should be 4.dp from the start |
| .assertLeftPositionInRootIsEqualTo(3.dp) |
| // Child icon should be 10.dp from the top |
| .assertTopPositionInRootIsEqualTo( |
| (appBarBottomEdgeY - topPadding - FakeIconSize) / 2 + 5.dp |
| ) |
| } |
| |
| @Test |
| fun bottomAppBarWithFAB_default_positioning() { |
| rule.setMaterialContent(lightColorScheme()) { |
| BottomAppBar( |
| actions = {}, |
| Modifier.testTag("bar"), |
| floatingActionButton = { |
| FloatingActionButton( |
| onClick = { /* do something */ }, |
| modifier = Modifier.testTag("FAB"), |
| containerColor = BottomAppBarDefaults.bottomAppBarFabColor, |
| elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation() |
| ) { |
| Icon(Icons.Filled.Add, "Localized description") |
| } |
| }) |
| } |
| |
| val appBarBounds = rule.onNodeWithTag("bar").getUnclippedBoundsInRoot() |
| |
| val fabBounds = rule.onNodeWithTag("FAB").getUnclippedBoundsInRoot() |
| |
| rule.onNodeWithTag("FAB") |
| // FAB should be 16.dp from the end |
| .assertLeftPositionInRootIsEqualTo(appBarBounds.width - 16.dp - fabBounds.width) |
| // FAB should be 12.dp from the top |
| .assertTopPositionInRootIsEqualTo(12.dp) |
| } |
| |
| @OptIn(ExperimentalMaterial3Api::class) |
| @Composable |
| private fun MultiPageContent(scrollBehavior: TopAppBarScrollBehavior, state: LazyListState) { |
| Scaffold( |
| modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), |
| topBar = { |
| SmallTopAppBar( |
| modifier = Modifier.testTag(TopAppBarTestTag), |
| title = { Text(text = "Title") }, |
| scrollBehavior = scrollBehavior, |
| ) |
| } |
| ) { contentPadding -> |
| LazyRow( |
| Modifier |
| .fillMaxSize() |
| .testTag(LazyListTag), state |
| ) { |
| items(2) { page -> |
| LazyColumn( |
| modifier = Modifier.fillParentMaxSize(), |
| contentPadding = contentPadding |
| ) { |
| items(50) { |
| Text( |
| modifier = Modifier.fillParentMaxWidth(), |
| text = "Item #$page x $it" |
| ) |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| /** |
| * Checks the app bar's components positioning when it's a [SmallTopAppBar], a |
| * [CenterAlignedTopAppBar], or a larger app bar that is scrolled up and collapsed into a small |
| * configuration and there is no navigation icon. |
| */ |
| private fun assertSmallPositioningWithoutNavigation(isCenteredTitle: Boolean = false) { |
| val appBarBounds = rule.onNodeWithTag(TopAppBarTestTag).getUnclippedBoundsInRoot() |
| val titleBounds = rule.onNodeWithTag(TitleTestTag).getUnclippedBoundsInRoot() |
| |
| val titleNode = rule.onNodeWithTag(TitleTestTag) |
| // Title should be vertically centered |
| titleNode.assertTopPositionInRootIsEqualTo((appBarBounds.height - titleBounds.height) / 2) |
| if (isCenteredTitle) { |
| // Title should be horizontally centered |
| titleNode.assertLeftPositionInRootIsEqualTo( |
| (appBarBounds.width - titleBounds.width) / 2 |
| ) |
| } else { |
| // 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 |
| titleNode.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] or a |
| * [CenterAlignedTopAppBar]. |
| */ |
| private fun assertSmallDefaultPositioning(isCenteredTitle: Boolean = false) { |
| 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 |
| ) |
| |
| val titleNode = rule.onNodeWithTag(TitleTestTag) |
| // Title should be vertically centered |
| titleNode.assertTopPositionInRootIsEqualTo((appBarBounds.height - titleBounds.height) / 2) |
| if (isCenteredTitle) { |
| // Title should be horizontally centered |
| titleNode.assertLeftPositionInRootIsEqualTo( |
| (appBarBounds.width - titleBounds.width) / 2 |
| ) |
| } else { |
| // Title should be 56.dp from the start |
| // 4.dp padding for the whole app bar + 48.dp icon size + 4.dp title padding. |
| titleNode.assertLeftPositionInRootIsEqualTo(4.dp + FakeIconSize + 4.dp) |
| } |
| |
| 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 |
| ) |
| } |
| |
| /** |
| * Checks the app bar's components positioning when it's a [MediumTopAppBar] or a |
| * [LargeTopAppBar]. |
| */ |
| private fun assertMediumOrLargeDefaultPositioning( |
| expectedAppBarHeight: Dp, |
| bottomTextPadding: Dp |
| ) { |
| val appBarBounds = rule.onNodeWithTag(TopAppBarTestTag).getUnclippedBoundsInRoot() |
| appBarBounds.height.assertIsEqualTo(expectedAppBarHeight, "top app bar height") |
| |
| // Expecting the title composable to be reused for the top and bottom rows of the top app |
| // bar, so obtaining the node with the title tag should return two nodes, one for each row. |
| val allTitleNodes = rule.onAllNodesWithTag(TitleTestTag, true) |
| allTitleNodes.assertCountEquals(2) |
| val topTitleNode = allTitleNodes.onFirst() |
| val bottomTitleNode = allTitleNodes.onLast() |
| |
| val topTitleBounds = topTitleNode.getUnclippedBoundsInRoot() |
| val bottomTitleBounds = bottomTitleNode.getUnclippedBoundsInRoot() |
| val topAppBarBottomEdgeY = appBarBounds.top + TopAppBarSmallTokens.ContainerHeight |
| val bottomAppBarBottomEdgeY = 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 top part of the app bar. |
| .assertTopPositionInRootIsEqualTo( |
| topAppBarBottomEdgeY - AppBarTopAndBottomPadding - FakeIconSize |
| ) |
| |
| rule.onNodeWithTag(ActionsTestTag) |
| // Action should be placed at the end |
| .assertLeftPositionInRootIsEqualTo(expectedActionPosition(appBarBounds.width)) |
| // Action should be 8.dp from the top |
| .assertTopPositionInRootIsEqualTo( |
| topAppBarBottomEdgeY - AppBarTopAndBottomPadding - FakeIconSize |
| ) |
| |
| topTitleNode |
| // Top 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 in the top part, which has a height of a small |
| // app bar. |
| .assertTopPositionInRootIsEqualTo((topAppBarBottomEdgeY - topTitleBounds.height) / 2) |
| |
| bottomTitleNode |
| // Bottom title should be 16.dp from the start. |
| .assertLeftPositionInRootIsEqualTo(16.dp) |
| |
| // Check if the bottom text baseline is at the expected distance from the bottom of the |
| // app bar. |
| val bottomTextBaselineY = bottomTitleBounds.top + bottomTitleNode.getLastBaselinePosition() |
| (bottomAppBarBottomEdgeY - bottomTextBaselineY).assertIsEqualTo( |
| bottomTextPadding, |
| "text baseline distance from the bottom" |
| ) |
| } |
| |
| /** |
| * Checks that changing values at a [MediumTopAppBar] or a [LargeTopAppBar] scroll behavior |
| * affects the height of the app bar. |
| * |
| * This check partially and fully collapses the app bar to test its height. |
| * |
| * @param appBarMaxHeight the max height of the app bar [content] |
| * @param appBarMinHeight the min height of the app bar [content] |
| * @param content a Composable that adds a MediumTopAppBar or a LargeTopAppBar |
| */ |
| @OptIn(ExperimentalMaterial3Api::class) |
| private fun assertMediumOrLargeScrolledHeight( |
| appBarMaxHeight: Dp, |
| appBarMinHeight: Dp, |
| windowInsets: WindowInsets, |
| content: @Composable (TopAppBarScrollBehavior?) -> Unit |
| ) { |
| val (topInset, bottomInset) = with(rule.density) { |
| windowInsets.getTop(this).toDp() to windowInsets.getBottom(this).toDp() |
| } |
| val fullyCollapsedOffsetDp = appBarMaxHeight - appBarMinHeight |
| val partiallyCollapsedOffsetDp = fullyCollapsedOffsetDp / 3 |
| var partiallyCollapsedHeightOffsetPx = 0f |
| var fullyCollapsedHeightOffsetPx = 0f |
| lateinit var scrollBehavior: TopAppBarScrollBehavior |
| rule.setMaterialContent(lightColorScheme()) { |
| scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() |
| with(LocalDensity.current) { |
| partiallyCollapsedHeightOffsetPx = partiallyCollapsedOffsetDp.toPx() |
| fullyCollapsedHeightOffsetPx = fullyCollapsedOffsetDp.toPx() |
| } |
| |
| content(scrollBehavior) |
| } |
| |
| // Simulate a partially collapsed app bar. |
| rule.runOnIdle { |
| scrollBehavior.state.heightOffset = -partiallyCollapsedHeightOffsetPx |
| scrollBehavior.state.contentOffset = -partiallyCollapsedHeightOffsetPx |
| } |
| rule.waitForIdle() |
| rule.onNodeWithTag(TopAppBarTestTag) |
| .assertHeightIsEqualTo( |
| appBarMaxHeight - partiallyCollapsedOffsetDp + topInset + bottomInset |
| ) |
| |
| // Simulate a fully collapsed app bar. |
| rule.runOnIdle { |
| scrollBehavior.state.heightOffset = -fullyCollapsedHeightOffsetPx |
| // Simulate additional content scroll beyond the max offset scroll. |
| scrollBehavior.state.contentOffset = |
| -fullyCollapsedHeightOffsetPx - partiallyCollapsedHeightOffsetPx |
| } |
| rule.waitForIdle() |
| // Check that the app bar collapsed to its min height. |
| rule.onNodeWithTag(TopAppBarTestTag).assertHeightIsEqualTo( |
| appBarMinHeight + topInset + bottomInset |
| ) |
| } |
| |
| /** |
| * Checks that changing values at a [MediumTopAppBar] or a [LargeTopAppBar] scroll behavior |
| * affects the container color and the title's content color of the app bar. |
| * |
| * This check partially and fully collapses the app bar to test its colors. |
| * |
| * @param appBarMaxHeight the max height of the app bar [content] |
| * @param appBarMinHeight the min height of the app bar [content] |
| * @param content a Composable that adds a MediumTopAppBar or a LargeTopAppBar |
| */ |
| @OptIn(ExperimentalMaterial3Api::class) |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| private fun assertMediumOrLargeScrolledColors( |
| appBarMaxHeight: Dp, |
| appBarMinHeight: Dp, |
| content: @Composable (TopAppBarScrollBehavior?) -> Unit |
| ) { |
| val fullyCollapsedOffsetDp = appBarMaxHeight - appBarMinHeight |
| val oneThirdCollapsedOffsetDp = fullyCollapsedOffsetDp / 3 |
| var fullyCollapsedHeightOffsetPx = 0f |
| var oneThirdCollapsedHeightOffsetPx = 0f |
| var fullyCollapsedContainerColor: Color = Color.Unspecified |
| var oneThirdCollapsedContainerColor: Color = Color.Unspecified |
| var titleContentColor: Color = Color.Unspecified |
| lateinit var scrollBehavior: TopAppBarScrollBehavior |
| rule.setMaterialContent(lightColorScheme()) { |
| scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() |
| // Using the mediumTopAppBarColors for both Medium and Large top app bars, as the |
| // current content color settings are the same. |
| oneThirdCollapsedContainerColor = |
| TopAppBarDefaults.mediumTopAppBarColors() |
| .containerColor(colorTransitionFraction = 1 / 3f) |
| fullyCollapsedContainerColor = |
| TopAppBarDefaults.mediumTopAppBarColors() |
| .containerColor(colorTransitionFraction = 1f) |
| |
| // Resolve the title's content color. The default implementation returns the same color |
| // regardless of the fraction, and the color is applied later with alpha. |
| titleContentColor = |
| TopAppBarDefaults.mediumTopAppBarColors().titleContentColor |
| |
| with(LocalDensity.current) { |
| oneThirdCollapsedHeightOffsetPx = oneThirdCollapsedOffsetDp.toPx() |
| fullyCollapsedHeightOffsetPx = fullyCollapsedOffsetDp.toPx() |
| } |
| |
| content(scrollBehavior) |
| } |
| |
| // Expecting the title composable to be reused for the top and bottom rows of the top app |
| // bar, so obtaining the node with the title tag should return two nodes, one for each row. |
| val allTitleNodes = rule.onAllNodesWithTag(TitleTestTag, true) |
| allTitleNodes.assertCountEquals(2) |
| val topTitleNode = allTitleNodes.onFirst() |
| val bottomTitleNode = allTitleNodes.onLast() |
| |
| // Simulate 1/3 collapsed content. |
| rule.runOnIdle { |
| scrollBehavior.state.heightOffset = -oneThirdCollapsedHeightOffsetPx |
| scrollBehavior.state.contentOffset = -oneThirdCollapsedHeightOffsetPx |
| } |
| rule.waitForIdle() |
| rule.onNodeWithTag(TopAppBarTestTag).captureToImage() |
| .assertContainsColor(oneThirdCollapsedContainerColor) |
| |
| // Both top and bottom titles should be visible. The top should have the title text color |
| // with ~33.333% alpha, and the bottom with ~66.666% alpha. |
| topTitleNode.captureToImage() |
| .assertContainsColor( |
| titleContentColor.copy(alpha = 1 / 3f) |
| .compositeOver(oneThirdCollapsedContainerColor) |
| ) |
| bottomTitleNode.captureToImage() |
| .assertContainsColor( |
| titleContentColor.copy(alpha = 2 / 3f) |
| .compositeOver(oneThirdCollapsedContainerColor) |
| ) |
| |
| // Simulate fully collapsed content. |
| rule.runOnIdle { |
| scrollBehavior.state.heightOffset = -fullyCollapsedHeightOffsetPx |
| scrollBehavior.state.contentOffset = -fullyCollapsedHeightOffsetPx |
| } |
| rule.waitForIdle() |
| rule.onNodeWithTag(TopAppBarTestTag).captureToImage() |
| .assertContainsColor(fullyCollapsedContainerColor) |
| // Only the top title should be visible in the collapsed form. |
| topTitleNode.captureToImage().assertContainsColor(titleContentColor) |
| bottomTitleNode.assertIsNotDisplayed() |
| } |
| |
| /** |
| * Checks that changing values at a [MediumTopAppBar] or a [LargeTopAppBar] scroll behavior |
| * affects the title's semantics. |
| * |
| * This check partially and fully collapses the app bar to test the semantics. |
| * |
| * @param appBarMaxHeight the max height of the app bar [content] |
| * @param appBarMinHeight the min height of the app bar [content] |
| * @param content a Composable that adds a MediumTopAppBar or a LargeTopAppBar |
| */ |
| @OptIn(ExperimentalMaterial3Api::class) |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| private fun assertMediumOrLargeScrolledSemantics( |
| appBarMaxHeight: Dp, |
| appBarMinHeight: Dp, |
| content: @Composable (TopAppBarScrollBehavior?) -> Unit |
| ) { |
| val fullyCollapsedOffsetDp = appBarMaxHeight - appBarMinHeight |
| val oneThirdCollapsedOffsetDp = fullyCollapsedOffsetDp / 3 |
| var fullyCollapsedHeightOffsetPx = 0f |
| var oneThirdCollapsedHeightOffsetPx = 0f |
| lateinit var scrollBehavior: TopAppBarScrollBehavior |
| rule.setMaterialContent(lightColorScheme()) { |
| scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() |
| with(LocalDensity.current) { |
| oneThirdCollapsedHeightOffsetPx = oneThirdCollapsedOffsetDp.toPx() |
| fullyCollapsedHeightOffsetPx = fullyCollapsedOffsetDp.toPx() |
| } |
| |
| content(scrollBehavior) |
| } |
| |
| // Asserting that only one semantic title node is returned after the clearAndSetSemantics is |
| // applied to the merged tree according to the alpha values of the titles. |
| assertSingleTitleSemanticNode() |
| |
| // Simulate 1/3 collapsed content. |
| rule.runOnIdle { |
| scrollBehavior.state.heightOffset = -oneThirdCollapsedHeightOffsetPx |
| scrollBehavior.state.contentOffset = -oneThirdCollapsedHeightOffsetPx |
| } |
| rule.waitForIdle() |
| |
| // Assert that only one semantic title node is available while scrolling the app bar. |
| assertSingleTitleSemanticNode() |
| |
| // Simulate fully collapsed content. |
| rule.runOnIdle { |
| scrollBehavior.state.heightOffset = -fullyCollapsedHeightOffsetPx |
| scrollBehavior.state.contentOffset = -fullyCollapsedHeightOffsetPx |
| } |
| rule.waitForIdle() |
| |
| // Assert that only one semantic title node is available. |
| assertSingleTitleSemanticNode() |
| } |
| |
| /** |
| * Asserts that only one semantic node exists at app bar title when the tree is merged. |
| */ |
| private fun assertSingleTitleSemanticNode() { |
| val unmergedTitleNodes = rule.onAllNodesWithTag(TitleTestTag, useUnmergedTree = true) |
| unmergedTitleNodes.assertCountEquals(2) |
| |
| val mergedTitleNodes = rule.onAllNodesWithTag(TitleTestTag, useUnmergedTree = false) |
| mergedTitleNodes.assertCountEquals(1) |
| } |
| |
| /** |
| * An [IconButton] with an [Icon] inside for testing positions. |
| * |
| * An [IconButton] is defaulted to be 48X48dp, while its child [Icon] is defaulted to 24x24dp. |
| */ |
| private val FakeIcon = @Composable { modifier: Modifier -> |
| IconButton( |
| onClick = { /* doSomething() */ }, |
| modifier = modifier.semantics(mergeDescendants = true) {} |
| ) { |
| Icon(ColorPainter(Color.Red), null) |
| } |
| } |
| |
| private fun expectedActionPosition(appBarWidth: Dp): Dp = |
| appBarWidth - AppBarStartAndEndPadding - FakeIconSize |
| |
| private val FakeIconSize = 48.dp |
| private val AppBarStartAndEndPadding = 4.dp |
| private val AppBarTopAndBottomPadding = |
| (TopAppBarSmallTokens.ContainerHeight - FakeIconSize) / 2 |
| |
| private val LazyListTag = "lazyList" |
| private val TopAppBarTestTag = "bar" |
| private val NavigationIconTestTag = "navigationIcon" |
| private val TitleTestTag = "title" |
| private val ActionsTestTag = "actions" |
| } |