diff options
2 files changed, 199 insertions, 397 deletions
diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBar.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBar.kt index 9a344c3d7f14..2ae3b569bc70 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBar.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBar.kt @@ -41,23 +41,25 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.layout.Layout @@ -66,6 +68,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow @@ -80,11 +83,10 @@ import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt -private val windowInsets: WindowInsets +private val safeDrawingWindowInsets: WindowInsets @Composable @NonRestartableComposable - get() = WindowInsets.safeDrawing - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) @Composable internal fun CustomizedTopAppBar( @@ -97,14 +99,12 @@ internal fun CustomizedTopAppBar( titleTextStyle = MaterialTheme.typography.titleMedium, navigationIcon = navigationIcon, actions = actions, - windowInsets = windowInsets, + windowInsets = safeDrawingWindowInsets, colors = topAppBarColors(), ) } -/** - * The customized LargeTopAppBar for Settings. - */ +/** The customized LargeTopAppBar for Settings. */ @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun CustomizedLargeTopAppBar( @@ -124,7 +124,7 @@ internal fun CustomizedLargeTopAppBar( navigationIcon = navigationIcon, actions = actions, colors = topAppBarColors(), - windowInsets = windowInsets, + windowInsets = safeDrawingWindowInsets, pinnedHeight = ContainerHeight, scrollBehavior = scrollBehavior, ) @@ -134,38 +134,41 @@ internal fun CustomizedLargeTopAppBar( private fun Title(title: String, maxLines: Int = Int.MAX_VALUE) { Text( text = title, - modifier = Modifier.padding( - start = SettingsDimension.itemPaddingAround, - end = SettingsDimension.itemPaddingEnd, - ) - .semantics { heading() }, + modifier = + Modifier.padding( + start = SettingsDimension.itemPaddingAround, + end = SettingsDimension.itemPaddingEnd, + ) + .semantics { heading() }, overflow = TextOverflow.Ellipsis, maxLines = maxLines, ) } @Composable -private fun topAppBarColors() = TopAppBarColors( - containerColor = MaterialTheme.colorScheme.settingsBackground, - scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant, - navigationIconContentColor = MaterialTheme.colorScheme.onSurface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, -) +private fun topAppBarColors() = + TopAppBarColors( + containerColor = MaterialTheme.colorScheme.settingsBackground, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) /** * Represents the colors used by a top app bar in different states. + * * This implementation animates the container color according to the top app bar scroll state. It * does not animate the leading, headline, or trailing colors. * - * @constructor create an instance with arbitrary colors, see [TopAppBarColors] for a - * factory method using the default material3 spec * @param containerColor the color used for the background of this BottomAppBar. Use - * [Color.Transparent] to have no color. + * [Color.Transparent] to have no color. * @param scrolledContainerColor the container color when content is scrolled behind it * @param navigationIconContentColor the content color used for the navigation icon * @param titleContentColor the content color used for the title * @param actionIconContentColor the content color used for actions + * @constructor create an instance with arbitrary colors, see [TopAppBarColors] for a factory method + * using the default material3 spec */ @Stable private class TopAppBarColors( @@ -180,11 +183,11 @@ private class TopAppBarColors( * Represents the container color used for the top app bar. * * A [colorTransitionFraction] provides a percentage value that can be used to generate a color. - * Usually, an app bar implementation will pass in a [colorTransitionFraction] read from - * the [TopAppBarState.collapsedFraction] or the [TopAppBarState.overlappedFraction]. + * Usually, an app bar implementation will pass in a [colorTransitionFraction] read from the + * [TopAppBarState.collapsedFraction] or the [TopAppBarState.overlappedFraction]. * * @param colorTransitionFraction a `0.0` to `1.0` value that represents a color transition - * percentage + * percentage */ @Stable fun containerColor(colorTransitionFraction: Float): Color { @@ -233,29 +236,35 @@ private fun SingleRowTopAppBar( colors: TopAppBarColors, ) { // Wrap the given actions in a Row. - val actionsRow = @Composable { - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - content = actions - ) - } + val actionsRow = + @Composable { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions + ) + } // Compose a Surface with a TopAppBarLayout content. - Surface(color = colors.scrolledContainerColor) { + Box( + modifier = + Modifier.drawBehind { drawRect(color = colors.scrolledContainerColor) } + .semantics { isTraversalGroup = true } + .pointerInput(Unit) {} + ) { val height = LocalDensity.current.run { ContainerHeight.toPx() } TopAppBarLayout( - modifier = Modifier - .windowInsetsPadding(windowInsets) - // clip after padding so we don't show the title over the inset area - .clipToBounds(), + modifier = + Modifier.windowInsetsPadding(windowInsets) + // clip after padding so we don't show the title over the inset area + .clipToBounds(), heightPx = height, navigationIconContentColor = colors.navigationIconContentColor, titleContentColor = colors.titleContentColor, actionIconContentColor = colors.actionIconContentColor, title = title, titleTextStyle = titleTextStyle, - titleAlpha = 1f, + titleAlpha = { 1f }, titleVerticalArrangement = Arrangement.Center, titleBottomPadding = 0, hideTitleSemantics = false, @@ -271,7 +280,7 @@ private fun SingleRowTopAppBar( * composables. * * @throws [IllegalArgumentException] if the given [MaxHeightWithoutTitle] is equal or smaller than - * the [pinnedHeight] + * the [pinnedHeight] */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -308,62 +317,67 @@ private fun TwoRowsTopAppBar( // Sets the app bar's height offset limit to hide just the bottom title area and keep top title // visible when collapsed. - SideEffect { - if (scrollBehavior?.state?.heightOffsetLimit != pinnedHeightPx - maxHeightPx.floatValue) { - scrollBehavior?.state?.heightOffsetLimit = pinnedHeightPx - maxHeightPx.floatValue - } - } + scrollBehavior?.state?.heightOffsetLimit = pinnedHeightPx - maxHeightPx.floatValue // Obtain the container Color from the TopAppBarColors using the `collapsedFraction`, as the // bottom part of this TwoRowsTopAppBar changes color at the same rate the app bar expands or // collapse. // This will potentially animate or interpolate a transition between the container color and the // container's scrolled color according to the app bar's scroll state. - val colorTransitionFraction = scrollBehavior?.state?.collapsedFraction ?: 0f - val appBarContainerColor = colors.containerColor(colorTransitionFraction) + val colorTransitionFraction = { scrollBehavior?.state?.collapsedFraction ?: 0f } + val appBarContainerColor = { colors.containerColor(colorTransitionFraction()) } // Wrap the given actions in a Row. - val actionsRow = @Composable { - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - content = actions - ) - } - val topTitleAlpha = TopTitleAlphaEasing.transform(colorTransitionFraction) - val bottomTitleAlpha = 1f - colorTransitionFraction + val actionsRow = + @Composable { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions + ) + } + val topTitleAlpha = { TopTitleAlphaEasing.transform(colorTransitionFraction()) } + val bottomTitleAlpha = { 1f - colorTransitionFraction() } // Hide the top row title semantics when its alpha value goes below 0.5 threshold. // Hide the bottom row title semantics when the top title semantics are active. - val hideTopRowSemantics = colorTransitionFraction < 0.5f + val hideTopRowSemantics by + remember(colorTransitionFraction) { derivedStateOf { colorTransitionFraction() < 0.5f } } val hideBottomRowSemantics = !hideTopRowSemantics // Set up support for resizing the top app bar when vertically dragging the bar itself. - val appBarDragModifier = if (scrollBehavior != null && !scrollBehavior.isPinned) { - Modifier.draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - scrollBehavior.state.heightOffset += delta - }, - onDragStopped = { velocity -> - settleAppBar( - scrollBehavior.state, - velocity, - scrollBehavior.flingAnimationSpec, - scrollBehavior.snapAnimationSpec - ) - } - ) - } else { - Modifier - } + val appBarDragModifier = + if (scrollBehavior != null && !scrollBehavior.isPinned) { + Modifier.draggable( + orientation = Orientation.Vertical, + state = + rememberDraggableState { delta -> scrollBehavior.state.heightOffset += delta }, + onDragStopped = { velocity -> + settleAppBar( + scrollBehavior.state, + velocity, + scrollBehavior.flingAnimationSpec, + scrollBehavior.snapAnimationSpec + ) + } + ) + } else { + Modifier + } - Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) { + Box( + modifier = + modifier + .then(appBarDragModifier) + .drawBehind { drawRect(color = appBarContainerColor()) } + .semantics { isTraversalGroup = true } + .pointerInput(Unit) {} + ) { Column { TopAppBarLayout( - modifier = Modifier - .windowInsetsPadding(windowInsets) - // clip after padding so we don't show the title over the inset area - .clipToBounds(), + modifier = + Modifier.windowInsetsPadding(windowInsets) + // clip after padding so we don't show the title over the inset area + .clipToBounds(), heightPx = pinnedHeightPx, navigationIconContentColor = colors.navigationIconContentColor, titleContentColor = colors.titleContentColor, @@ -378,27 +392,37 @@ private fun TwoRowsTopAppBar( actions = actionsRow, ) TopAppBarLayout( - modifier = Modifier - // only apply the horizontal sides of the window insets padding, since the top - // padding will always be applied by the layout above - .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal)) - .clipToBounds(), - heightPx = maxHeightPx.floatValue - pinnedHeightPx + - (scrollBehavior?.state?.heightOffset ?: 0f), + modifier = + Modifier + // only apply the horizontal sides of the window insets padding, since the + // top + // padding will always be applied by the layout above + .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal)) + .clipToBounds(), + heightPx = + maxHeightPx.floatValue - pinnedHeightPx + + (scrollBehavior?.state?.heightOffset ?: 0f), navigationIconContentColor = colors.navigationIconContentColor, titleContentColor = colors.titleContentColor, actionIconContentColor = colors.actionIconContentColor, title = { - Box(modifier = Modifier.onGloballyPositioned { coordinates -> - val measuredMaxHeightPx = density.run { - MaxHeightWithoutTitle.toPx() + coordinates.size.height.toFloat() - } - // Allow larger max height for multi-line title, but do not reduce - // max height to prevent flaky. - if (measuredMaxHeightPx > defaultMaxHeightPx) { - maxHeightPx.floatValue = measuredMaxHeightPx - } - }) { title() } + Box( + modifier = + Modifier.onGloballyPositioned { coordinates -> + val measuredMaxHeightPx = + density.run { + MaxHeightWithoutTitle.toPx() + + coordinates.size.height.toFloat() + } + // Allow larger max height for multi-line title, but do not reduce + // max height to prevent flaky. + if (measuredMaxHeightPx > defaultMaxHeightPx) { + maxHeightPx.floatValue = measuredMaxHeightPx + } + } + ) { + title() + } }, titleTextStyle = titleTextStyle, titleAlpha = bottomTitleAlpha, @@ -420,21 +444,21 @@ private fun TwoRowsTopAppBar( * @param modifier a [Modifier] * @param heightPx the total height this layout is capped to * @param navigationIconContentColor the content color that will be applied via a - * [LocalContentColor] when composing the navigation icon + * [LocalContentColor] when composing the navigation icon * @param titleContentColor the color that will be applied via a [LocalContentColor] when composing - * the title + * the title * @param actionIconContentColor the content color that will be applied via a [LocalContentColor] - * when composing the action icons + * when composing the action icons * @param title the top app bar title (header) * @param titleTextStyle the title's text style * @param modifier a [Modifier] * @param titleAlpha the title's alpha * @param titleVerticalArrangement the title's vertical arrangement * @param titleBottomPadding the title's bottom padding - * @param hideTitleSemantics hides the title node from the semantic tree. Apply this - * boolean when this layout is part of a [TwoRowsTopAppBar] to hide the title's semantics - * from accessibility services. This is needed to avoid having multiple titles visible to - * accessibility services at the same time, when animating between collapsed / expanded states. + * @param hideTitleSemantics hides the title node from the semantic tree. Apply this boolean when + * this layout is part of a [TwoRowsTopAppBar] to hide the title's semantics from accessibility + * services. This is needed to avoid having multiple titles visible to accessibility services at + * the same time, when animating between collapsed / expanded states. * @param navigationIcon a navigation icon [Composable] * @param actions actions [Composable] * @param titleScaleDisabled whether the title font scaling is disabled. Default is disabled. @@ -448,7 +472,7 @@ private fun TopAppBarLayout( actionIconContentColor: Color, title: @Composable () -> Unit, titleTextStyle: TextStyle, - titleAlpha: Float, + titleAlpha: () -> Float, titleVerticalArrangement: Arrangement.Vertical, titleBottomPadding: Int, hideTitleSemantics: Boolean, @@ -458,41 +482,33 @@ private fun TopAppBarLayout( ) { Layout( { - Box( - Modifier - .layoutId("navigationIcon") - .padding(start = TopAppBarHorizontalPadding) - ) { + Box(Modifier.layoutId("navigationIcon").padding(start = TopAppBarHorizontalPadding)) { CompositionLocalProvider( LocalContentColor provides navigationIconContentColor, content = navigationIcon ) } Box( - Modifier - .layoutId("title") + Modifier.layoutId("title") .padding(horizontal = TopAppBarHorizontalPadding) - .then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier) - .graphicsLayer(alpha = titleAlpha) + .then(if (hideTitleSemantics) Modifier.clearAndSetSemantics {} else Modifier) + .graphicsLayer { alpha = titleAlpha() } ) { ProvideTextStyle(value = titleTextStyle) { CompositionLocalProvider( LocalContentColor provides titleContentColor, - LocalDensity provides with(LocalDensity.current) { - Density( - density = density, - fontScale = if (titleScaleDisabled) 1f else fontScale, - ) - }, + LocalDensity provides + with(LocalDensity.current) { + Density( + density = density, + fontScale = if (titleScaleDisabled) 1f else fontScale, + ) + }, content = title ) } } - Box( - Modifier - .layoutId("actionIcons") - .padding(end = TopAppBarHorizontalPadding) - ) { + Box(Modifier.layoutId("actionIcons").padding(end = TopAppBarHorizontalPadding)) { CompositionLocalProvider( LocalContentColor provides actionIconContentColor, content = actions @@ -502,20 +518,24 @@ private fun TopAppBarLayout( modifier = modifier ) { measurables, constraints -> val navigationIconPlaceable = - measurables.first { it.layoutId == "navigationIcon" } + measurables + .first { it.layoutId == "navigationIcon" } .measure(constraints.copy(minWidth = 0)) val actionIconsPlaceable = - measurables.first { it.layoutId == "actionIcons" } + measurables + .first { it.layoutId == "actionIcons" } .measure(constraints.copy(minWidth = 0)) - val maxTitleWidth = if (constraints.maxWidth == Constraints.Infinity) { - constraints.maxWidth - } else { - (constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width) - .coerceAtLeast(0) - } + val maxTitleWidth = + if (constraints.maxWidth == Constraints.Infinity) { + constraints.maxWidth + } else { + (constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width) + .coerceAtLeast(0) + } val titlePlaceable = - measurables.first { it.layoutId == "title" } + measurables + .first { it.layoutId == "title" } .measure(constraints.copy(minWidth = 0, maxWidth = maxTitleWidth)) // Locate the title's baseline. @@ -538,19 +558,23 @@ private fun TopAppBarLayout( // Title titlePlaceable.placeRelative( x = max(TopAppBarTitleInset.roundToPx(), navigationIconPlaceable.width), - y = when (titleVerticalArrangement) { - Arrangement.Center -> (layoutHeight - titlePlaceable.height) / 2 - // Apply bottom padding from the title's baseline only when the Arrangement is - // "Bottom". - Arrangement.Bottom -> - if (titleBottomPadding == 0) layoutHeight - titlePlaceable.height - else layoutHeight - titlePlaceable.height - max( - 0, - titleBottomPadding - titlePlaceable.height + titleBaseline - ) - // Arrangement.Top - else -> 0 - } + y = + when (titleVerticalArrangement) { + Arrangement.Center -> (layoutHeight - titlePlaceable.height) / 2 + // Apply bottom padding from the title's baseline only when the Arrangement + // is "Bottom". + Arrangement.Bottom -> + if (titleBottomPadding == 0) layoutHeight - titlePlaceable.height + else + layoutHeight - + titlePlaceable.height - + max( + 0, + titleBottomPadding - titlePlaceable.height + titleBaseline + ) + // Arrangement.Top + else -> 0 + } ) // Action icons @@ -562,7 +586,6 @@ private fun TopAppBarLayout( } } - /** * Settles the app bar by flinging, in case the given velocity is greater than zero, and snapping * after the fling settles. @@ -587,9 +610,9 @@ private suspend fun settleAppBar( if (flingAnimationSpec != null && abs(velocity) > 1f) { var lastValue = 0f AnimationState( - initialValue = 0f, - initialVelocity = velocity, - ) + initialValue = 0f, + initialVelocity = velocity, + ) .animateDecay(flingAnimationSpec) { val delta = value - lastValue val initialHeightOffset = state.heightOffset @@ -603,9 +626,7 @@ private suspend fun settleAppBar( } // Snap if animation specs were provided. if (snapAnimationSpec != null) { - if (state.heightOffset < 0 && - state.heightOffset > state.heightOffsetLimit - ) { + if (state.heightOffset < 0 && state.heightOffset > state.heightOffsetLimit) { AnimationState(initialValue = state.heightOffset).animateTo( if (state.collapsedFraction < 0.5f) { 0f @@ -613,7 +634,9 @@ private suspend fun settleAppBar( state.heightOffsetLimit }, animationSpec = snapAnimationSpec - ) { state.heightOffset = value } + ) { + state.heightOffset = value + } } } diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBarTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBarTest.kt index ea69eabbe971..0a4f0d937600 100644 --- a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBarTest.kt +++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBarTest.kt @@ -37,15 +37,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo -import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo import androidx.compose.ui.test.assertWidthIsEqualTo -import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -53,33 +49,24 @@ 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.dp -import androidx.compose.ui.unit.height -import androidx.compose.ui.unit.width import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.settingslib.spa.testutils.rootWidth import com.android.settingslib.spa.testutils.setContentForSizeAssertions import com.google.common.truth.Truth.assertThat -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @OptIn(ExperimentalMaterial3Api::class) -@Ignore("b/346785755") @RunWith(AndroidJUnit4::class) class CustomizedAppBarTest { - @get:Rule - val rule = createComposeRule() + @get:Rule val rule = createComposeRule() @Test fun smallTopAppBar_expandsToScreen() { rule - .setContentForSizeAssertions { - CustomizedTopAppBar(title = { Text("Title") }) - } + .setContentForSizeAssertions { CustomizedTopAppBar(title = { Text("Title") }) } .assertHeightIsEqualTo(ContainerHeight) .assertWidthIsEqualTo(rule.rootWidth()) } @@ -88,51 +75,12 @@ class CustomizedAppBarTest { fun smallTopAppBar_withTitle() { val title = "Title" rule.setContent { - Box(Modifier.testTag(TopAppBarTestTag)) { - CustomizedTopAppBar(title = { Text(title) }) - } + Box(Modifier.testTag(TopAppBarTestTag)) { CustomizedTopAppBar(title = { Text(title) }) } } rule.onNodeWithText(title).assertIsDisplayed() } @Test - fun smallTopAppBar_default_positioning() { - rule.setContent { - Box(Modifier.testTag(TopAppBarTestTag)) { - CustomizedTopAppBar( - navigationIcon = { - FakeIcon(Modifier.testTag(NavigationIconTestTag)) - }, - title = { - Text("Title", Modifier.testTag(TitleTestTag)) - }, - actions = { - FakeIcon(Modifier.testTag(ActionsTestTag)) - } - ) - } - } - assertSmallDefaultPositioning() - } - - @Test - fun smallTopAppBar_noNavigationIcon_positioning() { - rule.setContent { - Box(Modifier.testTag(TopAppBarTestTag)) { - CustomizedTopAppBar( - title = { - Text("Title", Modifier.testTag(TitleTestTag)) - }, - actions = { - FakeIcon(Modifier.testTag(ActionsTestTag)) - } - ) - } - } - assertSmallPositioningWithoutNavigation() - } - - @Test fun smallTopAppBar_titleDefaultStyle() { var textStyle: TextStyle? = null var expectedTextStyle: TextStyle? = null @@ -188,29 +136,6 @@ class CustomizedAppBarTest { assertThat(actionsColor).isEqualTo(expectedActionsColor) } - @Test - fun largeTopAppBar_scrolled_positioning() { - val content = @Composable { scrollBehavior: TopAppBarScrollBehavior? -> - Box(Modifier.testTag(TopAppBarTestTag)) { - CustomizedLargeTopAppBar( - navigationIcon = { - FakeIcon(Modifier.testTag(NavigationIconTestTag)) - }, - title = "Title", - actions = { - FakeIcon(Modifier.testTag(ActionsTestTag)) - }, - scrollBehavior = scrollBehavior, - ) - } - } - assertLargeScrolledHeight( - MaxHeightWithoutTitle + DefaultTitleHeight, - MaxHeightWithoutTitle + DefaultTitleHeight, - content, - ) - } - @OptIn(ExperimentalMaterial3Api::class) @Test fun topAppBar_enterAlways_allowHorizontalScroll() { @@ -221,14 +146,10 @@ class CustomizedAppBarTest { } rule.onNodeWithTag(LazyListTag).performTouchInput { swipeLeft() } - rule.runOnIdle { - assertThat(state.firstVisibleItemIndex).isEqualTo(1) - } + rule.runOnIdle { assertThat(state.firstVisibleItemIndex).isEqualTo(1) } rule.onNodeWithTag(LazyListTag).performTouchInput { swipeRight() } - rule.runOnIdle { - assertThat(state.firstVisibleItemIndex).isEqualTo(0) - } + rule.runOnIdle { assertThat(state.firstVisibleItemIndex).isEqualTo(0) } } @OptIn(ExperimentalMaterial3Api::class) @@ -241,14 +162,10 @@ class CustomizedAppBarTest { } rule.onNodeWithTag(LazyListTag).performTouchInput { swipeLeft() } - rule.runOnIdle { - assertThat(state.firstVisibleItemIndex).isEqualTo(1) - } + rule.runOnIdle { assertThat(state.firstVisibleItemIndex).isEqualTo(1) } rule.onNodeWithTag(LazyListTag).performTouchInput { swipeRight() } - rule.runOnIdle { - assertThat(state.firstVisibleItemIndex).isEqualTo(0) - } + rule.runOnIdle { assertThat(state.firstVisibleItemIndex).isEqualTo(0) } } @OptIn(ExperimentalMaterial3Api::class) @@ -257,21 +174,14 @@ class CustomizedAppBarTest { lateinit var state: LazyListState rule.setContent { state = rememberLazyListState() - MultiPageContent( - TopAppBarDefaults.pinnedScrollBehavior(), - state - ) + MultiPageContent(TopAppBarDefaults.pinnedScrollBehavior(), state) } rule.onNodeWithTag(LazyListTag).performTouchInput { swipeLeft() } - rule.runOnIdle { - assertThat(state.firstVisibleItemIndex).isEqualTo(1) - } + rule.runOnIdle { assertThat(state.firstVisibleItemIndex).isEqualTo(1) } rule.onNodeWithTag(LazyListTag).performTouchInput { swipeRight() } - rule.runOnIdle { - assertThat(state.firstVisibleItemIndex).isEqualTo(0) - } + rule.runOnIdle { assertThat(state.firstVisibleItemIndex).isEqualTo(0) } } @OptIn(ExperimentalMaterial3Api::class) @@ -285,11 +195,7 @@ class CustomizedAppBarTest { ) } ) { contentPadding -> - LazyRow( - Modifier - .fillMaxSize() - .testTag(LazyListTag), state - ) { + LazyRow(Modifier.fillMaxSize().testTag(LazyListTag), state) { items(2) { page -> LazyColumn( modifier = Modifier.fillParentMaxSize(), @@ -308,146 +214,19 @@ class CustomizedAppBarTest { } /** - * Checks the app bar's components positioning when it's a [CustomizedTopAppBar] - * 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 [CustomizedTopAppBar]. - */ - 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 that changing values at a [CustomizedLargeTopAppBar] 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 CustomizedLargeTopAppBar - */ - @OptIn(ExperimentalMaterial3Api::class) - private fun assertLargeScrolledHeight( - appBarMaxHeight: Dp, - appBarMinHeight: Dp, - content: @Composable (TopAppBarScrollBehavior?) -> Unit - ) { - val fullyCollapsedOffsetDp = appBarMaxHeight - appBarMinHeight - val partiallyCollapsedOffsetDp = fullyCollapsedOffsetDp / 3 - var partiallyCollapsedHeightOffsetPx = 0f - var fullyCollapsedHeightOffsetPx = 0f - lateinit var scrollBehavior: TopAppBarScrollBehavior - rule.setContent { - 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 - ) - - // 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) - } - - /** * 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 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 = (ContainerHeight - FakeIconSize) / 2 private val LazyListTag = "lazyList" private val TopAppBarTestTag = "bar" |