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 e07a6298a627b..90a723f707da3 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 67c4cdcc12908..67f4418b7e4ce 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 0000000000000..a6a5ed229756d --- /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 a5d1f402e0226..0436fc275b60a 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) +}