diff options
author | 2023-06-24 00:08:04 +0800 | |
---|---|---|
committer | 2023-06-26 12:15:50 +0800 | |
commit | e055bf63f769a955258931a665f6b4f542de1986 (patch) | |
tree | a8a13fc998c9c7bb37d69275d832734cb6e71e5c | |
parent | 11b85acc51705834b77a2c55da023580b9751c66 (diff) |
Fix SearchScaffold flaky when close search box
AppBar's max height is calculated based on title's size, when the search
box is closing, this calculated value could be changed to smaller value
than default, which cause flaky.
Not set max height value less than default to fix this issue.
Also import the following upstream change,
https://r.android.com/2617890 Guard against NaN at the TopAppBarLayout
Bug: 284913888
Test: manual - click search, then close, observe
Test: unit test
Change-Id: If733535487da46cc15abfaa453355acd9a172097
4 files changed, 531 insertions, 18 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 e07a6298a627..90a723f707da 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 @@ -50,7 +50,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment @@ -286,21 +286,22 @@ private fun TwoRowsTopAppBar( ) } val pinnedHeightPx: Float - val density = LocalDensity.current - val maxHeightPx = density.run { - remember { mutableStateOf((MaxHeightWithoutTitle + DefaultTitleHeight).toPx()) } - } val titleBottomPaddingPx: Int + val defaultMaxHeightPx: Float + val density = LocalDensity.current density.run { pinnedHeightPx = pinnedHeight.toPx() titleBottomPaddingPx = titleBottomPadding.roundToPx() + defaultMaxHeightPx = (MaxHeightWithoutTitle + DefaultTitleHeight).toPx() } + val maxHeightPx = remember(density) { mutableFloatStateOf(defaultMaxHeightPx) } + // 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.value) { - scrollBehavior?.state?.heightOffsetLimit = pinnedHeightPx - maxHeightPx.value + if (scrollBehavior?.state?.heightOffsetLimit != pinnedHeightPx - maxHeightPx.floatValue) { + scrollBehavior?.state?.heightOffsetLimit = pinnedHeightPx - maxHeightPx.floatValue } } @@ -370,16 +371,20 @@ private fun TwoRowsTopAppBar( ) TopAppBarLayout( modifier = Modifier.clipToBounds(), - heightPx = maxHeightPx.value - pinnedHeightPx + + heightPx = maxHeightPx.floatValue - pinnedHeightPx + (scrollBehavior?.state?.heightOffset ?: 0f), navigationIconContentColor = colors.navigationIconContentColor, titleContentColor = colors.titleContentColor, actionIconContentColor = colors.actionIconContentColor, title = { Box(modifier = Modifier.onGloballyPositioned { coordinates -> - density.run { - maxHeightPx.value = - MaxHeightWithoutTitle.toPx() + coordinates.size.height.toFloat() + 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() } }, @@ -506,7 +511,7 @@ private fun TopAppBarLayout( 0 } - val layoutHeight = heightPx.roundToInt() + val layoutHeight = if (heightPx.isNaN()) 0 else heightPx.roundToInt() layout(constraints.maxWidth, layoutHeight) { // Navigation icon @@ -612,9 +617,9 @@ private suspend fun settleAppBar( // Medium or Large app bar. private val TopTitleAlphaEasing = CubicBezierEasing(.8f, 0f, .8f, .15f) -private val MaxHeightWithoutTitle = 124.dp -private val DefaultTitleHeight = 52.dp -private val ContainerHeight = 56.dp +internal val MaxHeightWithoutTitle = 124.dp +internal val DefaultTitleHeight = 52.dp +internal val ContainerHeight = 56.dp private val LargeTitleBottomPadding = 28.dp private val TopAppBarHorizontalPadding = 4.dp diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SearchScaffold.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SearchScaffold.kt index 67c4cdcc1290..67f4418b7e4c 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SearchScaffold.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SearchScaffold.kt @@ -163,7 +163,6 @@ private fun SearchTopAppBar( BackHandler { onClose() } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SearchBox(query: TextFieldValue, onQueryChange: (TextFieldValue) -> Unit) { val focusRequester = remember { FocusRequester() } @@ -186,8 +185,9 @@ private fun SearchBox(query: TextFieldValue, onQueryChange: (TextFieldValue) -> keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { hideKeyboardAction() }), singleLine = true, - colors = TextFieldDefaults.textFieldColors( - containerColor = Color.Transparent, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), 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 new file mode 100644 index 000000000000..a6a5ed229756 --- /dev/null +++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBarTest.kt @@ -0,0 +1,457 @@ +/* + * Copyright 2023 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 com.android.settingslib.spa.widget.scaffold + +import androidx.compose.foundation.layout.Box +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.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +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 +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.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalMaterial3Api::class) +@RunWith(AndroidJUnit4::class) +class CustomizedAppBarTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun smallTopAppBar_expandsToScreen() { + rule + .setContentForSizeAssertions { + CustomizedTopAppBar(title = { Text("Title") }) + } + .assertHeightIsEqualTo(ContainerHeight) + .assertWidthIsEqualTo(rule.rootWidth()) + } + + @Test + fun smallTopAppBar_withTitle() { + val title = "Title" + rule.setContent { + 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 + rule.setContent { + CustomizedTopAppBar( + title = { + Text("Title") + textStyle = LocalTextStyle.current + expectedTextStyle = MaterialTheme.typography.titleMedium + }, + ) + } + assertThat(textStyle).isNotNull() + assertThat(textStyle).isEqualTo(expectedTextStyle) + } + + @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 + + rule.setContent { + CustomizedTopAppBar( + navigationIcon = { + FakeIcon(Modifier.testTag(NavigationIconTestTag)) + navigationIconColor = LocalContentColor.current + expectedNavigationIconColor = + TopAppBarDefaults.topAppBarColors().navigationIconContentColor + // fraction = 0f to indicate no scroll. + }, + title = { + Text("Title", Modifier.testTag(TitleTestTag)) + titleColor = LocalContentColor.current + expectedTitleColor = TopAppBarDefaults.topAppBarColors().titleContentColor + }, + actions = { + FakeIcon(Modifier.testTag(ActionsTestTag)) + actionsColor = LocalContentColor.current + expectedActionsColor = + TopAppBarDefaults.topAppBarColors().actionIconContentColor + } + ) + } + assertThat(navigationIconColor).isNotNull() + assertThat(titleColor).isNotNull() + assertThat(actionsColor).isNotNull() + assertThat(navigationIconColor).isEqualTo(expectedNavigationIconColor) + assertThat(titleColor).isEqualTo(expectedTitleColor) + 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() { + lateinit var state: LazyListState + rule.setContent { + 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.setContent { + 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.setContent { + 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) + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun MultiPageContent(scrollBehavior: TopAppBarScrollBehavior, state: LazyListState) { + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + CustomizedTopAppBar( + title = { Text(text = "Title") }, + ) + } + ) { 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 [CustomizedTopAppBar], 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 [CustomizedTopAppBar] 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 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 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" + private val NavigationIconTestTag = "navigationIcon" + private val TitleTestTag = "title" + private val ActionsTestTag = "actions" +} diff --git a/packages/SettingsLib/Spa/testutils/src/com/android/settingslib/spa/testutils/ComposeContentTestRuleExt.kt b/packages/SettingsLib/Spa/testutils/src/com/android/settingslib/spa/testutils/ComposeContentTestRuleExt.kt index a5d1f402e022..0436fc275b60 100644 --- a/packages/SettingsLib/Spa/testutils/src/com/android/settingslib/spa/testutils/ComposeContentTestRuleExt.kt +++ b/packages/SettingsLib/Spa/testutils/src/com/android/settingslib/spa/testutils/ComposeContentTestRuleExt.kt @@ -16,13 +16,28 @@ package com.android.settingslib.spa.testutils +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +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 com.android.settingslib.spa.framework.theme.SettingsTheme /** Blocks until the found a semantics node that match the given condition. */ fun ComposeContentTestRule.waitUntilExists(matcher: SemanticsMatcher) = waitUntil { @@ -39,3 +54,39 @@ fun ComposeContentTestRule.delay(timeoutMillis: Long = 1_000) = try { /** Finds a text node that within dialog. */ fun ComposeContentTestRule.onDialogText(text: String): SemanticsNodeInteraction = onNode(hasAnyAncestor(isDialog()) and hasText(text)) + +fun ComposeTestRule.rootWidth(): Dp = onRoot().getUnclippedBoundsInRoot().width + +fun ComposeTestRule.rootHeight(): Dp = onRoot().getUnclippedBoundsInRoot().height + +/** + * Constant to emulate very big but finite constraints + */ +private val sizeAssertionMaxSize = 5000.dp + +private const val SIZE_ASSERTION_TAG = "containerForSizeAssertion" + +fun ComposeContentTestRule.setContentForSizeAssertions( + parentMaxWidth: Dp = sizeAssertionMaxSize, + parentMaxHeight: Dp = sizeAssertionMaxSize, + // TODO : figure out better way to make it flexible + content: @Composable () -> Unit +): SemanticsNodeInteraction { + setContent { + SettingsTheme { + Surface { + Box { + Box( + Modifier + .sizeIn(maxWidth = parentMaxWidth, maxHeight = parentMaxHeight) + .testTag(SIZE_ASSERTION_TAG) + ) { + content() + } + } + } + } + } + + return onNodeWithTag(SIZE_ASSERTION_TAG) +} |