| /* |
| * 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 androidx.compose.material3 |
| |
| import android.content.ComponentCallbacks2 |
| import android.content.pm.ActivityInfo |
| import android.content.res.Configuration |
| import android.os.Build.VERSION.SDK_INT |
| import androidx.activity.ComponentActivity |
| import androidx.activity.compose.LocalOnBackPressedDispatcherOwner |
| import androidx.compose.foundation.ScrollState |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.WindowInsets |
| import androidx.compose.foundation.layout.fillMaxHeight |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.layout.fillMaxWidth |
| import androidx.compose.foundation.layout.height |
| import androidx.compose.foundation.layout.requiredHeight |
| import androidx.compose.foundation.layout.size |
| import androidx.compose.foundation.lazy.LazyColumn |
| import androidx.compose.foundation.rememberScrollState |
| import androidx.compose.foundation.verticalScroll |
| import androidx.compose.runtime.CompositionLocalProvider |
| import androidx.compose.runtime.MutableState |
| import androidx.compose.runtime.derivedStateOf |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.rememberCoroutineScope |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.input.nestedscroll.NestedScrollConnection |
| import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher |
| import androidx.compose.ui.input.nestedscroll.NestedScrollSource |
| import androidx.compose.ui.input.nestedscroll.nestedScroll |
| import androidx.compose.ui.layout.onSizeChanged |
| import androidx.compose.ui.platform.LocalConfiguration |
| import androidx.compose.ui.platform.LocalContext |
| import androidx.compose.ui.platform.LocalDensity |
| import androidx.compose.ui.platform.LocalLayoutDirection |
| import androidx.compose.ui.platform.testTag |
| import androidx.compose.ui.semantics.SemanticsActions |
| import androidx.compose.ui.test.SemanticsMatcher |
| import androidx.compose.ui.test.assert |
| 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.isPopup |
| import androidx.compose.ui.test.junit4.createAndroidComposeRule |
| import androidx.compose.ui.test.onFirst |
| import androidx.compose.ui.test.onNodeWithTag |
| import androidx.compose.ui.test.onParent |
| import androidx.compose.ui.test.performClick |
| import androidx.compose.ui.test.performSemanticsAction |
| import androidx.compose.ui.test.performTouchInput |
| import androidx.compose.ui.test.requestFocus |
| import androidx.compose.ui.test.swipeDown |
| import androidx.compose.ui.test.swipeUp |
| import androidx.compose.ui.unit.Dp |
| import androidx.compose.ui.unit.LayoutDirection |
| import androidx.compose.ui.unit.Velocity |
| import androidx.compose.ui.unit.coerceAtMost |
| import androidx.compose.ui.unit.dp |
| import androidx.compose.ui.unit.height |
| import androidx.compose.ui.unit.width |
| import androidx.test.filters.MediumTest |
| import androidx.test.platform.app.InstrumentationRegistry |
| import androidx.test.uiautomator.UiDevice |
| import com.google.common.truth.Truth.assertThat |
| import java.util.concurrent.CountDownLatch |
| import java.util.concurrent.TimeUnit |
| import junit.framework.TestCase.assertFalse |
| import junit.framework.TestCase.assertTrue |
| import junit.framework.TestCase.fail |
| import kotlinx.coroutines.CoroutineScope |
| import kotlinx.coroutines.launch |
| import kotlinx.coroutines.runBlocking |
| import org.junit.Assume |
| import org.junit.Rule |
| import org.junit.Test |
| import org.junit.runner.RunWith |
| import org.junit.runners.Parameterized |
| |
| @MediumTest |
| @RunWith(Parameterized::class) |
| @OptIn(ExperimentalMaterial3Api::class) |
| class ModalBottomSheetTest(private val edgeToEdgeWrapper: EdgeToEdgeWrapper) { |
| |
| @get:Rule |
| val rule = createAndroidComposeRule<ComponentActivity>() |
| |
| private val sheetHeight = 256.dp |
| private val dragHandleSize = 44.dp |
| |
| private val sheetTag = "sheetContentTag" |
| private val dragHandleTag = "dragHandleTag" |
| private val BackTestTag = "Back" |
| |
| @Test |
| fun modalBottomSheet_isDismissedOnTapOutside() { |
| var showBottomSheet by mutableStateOf(true) |
| val sheetState = SheetState(skipPartiallyExpanded = false, density = rule.density) |
| |
| rule.setContent { |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| if (showBottomSheet) { |
| ModalBottomSheet( |
| sheetState = sheetState, |
| onDismissRequest = { showBottomSheet = false }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .size(sheetHeight) |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| } |
| |
| assertThat(sheetState.isVisible).isTrue() |
| |
| // Tap Scrim |
| val outsideY = with(rule.density) { |
| rule.onAllNodes(isPopup()).onFirst().getUnclippedBoundsInRoot().height.roundToPx() / 4 |
| } |
| UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).click(0, outsideY) |
| rule.waitForIdle() |
| |
| // Bottom sheet should not exist |
| rule.onNodeWithTag(sheetTag).assertDoesNotExist() |
| } |
| |
| @Test |
| fun modalBottomSheet_isDismissedOnSwipeDown() { |
| var showBottomSheet by mutableStateOf(true) |
| val sheetState = SheetState(skipPartiallyExpanded = false, density = rule.density) |
| |
| rule.setContent { |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| if (showBottomSheet) { |
| ModalBottomSheet( |
| sheetState = sheetState, |
| onDismissRequest = { showBottomSheet = false }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .size(sheetHeight) |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| } |
| |
| assertThat(sheetState.isVisible).isTrue() |
| |
| // Swipe Down |
| rule.onNodeWithTag(sheetTag).performTouchInput { |
| swipeDown() |
| } |
| rule.waitForIdle() |
| |
| // Bottom sheet should not exist |
| rule.onNodeWithTag(sheetTag).assertDoesNotExist() |
| } |
| |
| @Test |
| fun modalBottomSheet_fillsScreenWidth() { |
| var boxWidth = 0 |
| var screenWidth by mutableStateOf(0) |
| |
| rule.setContent { |
| val context = LocalContext.current |
| screenWidth = context.resources.displayMetrics.widthPixels |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(sheetHeight) |
| .onSizeChanged { boxWidth = it.width } |
| ) |
| } |
| } |
| assertThat(boxWidth).isEqualTo(screenWidth) |
| } |
| |
| @Test |
| fun modalBottomSheet_wideScreen_fixedMaxWidth_sheetRespectsMaxWidthAndIsCentered() { |
| rule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE |
| val latch = CountDownLatch(1) |
| |
| rule.activity.application.registerComponentCallbacks(object : ComponentCallbacks2 { |
| override fun onConfigurationChanged(p0: Configuration) { |
| latch.countDown() |
| } |
| |
| override fun onLowMemory() { |
| // NO-OP |
| } |
| |
| override fun onTrimMemory(p0: Int) { |
| // NO-OP |
| } |
| }) |
| |
| try { |
| latch.await(1500, TimeUnit.MILLISECONDS) |
| rule.setContent { |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .testTag(sheetTag) |
| .fillMaxHeight(0.4f) |
| ) |
| } |
| } |
| |
| val simulatedRootWidth = rule.onNode(isPopup()).getUnclippedBoundsInRoot().width |
| val maxSheetWidth = 640.dp |
| val expectedSheetWidth = maxSheetWidth.coerceAtMost(simulatedRootWidth) |
| // Our sheet should be max 640 dp but fill the width if the container is less wide |
| val expectedSheetLeft = if (simulatedRootWidth <= expectedSheetWidth) { |
| 0.dp |
| } else { |
| (simulatedRootWidth - expectedSheetWidth) / 2 |
| } |
| |
| rule.onNodeWithTag(sheetTag) |
| .onParent() |
| .assertLeftPositionInRootIsEqualTo( |
| expectedLeft = expectedSheetLeft |
| ) |
| .assertWidthIsEqualTo(expectedSheetWidth) |
| } catch (e: InterruptedException) { |
| fail("Unable to verify sheet width in landscape orientation") |
| } finally { |
| rule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_wideScreen_filledWidth_sheetFillsEntireWidth() { |
| rule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE |
| val latch = CountDownLatch(1) |
| |
| rule.activity.application.registerComponentCallbacks(object : ComponentCallbacks2 { |
| override fun onConfigurationChanged(p0: Configuration) { |
| latch.countDown() |
| } |
| |
| override fun onLowMemory() { |
| // NO-OP |
| } |
| |
| override fun onTrimMemory(p0: Int) { |
| // NO-OP |
| } |
| }) |
| |
| try { |
| latch.await(1500, TimeUnit.MILLISECONDS) |
| var screenWidthPx by mutableStateOf(0) |
| rule.setContent { |
| val context = LocalContext.current |
| screenWidthPx = context.resources.displayMetrics.widthPixels |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetMaxWidth = Dp.Unspecified, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .testTag(sheetTag) |
| .fillMaxHeight(0.4f) |
| ) |
| } |
| } |
| |
| val sheet = rule.onNodeWithTag(sheetTag).onParent().getUnclippedBoundsInRoot() |
| val sheetWidthPx = with(rule.density) { sheet.width.roundToPx() } |
| assertThat(sheetWidthPx).isEqualTo(screenWidthPx) |
| } catch (e: InterruptedException) { |
| fail("Unable to verify sheet width in landscape orientation") |
| } finally { |
| rule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_defaultStateForSmallContentIsFullExpanded() { |
| lateinit var sheetState: SheetState |
| var height by mutableStateOf(0.dp) |
| |
| rule.setContent { |
| val config = LocalContext.current.resources.configuration |
| height = config.screenHeightDp.dp |
| sheetState = rememberModalBottomSheetState() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| dragHandle = null, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .testTag(sheetTag) |
| .height(sheetHeight) |
| ) |
| } |
| } |
| |
| height = rule.onNode(isPopup()).getUnclippedBoundsInRoot().height |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| rule.onNodeWithTag(sheetTag).assertTopPositionInRootIsEqualTo(height - sheetHeight) |
| } |
| |
| @Test |
| fun modalBottomSheet_defaultStateForLargeContentIsHalfExpanded() { |
| lateinit var sheetState: SheetState |
| var screenHeightPx by mutableStateOf(0f) |
| |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| // Deliberately use fraction != 1f |
| .fillMaxSize(0.6f) |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| |
| screenHeightPx = with(rule.density) { |
| rule.onNode(isPopup()).getUnclippedBoundsInRoot().height.toPx() |
| } |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| assertThat(sheetState.requireOffset()) |
| .isWithin(1f) |
| .of(screenHeightPx / 2f) |
| } |
| |
| @Test |
| fun modalBottomSheet_shortSheet_isDismissedOnBackPress() { |
| var showBottomSheet by mutableStateOf(true) |
| val sheetState = SheetState(skipPartiallyExpanded = true, density = rule.density) |
| |
| rule.setContent { |
| val dispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| if (showBottomSheet) { |
| ModalBottomSheet( |
| sheetState = sheetState, |
| onDismissRequest = { showBottomSheet = false }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxHeight(0.4f) |
| .testTag(sheetTag) |
| ) { |
| Button( |
| onClick = { dispatcher.onBackPressed() }, |
| modifier = Modifier.testTag(BackTestTag), |
| content = { Text("Content") }, |
| ) |
| } |
| } |
| } |
| } |
| |
| assertThat(sheetState.isVisible).isTrue() |
| |
| rule.onNodeWithTag(BackTestTag).performClick() |
| |
| rule.onNodeWithTag(BackTestTag).assertDoesNotExist() |
| |
| // Popup should not exist |
| rule.onNodeWithTag(sheetTag).assertDoesNotExist() |
| } |
| |
| @Test |
| fun modalBottomSheet_tallSheet_isDismissedOnBackPress() { |
| var showBottomSheet by mutableStateOf(true) |
| val sheetState = SheetState(skipPartiallyExpanded = false, density = rule.density) |
| |
| rule.setContent { |
| val dispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| if (showBottomSheet) { |
| ModalBottomSheet( |
| sheetState = sheetState, |
| onDismissRequest = { showBottomSheet = false }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxHeight(0.6f) |
| .testTag(sheetTag) |
| ) { |
| Button( |
| onClick = { dispatcher.onBackPressed() }, |
| modifier = Modifier.testTag(BackTestTag), |
| content = { Text("Content") }, |
| ) |
| } |
| } |
| } |
| } |
| assertThat(sheetState.isVisible).isTrue() |
| |
| rule.onNodeWithTag(BackTestTag).performClick() |
| rule.onNodeWithTag(BackTestTag).assertDoesNotExist() |
| |
| // Popup should not exist |
| rule.onNodeWithTag(sheetTag).assertDoesNotExist() |
| } |
| |
| @Test |
| fun modalBottomSheet_shortSheet_sizeChanges_snapsToNewTarget() { |
| lateinit var state: SheetState |
| var size by mutableStateOf(56.dp) |
| var screenHeight by mutableStateOf(0.dp) |
| val expectedExpandedAnchor by derivedStateOf { |
| with(rule.density) { |
| (screenHeight - size).toPx() |
| } |
| } |
| |
| rule.setContent { |
| val context = LocalContext.current |
| screenHeight = context.resources.configuration.screenHeightDp.dp |
| state = rememberModalBottomSheetState() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = state, |
| dragHandle = null, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .height(size) |
| .fillMaxWidth() |
| ) |
| } |
| } |
| screenHeight = rule.onNode(isPopup()).getUnclippedBoundsInRoot().height |
| assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor) |
| |
| size = 100.dp |
| rule.waitForIdle() |
| assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor) |
| |
| size = 30.dp |
| rule.waitForIdle() |
| assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor) |
| } |
| |
| @Test |
| fun modalBottomSheet_sheetMaxWidth_sizeChanges_snapsToNewTarget() { |
| lateinit var sheetMaxWidth: MutableState<Dp> |
| var screenWidth by mutableStateOf(0.dp) |
| rule.setContent { |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| sheetMaxWidth = remember { mutableStateOf(0.dp) } |
| val context = LocalContext.current |
| val density = LocalDensity.current |
| screenWidth = with(density) { context.resources.displayMetrics.widthPixels.toDp() } |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetMaxWidth = sheetMaxWidth.value, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| |
| for (dp in listOf(0.dp, 200.dp, 400.dp)) { |
| sheetMaxWidth.value = dp |
| val sheetWidth = rule.onNodeWithTag(sheetTag).getUnclippedBoundsInRoot().width |
| val expectedSheetWidth = minOf(sheetMaxWidth.value, screenWidth) |
| assertThat(sheetWidth).isEqualTo(expectedSheetWidth) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_emptySheet_expandDoesNotAnimate() { |
| lateinit var state: SheetState |
| lateinit var scope: CoroutineScope |
| rule.setContent { |
| state = rememberModalBottomSheetState() |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = state, |
| dragHandle = null, |
| windowInsets = windowInsets |
| ) {} |
| } |
| assertThat(state.anchoredDraggableState.currentValue).isEqualTo(SheetValue.Hidden) |
| val hiddenOffset = state.requireOffset() |
| scope.launch { state.show() } |
| rule.waitForIdle() |
| |
| assertThat(state.anchoredDraggableState.currentValue).isEqualTo(SheetValue.Expanded) |
| val expandedOffset = state.requireOffset() |
| |
| assertThat(hiddenOffset).isEqualTo(expandedOffset) |
| } |
| |
| @Test |
| fun modalBottomSheet_anchorsChange_retainsCurrentValue() { |
| lateinit var state: SheetState |
| var amountOfItems by mutableStateOf(0) |
| lateinit var scope: CoroutineScope |
| rule.setContent { |
| state = rememberModalBottomSheetState() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = state, |
| dragHandle = null, |
| windowInsets = windowInsets |
| ) { |
| scope = rememberCoroutineScope() |
| LazyColumn { |
| items(amountOfItems) { |
| ListItem(headlineContent = { Text("$it") }) |
| } |
| } |
| } |
| } |
| |
| assertThat(state.currentValue).isEqualTo(SheetValue.Hidden) |
| |
| amountOfItems = 50 |
| rule.waitForIdle() |
| scope.launch { |
| state.show() |
| } |
| // The anchors should now be {Hidden, PartiallyExpanded, Expanded} |
| |
| rule.waitForIdle() |
| assertThat(state.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| |
| amountOfItems = 100 // The anchors should now be {Hidden, PartiallyExpanded, Expanded} |
| |
| rule.waitForIdle() |
| assertThat(state.currentValue).isEqualTo(SheetValue.PartiallyExpanded) // We should |
| // retain the current value if possible |
| assertTrue(state.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Hidden)) |
| assertTrue(state.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded)) |
| assertTrue(state.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)) |
| |
| amountOfItems = 0 // When the sheet height is 0, we should only have a hidden anchor |
| rule.waitForIdle() |
| assertThat(state.currentValue).isEqualTo(SheetValue.Hidden) |
| assertTrue(state.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Hidden)) |
| assertFalse(state.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded)) |
| assertFalse(state.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)) |
| } |
| |
| @Test |
| fun modalBottomSheet_nestedScroll_consumesWithinBounds_scrollsOutsideBounds() { |
| lateinit var sheetState: SheetState |
| lateinit var scrollState: ScrollState |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState() |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState |
| ) { |
| scrollState = rememberScrollState() |
| Column( |
| Modifier |
| .verticalScroll(scrollState) |
| .testTag(sheetTag) |
| ) { |
| repeat(100) { |
| Text(it.toString(), Modifier.requiredHeight(50.dp)) |
| } |
| } |
| } |
| } |
| |
| rule.waitForIdle() |
| |
| assertThat(scrollState.value).isEqualTo(0) |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { |
| swipeUp(startY = bottom, endY = bottom / 2) |
| } |
| rule.waitForIdle() |
| assertThat(scrollState.value).isEqualTo(0) |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { |
| swipeUp(startY = bottom, endY = top) |
| } |
| rule.waitForIdle() |
| assertThat(scrollState.value).isGreaterThan(0) |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { |
| swipeDown(startY = top, endY = bottom) |
| } |
| rule.waitForIdle() |
| assertThat(scrollState.value).isEqualTo(0) |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { |
| swipeDown(startY = top, endY = bottom / 2) |
| } |
| rule.waitForIdle() |
| assertThat(scrollState.value).isEqualTo(0) |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { |
| swipeDown(startY = bottom / 2, endY = bottom) |
| } |
| rule.waitForIdle() |
| assertThat(scrollState.value).isEqualTo(0) |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden) |
| } |
| |
| @Test |
| fun modalBottomSheet_missingAnchors_findsClosest() { |
| val topTag = "ModalBottomSheetLayout" |
| var showShortContent by mutableStateOf(false) |
| val sheetState = SheetState(skipPartiallyExpanded = false, density = rule.density) |
| lateinit var scope: CoroutineScope |
| |
| rule.setContent { |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| modifier = Modifier.testTag(topTag), |
| sheetState = sheetState, |
| windowInsets = windowInsets |
| ) { |
| if (showShortContent) { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(100.dp) |
| ) |
| } else { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| } |
| |
| rule.onNodeWithTag(topTag).performTouchInput { |
| swipeDown() |
| swipeDown() |
| } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden) |
| } |
| |
| showShortContent = true |
| scope.launch { sheetState.show() } // We can't use LaunchedEffect with Swipeable in tests |
| // yet, so we're invoking this outside of composition. See b/254115946. |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_expandBySwiping() { |
| lateinit var sheetState: SheetState |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| } |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { swipeUp() } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_respectsConfirmValueChange() { |
| lateinit var sheetState: SheetState |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState( |
| confirmValueChange = { newState -> |
| newState != SheetValue.Hidden |
| } |
| ) |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| dragHandle = { |
| Box( |
| Modifier |
| .testTag(dragHandleTag) |
| .size(dragHandleSize) |
| ) |
| }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| } |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { swipeDown() } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| } |
| |
| rule.onNodeWithTag(dragHandleTag, useUnmergedTree = true).onParent() |
| .performSemanticsAction(SemanticsActions.Dismiss) |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| } |
| |
| // Tap Scrim |
| val outsideY = with(rule.density) { |
| rule.onAllNodes(isPopup()).onFirst().getUnclippedBoundsInRoot().height.roundToPx() / 4 |
| } |
| UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).click(0, outsideY) |
| rule.waitForIdle() |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_hideBySwiping_tallBottomSheet() { |
| lateinit var sheetState: SheetState |
| lateinit var scope: CoroutineScope |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState() |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| } |
| |
| scope.launch { sheetState.expand() } |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| } |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { swipeDown() } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_hideBySwiping_skipPartiallyExpanded() { |
| lateinit var sheetState: SheetState |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(sheetHeight) |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| } |
| |
| rule.onNodeWithTag(sheetTag) |
| .performTouchInput { swipeDown() } |
| |
| rule.runOnIdle { |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_hideManually_skipPartiallyExpanded(): Unit = runBlocking( |
| AutoTestFrameClock() |
| ) { |
| lateinit var sheetState: SheetState |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| assertThat(sheetState.currentValue == SheetValue.Expanded) |
| |
| sheetState.hide() |
| |
| assertThat(sheetState.currentValue == SheetValue.Hidden) |
| } |
| |
| @Test |
| fun modalBottomSheet_testParialExpandReturnsIllegalStateException_whenSkipPartialExpanded() { |
| lateinit var scope: CoroutineScope |
| val bottomSheetState = SheetState( |
| skipPartiallyExpanded = true, |
| density = rule.density |
| ) |
| rule.setContent { |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = bottomSheetState, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| scope.launch { |
| val exception = |
| kotlin.runCatching { bottomSheetState.partialExpand() }.exceptionOrNull() |
| assertThat(exception).isNotNull() |
| assertThat(exception).isInstanceOf(IllegalStateException::class.java) |
| assertThat(exception).hasMessageThat().containsMatch( |
| "Attempted to animate to partial expanded when skipPartiallyExpanded was " + |
| "enabled. Set skipPartiallyExpanded to false to use this function." |
| ) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_testDismissAction_tallBottomSheet_whenPartiallyExpanded() { |
| rule.setContent { |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| dragHandle = { |
| Box( |
| Modifier |
| .testTag(dragHandleTag) |
| .size(dragHandleSize) |
| ) |
| }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| |
| rule.onNodeWithTag(dragHandleTag, useUnmergedTree = true).onParent() |
| .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.Collapse)) |
| .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Expand)) |
| .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Dismiss)) |
| .performSemanticsAction(SemanticsActions.Dismiss) |
| } |
| |
| @Test |
| fun modalBottomSheet_testExpandAction_tallBottomSheet_whenHalfExpanded() { |
| lateinit var sheetState: SheetState |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| dragHandle = { |
| Box( |
| Modifier |
| .testTag(dragHandleTag) |
| .size(dragHandleSize) |
| ) |
| }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| |
| rule.onNodeWithTag(dragHandleTag, useUnmergedTree = true).onParent() |
| .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.Collapse)) |
| .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Expand)) |
| .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Dismiss)) |
| .performSemanticsAction(SemanticsActions.Expand) |
| |
| rule.runOnIdle { |
| assertThat(sheetState.requireOffset()).isEqualTo(0f) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_testDismissAction_tallBottomSheet_whenExpanded() { |
| lateinit var sheetState: SheetState |
| lateinit var scope: CoroutineScope |
| |
| var screenHeightPx by mutableStateOf(0f) |
| |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState() |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| dragHandle = { |
| Box( |
| Modifier |
| .testTag(dragHandleTag) |
| .size(dragHandleSize) |
| ) |
| }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| screenHeightPx = with(rule.density) { |
| rule.onNode(isPopup()).getUnclippedBoundsInRoot().height.toPx() |
| } |
| scope.launch { |
| sheetState.expand() |
| } |
| rule.waitForIdle() |
| |
| rule.onNodeWithTag(dragHandleTag, useUnmergedTree = true).onParent() |
| .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.Expand)) |
| .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Collapse)) |
| .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Dismiss)) |
| .performSemanticsAction(SemanticsActions.Dismiss) |
| |
| rule.runOnIdle { |
| assertThat(sheetState.requireOffset()).isWithin(1f).of(screenHeightPx) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_testCollapseAction_tallBottomSheet_whenExpanded() { |
| lateinit var sheetState: SheetState |
| lateinit var scope: CoroutineScope |
| |
| var screenHeightPx by mutableStateOf(0f) |
| |
| rule.setContent { |
| sheetState = rememberModalBottomSheetState() |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| dragHandle = { |
| Box( |
| Modifier |
| .testTag(dragHandleTag) |
| .size(dragHandleSize) |
| ) |
| }, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .testTag(sheetTag) |
| ) |
| } |
| } |
| screenHeightPx = with(rule.density) { |
| rule.onNode(isPopup()).getUnclippedBoundsInRoot().height.toPx() |
| } |
| scope.launch { |
| sheetState.expand() |
| } |
| rule.waitForIdle() |
| |
| rule.onNodeWithTag(dragHandleTag, useUnmergedTree = true).onParent() |
| .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.Expand)) |
| .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Collapse)) |
| .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Dismiss)) |
| .performSemanticsAction(SemanticsActions.Collapse) |
| |
| rule.runOnIdle { |
| assertThat(sheetState.requireOffset()).isWithin(1f).of(screenHeightPx / 2) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_shortSheet_anchorChangeHandler_previousTargetNotInAnchors_reconciles() { |
| val sheetState = SheetState(skipPartiallyExpanded = false, density = rule.density) |
| var hasSheetContent by mutableStateOf(false) // Start out with empty sheet content |
| lateinit var scope: CoroutineScope |
| rule.setContent { |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| dragHandle = null, |
| windowInsets = windowInsets |
| ) { |
| if (hasSheetContent) { |
| Box(Modifier.fillMaxHeight(0.4f)) |
| } |
| } |
| } |
| |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden) |
| assertFalse( |
| sheetState.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded) |
| ) |
| assertFalse(sheetState.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)) |
| |
| scope.launch { sheetState.show() } |
| rule.waitForIdle() |
| |
| assertThat(sheetState.isVisible).isTrue() |
| assertThat(sheetState.currentValue).isEqualTo(sheetState.targetValue) |
| |
| hasSheetContent = true // Recompose with sheet content |
| rule.waitForIdle() |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| } |
| |
| @Test |
| fun modalBottomSheet_tallSheet_anchorChangeHandler_previousTargetNotInAnchors_reconciles() { |
| val sheetState = SheetState(skipPartiallyExpanded = false, density = rule.density) |
| var hasSheetContent by mutableStateOf(false) // Start out with empty sheet content |
| lateinit var scope: CoroutineScope |
| rule.setContent { |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| sheetState = sheetState, |
| dragHandle = null, |
| windowInsets = windowInsets |
| ) { |
| if (hasSheetContent) { |
| Box(Modifier.fillMaxHeight(0.6f)) |
| } |
| } |
| } |
| |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden) |
| assertFalse( |
| sheetState.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded) |
| ) |
| assertFalse(sheetState.anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)) |
| |
| scope.launch { sheetState.show() } |
| rule.waitForIdle() |
| |
| assertThat(sheetState.isVisible).isTrue() |
| assertThat(sheetState.currentValue).isEqualTo(sheetState.targetValue) |
| |
| hasSheetContent = true // Recompose with sheet content |
| rule.waitForIdle() |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) |
| } |
| |
| @Test |
| fun modalBottomSheet_callsOnDismissRequest_onNestedScrollFling() { |
| var callCount by mutableStateOf(0) |
| val expectedCallCount = 1 |
| val sheetState = SheetState(skipPartiallyExpanded = true, density = rule.density) |
| |
| val nestedScrollDispatcher = NestedScrollDispatcher() |
| val nestedScrollConnection = object : NestedScrollConnection { |
| // No-Op |
| } |
| lateinit var scope: CoroutineScope |
| |
| rule.setContent { |
| scope = rememberCoroutineScope() |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| ModalBottomSheet( |
| onDismissRequest = { callCount += 1 }, |
| sheetState = sheetState, |
| windowInsets = windowInsets |
| ) { |
| Column( |
| Modifier |
| .testTag(sheetTag) |
| .nestedScroll(nestedScrollConnection, nestedScrollDispatcher) |
| ) { |
| (0..50).forEach { |
| Text(text = "$it") |
| } |
| } |
| } |
| } |
| |
| assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) |
| val scrollableContentHeight = rule.onNodeWithTag(sheetTag).fetchSemanticsNode().size.height |
| // Simulate a drag + fling |
| nestedScrollDispatcher.dispatchPostScroll( |
| consumed = Offset.Zero, |
| available = Offset(x = 0f, y = scrollableContentHeight / 2f), |
| source = NestedScrollSource.Drag |
| ) |
| scope.launch { |
| nestedScrollDispatcher.dispatchPostFling( |
| consumed = Velocity.Zero, |
| available = Velocity(x = 0f, y = with(rule.density) { 200.dp.toPx() }) |
| ) |
| } |
| |
| rule.waitForIdle() |
| assertThat(sheetState.isVisible).isFalse() |
| assertThat(callCount).isEqualTo(expectedCallCount) |
| } |
| |
| @Test |
| fun modalBottomSheet_screenWidthConfigurationChange_matchWidthSize() { |
| var boxWidth = 0 |
| var screenWidth by mutableStateOf(0) |
| lateinit var configuration: MutableState<Configuration> |
| val initialScreenWidth = 100 |
| val finalScreenWidth = 500 |
| |
| rule.setContent { |
| val localConfig = LocalConfiguration.current |
| configuration = remember { mutableStateOf(Configuration(localConfig)) } |
| |
| configuration.value.screenWidthDp = initialScreenWidth |
| |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| |
| CompositionLocalProvider( |
| LocalConfiguration provides configuration.value |
| ) { |
| val context = LocalContext.current |
| screenWidth = context.resources.displayMetrics.widthPixels |
| ModalBottomSheet( |
| onDismissRequest = {}, |
| windowInsets = windowInsets |
| ) { |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(sheetHeight) |
| .onSizeChanged { boxWidth = it.width } |
| ) |
| } |
| } |
| } |
| |
| // Make sure that the BottomSheet's width is the same as the configuration's screen width |
| assertThat(boxWidth).isEqualTo(screenWidth) |
| |
| // Change the screen width |
| configuration.value.screenWidthDp = finalScreenWidth |
| |
| // Make sure that BottomSheet is updating and resizing to the new screen width |
| assertThat(boxWidth).isEqualTo(screenWidth) |
| } |
| |
| @Test |
| fun modalBottomSheet_imePadding() { |
| // TODO: Include APIs < 30 when a solution is found for b/290893168. |
| // TODO: 33 > API > 29 does not use imePadding because of b/285746907, include when a better solution is found. |
| Assume.assumeTrue(SDK_INT >= 33) |
| |
| val imeAnimationDuration = 1000000L |
| val textFieldTag = "sheetTextField" |
| |
| lateinit var sheetState: SheetState |
| rule.setContent { |
| val windowInsets = if (edgeToEdgeWrapper.edgeToEdgeEnabled) |
| WindowInsets(0) else BottomSheetDefaults.windowInsets |
| sheetState = rememberModalBottomSheetState() |
| ModalBottomSheet( |
| sheetState = sheetState, |
| onDismissRequest = {}, |
| windowInsets = windowInsets |
| ) { |
| Box(Modifier.testTag(sheetTag)) { |
| TextField( |
| value = "", |
| onValueChange = {}, |
| modifier = Modifier.testTag(textFieldTag) |
| ) |
| } |
| } |
| } |
| |
| // Stop auto advance for test consistency |
| rule.mainClock.autoAdvance = false |
| |
| val textFieldNode = rule.onNodeWithTag(textFieldTag) |
| var sheetNode = rule.onNodeWithTag(sheetTag) |
| val initialTop = sheetNode.getUnclippedBoundsInRoot().top |
| |
| // Focus on the text field to force ime visibility. |
| textFieldNode.requestFocus() |
| |
| // Wait for the ime and bottom sheet to animate after text field is focused. |
| rule.mainClock.advanceTimeBy(imeAnimationDuration) |
| val finalTop = sheetNode.getUnclippedBoundsInRoot().top |
| rule.runOnIdle { |
| // The top of the bottom sheet should be higher now due to ime padding. |
| assertThat(finalTop).isLessThan(initialTop) |
| } |
| } |
| |
| @Test |
| fun modalBottomSheet_preservesLayoutDirection() { |
| var value = LayoutDirection.Ltr |
| rule.setContent { |
| CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { |
| ModalBottomSheet(onDismissRequest = { /*TODO*/ }) { |
| value = LocalLayoutDirection.current |
| } |
| } |
| } |
| rule.runOnIdle { |
| assertThat(value).isEqualTo(LayoutDirection.Rtl) |
| } |
| } |
| |
| companion object { |
| @Parameterized.Parameters(name = "{0}") |
| @JvmStatic |
| fun parameters() = arrayOf( |
| EdgeToEdgeWrapper("EdgeToEdge", true), |
| EdgeToEdgeWrapper("NonEdgeToEdge", false) |
| ) |
| } |
| |
| class EdgeToEdgeWrapper(val name: String, val edgeToEdgeEnabled: Boolean) { |
| override fun toString(): String { |
| return name |
| } |
| } |
| } |