| /* |
| * 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.background |
| 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.layout.fillMaxWidth |
| import androidx.compose.foundation.layout.height |
| import androidx.compose.foundation.layout.requiredSize |
| import androidx.compose.foundation.layout.size |
| import androidx.compose.foundation.layout.windowInsetsPadding |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.draw.shadow |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.graphics.asAndroidBitmap |
| import androidx.compose.ui.layout.LayoutCoordinates |
| import androidx.compose.ui.layout.LookaheadScope |
| import androidx.compose.ui.layout.SubcomposeLayout |
| import androidx.compose.ui.layout.onGloballyPositioned |
| import androidx.compose.ui.layout.onSizeChanged |
| import androidx.compose.ui.layout.positionInParent |
| import androidx.compose.ui.layout.positionInRoot |
| 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.assertWidthIsEqualTo |
| import androidx.compose.ui.test.captureToImage |
| import androidx.compose.ui.test.junit4.createComposeRule |
| import androidx.compose.ui.test.onNodeWithTag |
| import androidx.compose.ui.unit.Density |
| import androidx.compose.ui.unit.Dp |
| import androidx.compose.ui.unit.IntSize |
| import androidx.compose.ui.unit.dp |
| import androidx.compose.ui.unit.toSize |
| import androidx.compose.ui.zIndex |
| 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 com.google.common.truth.Truth.assertWithMessage |
| import kotlin.math.roundToInt |
| import org.junit.Rule |
| import org.junit.Test |
| import org.junit.runner.RunWith |
| |
| @MediumTest |
| @RunWith(AndroidJUnit4::class) |
| class ScaffoldTest { |
| |
| @get:Rule |
| val rule = createComposeRule() |
| |
| private val scaffoldTag = "Scaffold" |
| private val roundingError = 0.5.dp |
| private val fabSpacing = 16.dp |
| |
| @Test |
| fun scaffold_onlyContent_takesWholeScreen() { |
| rule.setMaterialContentForSizeAssertions( |
| parentMaxWidth = 100.dp, |
| parentMaxHeight = 100.dp |
| ) { |
| Scaffold { |
| Text("Scaffold body") |
| } |
| } |
| .assertWidthIsEqualTo(100.dp) |
| .assertHeightIsEqualTo(100.dp) |
| } |
| |
| @Test |
| fun scaffold_onlyContent_stackSlot() { |
| var child1: Offset = Offset.Zero |
| var child2: Offset = Offset.Zero |
| rule.setMaterialContent(lightColorScheme()) { |
| Scaffold { |
| Text( |
| "One", |
| Modifier.onGloballyPositioned { child1 = it.positionInParent() } |
| ) |
| Text( |
| "Two", |
| Modifier.onGloballyPositioned { child2 = it.positionInParent() } |
| ) |
| } |
| } |
| assertThat(child1.y).isEqualTo(child2.y) |
| assertThat(child1.x).isEqualTo(child2.x) |
| } |
| |
| @Test |
| fun scaffold_AppbarAndContent_inColumn() { |
| var scaffoldSize: IntSize = IntSize.Zero |
| var appbarPosition: Offset = Offset.Zero |
| var contentPosition: Offset = Offset.Zero |
| var contentSize: IntSize = IntSize.Zero |
| rule.setMaterialContent(lightColorScheme()) { |
| Scaffold( |
| topBar = { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(50.dp) |
| .background(color = Color.Red) |
| .onGloballyPositioned { positioned: LayoutCoordinates -> |
| appbarPosition = positioned.localToWindow(Offset.Zero) |
| } |
| ) |
| }, |
| modifier = Modifier |
| .onGloballyPositioned { positioned: LayoutCoordinates -> |
| scaffoldSize = positioned.size |
| } |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .background(Color.Blue) |
| .onGloballyPositioned { positioned: LayoutCoordinates -> |
| contentPosition = positioned.positionInParent() |
| contentSize = positioned.size |
| } |
| ) |
| } |
| } |
| assertThat(appbarPosition.y).isEqualTo(contentPosition.y) |
| assertThat(scaffoldSize).isEqualTo(contentSize) |
| } |
| |
| @Test |
| fun scaffold_bottomBarAndContent_inStack() { |
| var scaffoldSize: IntSize = IntSize.Zero |
| var appbarPosition: Offset = Offset.Zero |
| var appbarSize: IntSize = IntSize.Zero |
| var contentPosition: Offset = Offset.Zero |
| var contentSize: IntSize = IntSize.Zero |
| rule.setMaterialContent(lightColorScheme()) { |
| Scaffold( |
| bottomBar = { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(50.dp) |
| .background(color = Color.Red) |
| .onGloballyPositioned { positioned: LayoutCoordinates -> |
| appbarPosition = positioned.positionInParent() |
| appbarSize = positioned.size |
| } |
| ) |
| }, |
| modifier = Modifier |
| .onGloballyPositioned { positioned: LayoutCoordinates -> |
| scaffoldSize = positioned.size |
| } |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .background(color = Color.Blue) |
| .onGloballyPositioned { positioned: LayoutCoordinates -> |
| contentPosition = positioned.positionInParent() |
| contentSize = positioned.size |
| } |
| ) |
| } |
| } |
| val appBarBottom = appbarPosition.y + appbarSize.height |
| val contentBottom = contentPosition.y + contentSize.height |
| assertThat(appBarBottom).isEqualTo(contentBottom) |
| assertThat(scaffoldSize).isEqualTo(contentSize) |
| } |
| |
| @Test |
| fun scaffold_innerPadding_lambdaParam() { |
| var topBarSize: IntSize = IntSize.Zero |
| var bottomBarSize: IntSize = IntSize.Zero |
| lateinit var innerPadding: PaddingValues |
| |
| rule.setContent { |
| Scaffold( |
| topBar = { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(50.dp) |
| .background(color = Color.Red) |
| .onGloballyPositioned { positioned: LayoutCoordinates -> |
| topBarSize = positioned.size |
| } |
| ) |
| }, |
| bottomBar = { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(100.dp) |
| .background(color = Color.Red) |
| .onGloballyPositioned { positioned: LayoutCoordinates -> |
| bottomBarSize = positioned.size |
| } |
| ) |
| } |
| ) { |
| innerPadding = it |
| Text("body") |
| } |
| } |
| rule.runOnIdle { |
| with(rule.density) { |
| assertThat(innerPadding.calculateTopPadding()) |
| .isEqualTo(topBarSize.toSize().height.toDp()) |
| assertThat(innerPadding.calculateBottomPadding()) |
| .isEqualTo(bottomBarSize.toSize().height.toDp()) |
| } |
| } |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun scaffold_topAppBarIsDrawnOnTopOfContent() { |
| rule.setContent { |
| Box( |
| Modifier |
| .requiredSize(10.dp, 20.dp) |
| .semantics(mergeDescendants = true) {} |
| .testTag(scaffoldTag) |
| ) { |
| Scaffold( |
| topBar = { |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .shadow(4.dp) |
| .zIndex(4f) |
| .background(color = Color.White) |
| ) |
| } |
| ) { |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| |
| rule.onNodeWithTag(scaffoldTag) |
| .captureToImage().asAndroidBitmap().apply { |
| // asserts the appbar(top half part) has the shadow |
| val yPos = height / 2 + 2 |
| assertThat(Color(getPixel(0, yPos))).isNotEqualTo(Color.White) |
| assertThat(Color(getPixel(width / 2, yPos))).isNotEqualTo(Color.White) |
| assertThat(Color(getPixel(width - 1, yPos))).isNotEqualTo(Color.White) |
| } |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun scaffold_providesInsets_respectTopAppBar() { |
| rule.setContent { |
| Box(Modifier.requiredSize(10.dp, 40.dp)) { |
| Scaffold( |
| contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp), |
| topBar = { |
| Box(Modifier.requiredSize(10.dp)) |
| } |
| ) { paddingValues -> |
| // top is like top app bar + rounding error |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateTopPadding(), |
| expected = 10.dp, |
| threshold = roundingError |
| ) |
| // bottom is like the insets |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateBottomPadding(), |
| expected = 3.dp, |
| threshold = roundingError |
| ) |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun scaffold_respectsProvidedInsets() { |
| rule.setContent { |
| Box(Modifier.requiredSize(10.dp, 40.dp)) { |
| Scaffold( |
| contentWindowInsets = WindowInsets(top = 15.dp, bottom = 10.dp), |
| ) { paddingValues -> |
| // topPadding is equal to provided top window inset |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateTopPadding(), |
| expected = 15.dp, |
| threshold = roundingError |
| ) |
| // bottomPadding is equal to provided bottom window inset |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateBottomPadding(), |
| expected = 10.dp, |
| threshold = roundingError |
| ) |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun scaffold_respectsConsumedWindowInsets() { |
| rule.setContent { |
| Box( |
| Modifier |
| .requiredSize(10.dp, 40.dp) |
| .windowInsetsPadding(WindowInsets(top = 10.dp, bottom = 10.dp)) |
| ) { |
| Scaffold( |
| contentWindowInsets = WindowInsets(top = 15.dp, bottom = 15.dp) |
| ) { paddingValues -> |
| // Consumed windowInsetsPadding is omitted. This replicates behavior from |
| // Modifier.windowInsetsPadding. (15.dp contentPadding - 10.dp consumedPadding) |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateTopPadding(), |
| expected = 5.dp, |
| threshold = roundingError |
| ) |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateBottomPadding(), |
| expected = 5.dp, |
| threshold = roundingError |
| ) |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun scaffold_providesInsets_respectCollapsedTopAppBar() { |
| rule.setContent { |
| Box(Modifier.requiredSize(10.dp, 40.dp)) { |
| Scaffold( |
| contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp), |
| topBar = { |
| Box(Modifier.requiredSize(0.dp)) |
| } |
| ) { paddingValues -> |
| // top is like the collapsed top app bar (i.e. 0dp) + rounding error |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateTopPadding(), |
| expected = 0.dp, |
| threshold = roundingError |
| ) |
| // bottom is like the insets |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateBottomPadding(), |
| expected = 3.dp, |
| threshold = roundingError |
| ) |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun scaffold_providesInsets_respectsBottomAppBar() { |
| rule.setContent { |
| Box(Modifier.requiredSize(10.dp, 40.dp)) { |
| Scaffold( |
| contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp), |
| bottomBar = { |
| Box(Modifier.requiredSize(10.dp)) |
| } |
| ) { paddingValues -> |
| // bottom is like bottom app bar + rounding error |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateBottomPadding(), |
| expected = 10.dp, |
| threshold = roundingError |
| ) |
| // top is like the insets |
| assertDpIsWithinThreshold( |
| actual = paddingValues.calculateTopPadding(), |
| expected = 5.dp, |
| threshold = roundingError |
| ) |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun scaffold_insetsTests_snackbarRespectsInsets() { |
| val hostState = SnackbarHostState() |
| var snackbarSize: IntSize? = null |
| var snackbarPosition: Offset? = null |
| var density: Density? = null |
| rule.setContent { |
| Box(Modifier.requiredSize(10.dp, 40.dp)) { |
| density = LocalDensity.current |
| Scaffold( |
| contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp), |
| snackbarHost = { |
| SnackbarHost(hostState = hostState, |
| modifier = Modifier |
| .onGloballyPositioned { |
| snackbarSize = it.size |
| snackbarPosition = it.positionInRoot() |
| }) |
| } |
| ) { |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| val snackbarBottomOffsetDp = |
| with(density!!) { (snackbarPosition!!.y.roundToInt() + snackbarSize!!.height).toDp() } |
| assertThat(rule.rootHeight() - snackbarBottomOffsetDp - 3.dp).isLessThan(1.dp) |
| } |
| |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| @Test |
| fun scaffold_insetsTests_FabRespectsInsets() { |
| var fabSize: IntSize? = null |
| var fabPosition: Offset? = null |
| var density: Density? = null |
| rule.setContent { |
| Box(Modifier.requiredSize(10.dp, 20.dp)) { |
| density = LocalDensity.current |
| Scaffold( |
| contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp), |
| floatingActionButton = { |
| FloatingActionButton(onClick = {}, |
| modifier = Modifier |
| .onGloballyPositioned { |
| fabSize = it.size |
| fabPosition = it.positionInRoot() |
| }) { |
| Text("Fab") |
| } |
| }, |
| ) { |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| val fabBottomOffsetDp = |
| with(density!!) { (fabPosition!!.y.roundToInt() + fabSize!!.height).toDp() } |
| assertThat(rule.rootHeight() - fabBottomOffsetDp - 3.dp).isLessThan(1.dp) |
| } |
| |
| @Test |
| fun scaffold_fabPosition_start() { |
| var fabSize: IntSize? = null |
| var fabPosition: Offset? = null |
| rule.setContent { |
| Box(Modifier.requiredSize(200.dp, 200.dp)) { |
| Scaffold( |
| floatingActionButton = { |
| FloatingActionButton( |
| onClick = {}, |
| modifier = Modifier |
| .onGloballyPositioned { |
| fabSize = it.size |
| fabPosition = it.positionInRoot() |
| }) { |
| Text("Fab") |
| } |
| }, |
| floatingActionButtonPosition = FabPosition.Start, |
| ) { |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| with(rule.density) { |
| assertThat(fabPosition!!.x).isWithin(1f).of(fabSpacing.toPx()) |
| assertThat(fabPosition!!.y).isWithin(1f).of( |
| 200.dp.toPx() - fabSize!!.height - fabSpacing.toPx() |
| ) |
| } |
| } |
| |
| @Test |
| fun scaffold_fabPosition_center() { |
| var fabSize: IntSize? = null |
| var fabPosition: Offset? = null |
| rule.setContent { |
| Box(Modifier.requiredSize(200.dp, 200.dp)) { |
| Scaffold( |
| floatingActionButton = { |
| FloatingActionButton( |
| onClick = {}, |
| modifier = Modifier |
| .onGloballyPositioned { |
| fabSize = it.size |
| fabPosition = it.positionInRoot() |
| }) { |
| Text("Fab") |
| } |
| }, |
| floatingActionButtonPosition = FabPosition.Center, |
| ) { |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| with(rule.density) { |
| assertThat(fabPosition!!.x).isWithin(1f).of( |
| (200.dp.toPx() - fabSize!!.width) / 2f |
| ) |
| assertThat(fabPosition!!.y).isWithin(1f).of( |
| 200.dp.toPx() - fabSize!!.height - fabSpacing.toPx() |
| ) |
| } |
| } |
| |
| @Test |
| fun scaffold_fabPosition_end() { |
| var fabSize: IntSize? = null |
| var fabPosition: Offset? = null |
| rule.setContent { |
| Box(Modifier.requiredSize(200.dp, 200.dp)) { |
| Scaffold( |
| floatingActionButton = { |
| FloatingActionButton( |
| onClick = {}, |
| modifier = Modifier |
| .onGloballyPositioned { |
| fabSize = it.size |
| fabPosition = it.positionInRoot() |
| }) { |
| Text("Fab") |
| } |
| }, |
| floatingActionButtonPosition = FabPosition.End, |
| ) { |
| Box( |
| Modifier |
| .requiredSize(10.dp) |
| .background(color = Color.White) |
| ) |
| } |
| } |
| } |
| with(rule.density) { |
| assertThat(fabPosition!!.x).isWithin(1f).of( |
| 200.dp.toPx() - fabSize!!.width - fabSpacing.toPx() |
| ) |
| assertThat(fabPosition!!.y).isWithin(1f).of( |
| 200.dp.toPx() - fabSize!!.height - fabSpacing.toPx() |
| ) |
| } |
| } |
| |
| // Regression test for b/295536718 |
| @Test |
| fun scaffold_onSizeChanged_calledBeforeLookaheadPlace() { |
| var size: IntSize? = null |
| var onSizeChangedCount = 0 |
| var onPlaceCount = 0 |
| |
| rule.setContent { |
| LookaheadScope { |
| Scaffold { |
| SubcomposeLayout { constraints -> |
| val measurables = subcompose("second") { |
| Box( |
| Modifier |
| .size(45.dp) |
| .onSizeChanged { |
| onSizeChangedCount++ |
| size = it |
| } |
| ) |
| } |
| val placeables = measurables.map { it.measure(constraints) } |
| |
| layout(constraints.maxWidth, constraints.maxHeight) { |
| onPlaceCount++ |
| assertWithMessage("Expected onSizeChangedCount to be >= 1") |
| .that(onSizeChangedCount).isAtLeast(1) |
| assertThat(size).isNotNull() |
| placeables.forEach { it.place(0, 0) } |
| } |
| } |
| } |
| } |
| } |
| |
| assertWithMessage("Expected placeCount to be >= 1").that(onPlaceCount).isAtLeast(1) |
| } |
| |
| private fun assertDpIsWithinThreshold(actual: Dp, expected: Dp, threshold: Dp) { |
| assertThat(actual.value).isWithin(threshold.value).of(expected.value) |
| } |
| } |