[Material3][BottomSheet] M3 Modal Bottom Sheet implementation.
Bug: 244189383
Relnote: Modal bottom sheet implementation for Material 3, including ModalBottomSheet and ModalBottomSheetDefaults. Also introduces SheetState and rememberSheetState which can be used for future sheet components.
Test: Ported relevant tests from M2 Modal bottom sheet and popup.
Change-Id: I0853a6ec6d06166787701db1edb4a09b90dd563e
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index b56b591..d0677cd 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -427,6 +427,9 @@
public final class MenuKt {
}
+ public final class ModalBottomSheetKt {
+ }
+
public final class NavigationBarDefaults {
method @androidx.compose.runtime.Composable public long getContainerColor();
method public float getElevation();
@@ -600,6 +603,9 @@
public final class ShapesKt {
}
+ public final class SheetDefaultsKt {
+ }
+
@androidx.compose.runtime.Immutable public final class SliderColors {
}
diff --git a/compose/material3/material3/api/public_plus_experimental_current.txt b/compose/material3/material3/api/public_plus_experimental_current.txt
index ff21164..a32321f 100644
--- a/compose/material3/material3/api/public_plus_experimental_current.txt
+++ b/compose/material3/material3/api/public_plus_experimental_current.txt
@@ -81,6 +81,21 @@
field public static final androidx.compose.material3.BottomAppBarDefaults INSTANCE;
}
+ @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Stable public final class BottomSheetDefaults {
+ method @androidx.compose.runtime.Composable public void DragHandle(optional androidx.compose.ui.Modifier modifier, optional float width, optional float height, optional androidx.compose.ui.graphics.Shape shape, optional long color);
+ method @androidx.compose.runtime.Composable public long getContainerColor();
+ method public float getElevation();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getExpandedShape();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getMinimizedShape();
+ method @androidx.compose.runtime.Composable public long getScrimColor();
+ property @androidx.compose.runtime.Composable public final long ContainerColor;
+ property public final float Elevation;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape ExpandedShape;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape MinimizedShape;
+ property @androidx.compose.runtime.Composable public final long ScrimColor;
+ field public static final androidx.compose.material3.BottomSheetDefaults INSTANCE;
+ }
+
@androidx.compose.runtime.Immutable public final class ButtonColors {
}
@@ -640,6 +655,10 @@
public final class MenuKt {
}
+ public final class ModalBottomSheetKt {
+ method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void ModalBottomSheet(kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.SheetState sheetState, optional androidx.compose.ui.graphics.Shape shape, optional long containerColor, optional long contentColor, optional float tonalElevation, optional long scrimColor, optional kotlin.jvm.functions.Function0<kotlin.Unit>? dragHandle, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
+ }
+
public final class NavigationBarDefaults {
method @androidx.compose.runtime.Composable public long getContainerColor();
method public float getElevation();
@@ -844,6 +863,37 @@
public final class ShapesKt {
}
+ public final class SheetDefaultsKt {
+ method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static androidx.compose.material3.SheetState rememberSheetState(optional boolean skipHalfExpanded, optional kotlin.jvm.functions.Function1<? super androidx.compose.material3.SheetValue,java.lang.Boolean> confirmValueChange);
+ }
+
+ @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Stable public final class SheetState {
+ ctor public SheetState(boolean skipCollapsed, optional androidx.compose.material3.SheetValue initialValue, optional kotlin.jvm.functions.Function1<? super androidx.compose.material3.SheetValue,java.lang.Boolean> confirmValueChange);
+ method public suspend Object? collapse(kotlin.coroutines.Continuation<? super kotlin.Unit>);
+ method public androidx.compose.material3.SheetValue getCurrentValue();
+ method public androidx.compose.material3.SheetValue getTargetValue();
+ method public suspend Object? hide(kotlin.coroutines.Continuation<? super kotlin.Unit>);
+ method public boolean isVisible();
+ method public float requireOffset();
+ method public suspend Object? show(kotlin.coroutines.Continuation<? super kotlin.Unit>);
+ property public final androidx.compose.material3.SheetValue currentValue;
+ property public final boolean isVisible;
+ property public final androidx.compose.material3.SheetValue targetValue;
+ field public static final androidx.compose.material3.SheetState.Companion Companion;
+ }
+
+ public static final class SheetState.Companion {
+ method public androidx.compose.runtime.saveable.Saver<androidx.compose.material3.SheetState,androidx.compose.material3.SheetValue> Saver(boolean skipHalfExpanded, kotlin.jvm.functions.Function1<? super androidx.compose.material3.SheetValue,java.lang.Boolean> confirmValueChange);
+ }
+
+ @androidx.compose.material3.ExperimentalMaterial3Api public enum SheetValue {
+ method public static androidx.compose.material3.SheetValue valueOf(String name) throws java.lang.IllegalArgumentException;
+ method public static androidx.compose.material3.SheetValue[] values();
+ enum_constant public static final androidx.compose.material3.SheetValue Collapsed;
+ enum_constant public static final androidx.compose.material3.SheetValue Expanded;
+ enum_constant public static final androidx.compose.material3.SheetValue Hidden;
+ }
+
@androidx.compose.runtime.Immutable public final class SliderColors {
}
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index b56b591..d0677cd 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -427,6 +427,9 @@
public final class MenuKt {
}
+ public final class ModalBottomSheetKt {
+ }
+
public final class NavigationBarDefaults {
method @androidx.compose.runtime.Composable public long getContainerColor();
method public float getElevation();
@@ -600,6 +603,9 @@
public final class ShapesKt {
}
+ public final class SheetDefaultsKt {
+ }
+
@androidx.compose.runtime.Immutable public final class SliderColors {
}
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Components.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Components.kt
index 2255feb..3da1933 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Components.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Components.kt
@@ -69,6 +69,18 @@
examples = BottomAppBarsExamples
)
+private val BottomSheets = Component(
+ id = nextId(),
+ name = "Bottom Sheet",
+ description = "Bottom sheets are surfaces containing supplementary content, anchored to the " +
+ "bottom of the screen.",
+ // No bottom sheet icon
+ guidelinesUrl = "$ComponentGuidelinesUrl/bottom-sheets",
+ docsUrl = "$DocsUrl#bottomsheet",
+ sourceUrl = "$Material3SourceUrl/ModalBottomSheet.kt",
+ examples = BottomSheetExamples
+)
+
private val Buttons = Component(
id = nextId(),
name = "Buttons",
@@ -350,6 +362,7 @@
val Components = listOf(
Badge,
BottomAppBars,
+ BottomSheets,
Buttons,
Card,
Checkboxes,
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
index 94fceef..dc1e6680 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
@@ -28,6 +28,7 @@
import androidx.compose.material3.samples.AnimatedExtendedFloatingActionButtonSample
import androidx.compose.material3.samples.AssistChipSample
import androidx.compose.material3.samples.BottomAppBarWithFAB
+import androidx.compose.material3.samples.ModalBottomSheetSample
import androidx.compose.material3.samples.ButtonSample
import androidx.compose.material3.samples.ButtonWithIconSample
import androidx.compose.material3.samples.CardSample
@@ -157,6 +158,17 @@
) { NavigationBarItemWithBadge() }
)
+private const val BottomSheetExampleDescription = "Bottom Sheet examples"
+private const val BottomSheetExampleSourceUrl = "$SampleSourceUrl/BottomSheetSamples.kt"
+val BottomSheetExamples =
+ listOf(
+ Example(
+ name = ::ModalBottomSheetSample.name,
+ description = BottomSheetExampleDescription,
+ sourceUrl = BottomSheetExampleSourceUrl
+ ) { ModalBottomSheetSample() }
+ )
+
private const val ButtonsExampleDescription = "Button examples"
private const val ButtonsExampleSourceUrl = "$SampleSourceUrl/ButtonSamples.kt"
val ButtonsExamples =
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/BottomSheetSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/BottomSheetSamples.kt
new file mode 100644
index 0000000..ba4e86b
--- /dev/null
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/BottomSheetSamples.kt
@@ -0,0 +1,121 @@
+/*
+ * 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.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+
+@Preview
+@Sampled
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ModalBottomSheetSample() {
+ var openBottomSheet by rememberSaveable { mutableStateOf(false) }
+ var skipHalfExpanded by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ val bottomSheetState = rememberSheetState(skipHalfExpanded = skipHalfExpanded)
+
+ // App content
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Row(
+ Modifier.toggleable(
+ value = skipHalfExpanded,
+ role = Role.Checkbox,
+ onValueChange = { checked -> skipHalfExpanded = checked }
+ )
+ ) {
+ Checkbox(checked = skipHalfExpanded, onCheckedChange = null)
+ Spacer(Modifier.width(16.dp))
+ Text("Skip Half Expanded State")
+ }
+ Button(onClick = { openBottomSheet = !openBottomSheet }) {
+ Text(text = "Show Bottom Sheet")
+ }
+ }
+
+ // Sheet content
+ if (openBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { openBottomSheet = false },
+ sheetState = bottomSheetState,
+ ) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+ Button(
+ // Note: If you provide logic outside of onDismissRequest to remove the sheet,
+ // you must additionally handle intended state cleanup, if any.
+ onClick = {
+ scope.launch { bottomSheetState.hide() }.invokeOnCompletion {
+ if (!bottomSheetState.isVisible) {
+ openBottomSheet = false
+ }
+ }
+ }
+ ) {
+ Text("Hide Bottom Sheet")
+ }
+ }
+ LazyColumn {
+ items(50) {
+ ListItem(
+ headlineText = { Text("Item $it") },
+ leadingContent = {
+ Icon(
+ Icons.Default.Favorite,
+ contentDescription = "Localized description"
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
new file mode 100644
index 0000000..ed5213e
--- /dev/null
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
@@ -0,0 +1,848 @@
+/*
+ * 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 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.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.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+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.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.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.swipeDown
+import androidx.compose.ui.test.swipeUp
+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.ext.junit.runners.AndroidJUnit4
+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.fail
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalMaterial3Api::class)
+class ModalBottomSheetTest {
+
+ @get:Rule
+ val rule = createAndroidComposeRule<ComponentActivity>()
+
+ private val sheetHeight = 256.dp
+ private val sheetTag = "sheetContentTag"
+ private val BackTestTag = "Back"
+
+ @Test
+ fun modalBottomSheet_isDismissedOnTapOutside() {
+ var showBottomSheet by mutableStateOf(true)
+
+ rule.setContent {
+ if (showBottomSheet) {
+ ModalBottomSheet(onDismissRequest = { showBottomSheet = false }) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(sheetHeight)
+ .testTag(sheetTag)
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag(sheetTag).assertIsDisplayed()
+
+ 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_fillsScreenWidth() {
+ var boxWidth = 0
+ var screenWidth by mutableStateOf(0)
+
+ rule.setContent {
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val resScreenWidth = context.resources.configuration.screenWidthDp
+ with(density) { screenWidth = resScreenWidth.dp.roundToPx() }
+
+ ModalBottomSheet(onDismissRequest = {}) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(sheetHeight)
+ .onSizeChanged { boxWidth = it.width }
+ )
+ }
+ }
+ assertThat(boxWidth).isEqualTo(screenWidth)
+ }
+
+ @Test
+ fun modalBottomSheet_wideScreen_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 {
+ ModalBottomSheet(onDismissRequest = {}) {
+ 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_defaultStateForSmallContentIsFullExpanded() {
+ lateinit var sheetState: SheetState
+
+ rule.setContent {
+ sheetState = rememberSheetState()
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState, dragHandle = null) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .testTag(sheetTag)
+ .height(sheetHeight)
+ )
+ }
+ }
+
+ val 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 = rememberSheetState()
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val resScreenHeight = context.resources.configuration.screenHeightDp
+ with(density) {
+ screenHeightPx = resScreenHeight.dp.roundToPx().toFloat()
+ }
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag))
+ }
+ }
+ rule.waitForIdle()
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Collapsed)
+ assertThat(sheetState.requireOffset())
+ .isWithin(1f)
+ .of(screenHeightPx / 2f)
+ }
+
+ @Test
+ fun modalBottomSheet_isDismissedOnBackPress() {
+ var showBottomSheet by mutableStateOf(true)
+ rule.setContent {
+ val dispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher
+ if (showBottomSheet) {
+ ModalBottomSheet(onDismissRequest = { showBottomSheet = false }) {
+ Box(
+ Modifier
+ .size(sheetHeight)
+ .testTag(sheetTag)) {
+ Button(
+ onClick = { dispatcher.onBackPressed() },
+ modifier = Modifier.testTag(BackTestTag),
+ content = { Text("Content") },
+ )
+ }
+ }
+ }
+ }
+
+ // Popup should be visible
+ rule.onNodeWithTag(sheetTag).assertIsDisplayed()
+
+ 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 = rememberSheetState()
+ ModalBottomSheet(
+ onDismissRequest = {},
+ sheetState = state,
+ dragHandle = null
+ ) {
+ Box(
+ Modifier
+ .height(size)
+ .fillMaxWidth()
+ )
+ }
+ }
+ assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor)
+
+ size = 100.dp
+ rule.waitForIdle()
+ assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor)
+
+ size = 30.dp
+ rule.waitForIdle()
+ assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor)
+ }
+
+ @Test
+ fun modalBottomSheet_emptySheet_expandDoesNotAnimate() {
+ lateinit var state: SheetState
+ lateinit var scope: CoroutineScope
+ rule.setContent {
+ state = rememberSheetState()
+ scope = rememberCoroutineScope()
+
+ ModalBottomSheet(onDismissRequest = {}, sheetState = state, dragHandle = null) {}
+ }
+ assertThat(state.swipeableState.currentValue).isEqualTo(SheetValue.Hidden)
+ val hiddenOffset = state.requireOffset()
+ scope.launch { state.show() }
+ rule.waitForIdle()
+
+ assertThat(state.swipeableState.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 = rememberSheetState()
+ ModalBottomSheet(
+ onDismissRequest = {},
+ sheetState = state,
+ dragHandle = null,
+ ) {
+ scope = rememberCoroutineScope()
+ LazyColumn {
+ items(amountOfItems) {
+ ListItem(headlineText = { Text("$it") })
+ }
+ }
+ }
+ }
+
+ assertThat(state.currentValue).isEqualTo(SheetValue.Hidden)
+
+ amountOfItems = 50
+ rule.waitForIdle()
+ scope.launch {
+ state.show()
+ }
+ // The anchors should now be {Hidden, HalfExpanded, Expanded}
+
+ rule.waitForIdle()
+ assertThat(state.currentValue).isEqualTo(SheetValue.Collapsed)
+
+ amountOfItems = 100 // The anchors should now be {Hidden, HalfExpanded, Expanded}
+
+ rule.waitForIdle()
+ assertThat(state.currentValue).isEqualTo(SheetValue.Collapsed) // We should
+ // retain the current value if possible
+ assertThat(state.swipeableState.anchors).containsKey(SheetValue.Hidden)
+ assertThat(state.swipeableState.anchors).containsKey(SheetValue.Collapsed)
+ assertThat(state.swipeableState.anchors).containsKey(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)
+ assertThat(state.swipeableState.anchors).containsKey(SheetValue.Hidden)
+ assertThat(state.swipeableState.anchors)
+ .doesNotContainKey(SheetValue.Collapsed)
+ assertThat(state.swipeableState.anchors).doesNotContainKey(SheetValue.Expanded)
+ }
+
+ @Test
+ fun modalBottomSheet_nestedScroll_consumesWithinBounds_scrollsOutsideBounds() {
+ lateinit var sheetState: SheetState
+ lateinit var scrollState: ScrollState
+ rule.setContent {
+ sheetState = rememberSheetState()
+ 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.Collapsed)
+
+ 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.Collapsed)
+
+ 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(skipCollapsed = false)
+ lateinit var scope: CoroutineScope
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ ModalBottomSheet(
+ onDismissRequest = {},
+ modifier = Modifier.testTag(topTag),
+ sheetState = sheetState,
+ ) {
+ 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 = rememberSheetState()
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag)
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Collapsed)
+ }
+
+ rule.onNodeWithTag(sheetTag)
+ .performTouchInput { swipeUp() }
+
+ rule.runOnIdle {
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
+ }
+ }
+
+ @Test
+ fun modalBottomSheet_respectsConfirmStateChange() {
+ lateinit var sheetState: SheetState
+ rule.setContent {
+ sheetState = rememberSheetState(
+ confirmValueChange = { newState ->
+ newState != SheetValue.Hidden
+ }
+ )
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag)
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Collapsed)
+ }
+
+ rule.onNodeWithTag(sheetTag)
+ .performTouchInput { swipeDown() }
+
+ rule.runOnIdle {
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Collapsed)
+ }
+
+ rule.onNodeWithTag(sheetTag).onParent()
+ .performSemanticsAction(SemanticsActions.Dismiss)
+
+ rule.runOnIdle {
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Collapsed)
+ }
+ }
+
+ @Test
+ fun modalBottomSheet_hideBySwiping_tallBottomSheet() {
+ lateinit var sheetState: SheetState
+ lateinit var scope: CoroutineScope
+ rule.setContent {
+ sheetState = rememberSheetState()
+ scope = rememberCoroutineScope()
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag)
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Collapsed)
+ }
+
+ 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_skipHalfExpanded() {
+ lateinit var sheetState: SheetState
+ rule.setContent {
+ sheetState = rememberSheetState(skipHalfExpanded = true)
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ 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_skipHalfExpanded(): Unit = runBlocking(
+ AutoTestFrameClock()
+ ) {
+ lateinit var sheetState: SheetState
+ rule.setContent {
+ sheetState = rememberSheetState(skipHalfExpanded = true)
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag)
+ )
+ }
+ }
+ assertThat(sheetState.currentValue == SheetValue.Expanded)
+
+ sheetState.hide()
+
+ assertThat(sheetState.currentValue == SheetValue.Hidden)
+ }
+
+ @Test
+ fun modalBottomSheet_testDismissAction_tallBottomSheet_whenHalfExpanded() {
+ rule.setContent {
+ ModalBottomSheet(onDismissRequest = {}) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag)
+ )
+ }
+ }
+
+ rule.onNodeWithTag(sheetTag).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 = rememberSheetState()
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag)
+ )
+ }
+ }
+
+ rule.onNodeWithTag(sheetTag).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 = rememberSheetState()
+ scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val resScreenHeight = context.resources.configuration.screenHeightDp
+ with(density) {
+ screenHeightPx = resScreenHeight.dp.roundToPx().toFloat()
+ }
+
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag)
+ )
+ }
+ }
+ scope.launch {
+ sheetState.expand()
+ }
+ rule.waitForIdle()
+
+ rule.onNodeWithTag(sheetTag).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 = rememberSheetState()
+ scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val resScreenHeight = context.resources.configuration.screenHeightDp
+ with(density) {
+ screenHeightPx = resScreenHeight.dp.roundToPx().toFloat()
+ }
+
+ ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .testTag(sheetTag)
+ )
+ }
+ }
+ scope.launch {
+ sheetState.expand()
+ }
+ rule.waitForIdle()
+
+ rule.onNodeWithTag(sheetTag).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(skipCollapsed = false)
+ var hasSheetContent by mutableStateOf(false) // Start out with empty sheet content
+ lateinit var scope: CoroutineScope
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ ModalBottomSheet(
+ onDismissRequest = {},
+ sheetState = sheetState,
+ dragHandle = null,
+ ) {
+ if (hasSheetContent) {
+ Box(Modifier.fillMaxHeight(0.4f))
+ }
+ }
+ }
+
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden)
+ assertThat(sheetState.swipeableState.hasAnchorForValue(SheetValue.Collapsed))
+ .isFalse()
+ assertThat(sheetState.swipeableState.hasAnchorForValue(SheetValue.Expanded))
+ .isFalse()
+
+ 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(skipCollapsed = false)
+ var hasSheetContent by mutableStateOf(false) // Start out with empty sheet content
+ lateinit var scope: CoroutineScope
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ ModalBottomSheet(
+ onDismissRequest = {},
+ sheetState = sheetState,
+ dragHandle = null,
+ ) {
+ if (hasSheetContent) {
+ Box(Modifier.fillMaxHeight(0.6f))
+ }
+ }
+ }
+
+ assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden)
+ assertThat(sheetState.swipeableState.hasAnchorForValue(SheetValue.Collapsed))
+ .isFalse()
+ assertThat(sheetState.swipeableState.hasAnchorForValue(SheetValue.Expanded))
+ .isFalse()
+
+ 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.Collapsed)
+ }
+}
\ No newline at end of file
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
new file mode 100644
index 0000000..a586945
--- /dev/null
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
@@ -0,0 +1,315 @@
+/*
+ * 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 androidx.compose.animation.core.TweenSpec
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.SheetValue.Expanded
+import androidx.compose.material3.SheetValue.Collapsed
+import androidx.compose.material3.SheetValue.Hidden
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.collapse
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.dismiss
+import androidx.compose.ui.semantics.expand
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import kotlin.math.max
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+// TODO: Upload material 3 assets for Bottom Sheet.
+/**
+ * <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
+ *
+ * Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
+ * especially when offering a long list of action items, or when items require longer descriptions
+ * and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
+ * app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
+ * required action has been taken.
+ *
+ * A simple example of a modal bottom sheet looks like this:
+ *
+ * @sample androidx.compose.material3.samples.ModalBottomSheetSample
+ *
+ * @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
+ * animates to [Hidden].
+ * @param modifier Optional [Modifier] for the bottom sheet.
+ * @param sheetState The state of the bottom sheet.
+ * @param shape The shape of the bottom sheet. By default, the shape changes from
+ * [BottomSheetDefaults.MinimizedShape] to [BottomSheetDefaults.ExpandedShape] when the
+ * sheet targets [Hidden] and non-Hidden states respectively.
+ * @param containerColor The color used for the background of this bottom sheet
+ * @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
+ * the matching content color for [containerColor], or to the current [LocalContentColor] if
+ * [containerColor] is not a color from the theme.
+ * @param tonalElevation The tonal elevation of this bottom sheet.
+ * @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
+ * @param dragHandle Optional visual marker to swipe the bottom sheet.
+ * @param content The content to be displayed inside the bottom sheet.
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun ModalBottomSheet(
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ sheetState: SheetState = rememberSheetState(),
+ shape: Shape = if (sheetState.targetValue == Hidden) BottomSheetDefaults.MinimizedShape
+ else BottomSheetDefaults.ExpandedShape,
+ containerColor: Color = BottomSheetDefaults.ContainerColor,
+ contentColor: Color = contentColorFor(containerColor),
+ tonalElevation: Dp = BottomSheetDefaults.Elevation,
+ scrimColor: Color = BottomSheetDefaults.ScrimColor,
+ dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+
+ // Callback that is invoked when the anchors have changed.
+ val anchorChangeHandler = remember(sheetState, scope) {
+ ModalBottomSheetAnchorChangeHandler(
+ state = sheetState,
+ animateTo = { target, velocity ->
+ scope.launch { sheetState.swipeableState.animateTo(target, velocity = velocity) }
+ },
+ snapTo = { target -> scope.launch { sheetState.swipeableState.snapTo(target) } }
+ )
+ }
+ val systemBarHeight = WindowInsets.systemBarsForVisualComponents.getBottom(LocalDensity.current)
+
+ Popup {
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val fullHeight = constraints.maxHeight
+ Scrim(
+ color = scrimColor,
+ onDismissRequest = {
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ if (!sheetState.isVisible) { onDismissRequest() }
+ }
+ },
+ visible = sheetState.targetValue != Hidden
+ )
+ Surface(
+ modifier = modifier
+ .widthIn(max = BottomSheetMaxWidth)
+ .fillMaxWidth()
+ .align(Alignment.TopCenter)
+ .offset {
+ IntOffset(
+ 0,
+ sheetState
+ .requireOffset()
+ .toInt()
+ )
+ }
+ .nestedScroll(
+ remember(sheetState.swipeableState, Orientation.Vertical) {
+ ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+ state = sheetState.swipeableState,
+ orientation = Orientation.Vertical
+ )
+ }
+ )
+ .modalBottomSheetSwipeable(
+ sheetState = sheetState,
+ scope = scope,
+ onDismissRequest = onDismissRequest,
+ anchorChangeHandler = anchorChangeHandler,
+ screenHeight = fullHeight.toFloat(),
+ bottomPadding = systemBarHeight.toFloat(),
+ ),
+ shape = shape,
+ color = containerColor,
+ contentColor = contentColor,
+ tonalElevation = tonalElevation,
+ ) {
+ Column(Modifier.fillMaxWidth()) {
+ if (dragHandle != null) {
+ Box(Modifier.align(Alignment.CenterHorizontally)) {
+ dragHandle()
+ }
+ }
+ content()
+ }
+ }
+ }
+ }
+ if (sheetState.hasExpandedState) {
+ LaunchedEffect(sheetState) {
+ sheetState.show()
+ }
+ }
+}
+
+@Composable
+private fun Scrim(
+ color: Color,
+ onDismissRequest: () -> Unit,
+ visible: Boolean
+) {
+ val sheetDescription = getString(Strings.CloseSheet)
+ if (color.isSpecified) {
+ val alpha by animateFloatAsState(
+ targetValue = if (visible) 1f else 0f,
+ animationSpec = TweenSpec()
+ )
+ val dismissSheet = if (visible) {
+ Modifier
+ .pointerInput(onDismissRequest) {
+ detectTapGestures {
+ onDismissRequest()
+ }
+ }
+ .semantics(mergeDescendants = true) {
+ contentDescription = sheetDescription
+ onClick { onDismissRequest(); true }
+ }
+ } else {
+ Modifier
+ }
+ Canvas(
+ Modifier
+ .fillMaxSize()
+ .then(dismissSheet)
+ ) {
+ drawRect(color = color, alpha = alpha)
+ }
+ }
+}
+
+@ExperimentalMaterial3Api
+private fun Modifier.modalBottomSheetSwipeable(
+ sheetState: SheetState,
+ scope: CoroutineScope,
+ onDismissRequest: () -> Unit,
+ anchorChangeHandler: AnchorChangeHandler<SheetValue>,
+ screenHeight: Float,
+ bottomPadding: Float,
+) = draggable(
+ state = sheetState.swipeableState.draggableState,
+ orientation = Orientation.Vertical,
+ enabled = sheetState.isVisible,
+ startDragImmediately = sheetState.swipeableState.isAnimationRunning,
+ onDragStopped = { velocity ->
+ try {
+ sheetState.settle(velocity)
+ } finally {
+ if (!sheetState.isVisible) onDismissRequest()
+ }
+ }
+).swipeAnchors(
+ state = sheetState.swipeableState,
+ anchorChangeHandler = anchorChangeHandler,
+ possibleValues = setOf(Hidden, Collapsed, Expanded),
+) { value, sheetSize ->
+ when (value) {
+ Hidden -> screenHeight + bottomPadding
+ Collapsed -> when {
+ sheetSize.height < screenHeight / 2 -> null
+ sheetState.skipCollapsed -> null
+ else -> sheetSize.height / 2f
+ }
+ Expanded -> if (sheetSize.height != 0) {
+ max(0f, screenHeight - sheetSize.height)
+ } else null
+ }
+}.semantics {
+ if (sheetState.isVisible) {
+ dismiss {
+ if (sheetState.swipeableState.confirmValueChange(Hidden)) {
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ if (!sheetState.isVisible) { onDismissRequest() }
+ }
+ }
+ true
+ }
+ if (sheetState.swipeableState.currentValue == Collapsed) {
+ expand {
+ if (sheetState.swipeableState.confirmValueChange(Expanded)) {
+ scope.launch { sheetState.expand() }
+ }
+ true
+ }
+ } else if (sheetState.hasCollapsedState) {
+ collapse {
+ if (sheetState.swipeableState.confirmValueChange(Collapsed)) {
+ scope.launch { sheetState.collapse() }
+ }
+ true
+ }
+ }
+ }
+}
+
+@ExperimentalMaterial3Api
+private fun ModalBottomSheetAnchorChangeHandler(
+ state: SheetState,
+ animateTo: (target: SheetValue, velocity: Float) -> Unit,
+ snapTo: (target: SheetValue) -> Unit,
+) = AnchorChangeHandler<SheetValue> { previousTarget, previousAnchors, newAnchors ->
+ val previousTargetOffset = previousAnchors[previousTarget]
+ val newTarget = when (previousTarget) {
+ Hidden -> Hidden
+ Collapsed, Expanded -> {
+ val hasCollapsedState = newAnchors.containsKey(Collapsed)
+ val newTarget = if (hasCollapsedState) Collapsed
+ else if (newAnchors.containsKey(Expanded)) Expanded else Hidden
+ newTarget
+ }
+ }
+ val newTargetOffset = newAnchors.getValue(newTarget)
+ if (newTargetOffset != previousTargetOffset) {
+ if (state.swipeableState.isAnimationRunning || previousAnchors.isEmpty()) {
+ // Re-target the animation to the new offset if it changed
+ animateTo(newTarget, state.swipeableState.lastVelocity)
+ } else {
+ // Snap to the new offset value of the target if no animation was running
+ snapTo(newTarget)
+ }
+ }
+}
+
+private val BottomSheetMaxWidth = 640.dp
\ No newline at end of file
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SheetDefaults.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SheetDefaults.kt
new file mode 100644
index 0000000..8149339
--- /dev/null
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SheetDefaults.kt
@@ -0,0 +1,326 @@
+/*
+ * 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 androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.SheetValue.Collapsed
+import androidx.compose.material3.SheetValue.Hidden
+import androidx.compose.material3.SheetValue.Expanded
+import androidx.compose.material3.tokens.ScrimTokens
+import androidx.compose.material3.tokens.SheetBottomTokens
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.CancellationException
+
+/**
+ * Create and [remember] a [SheetState].
+ *
+ * @param skipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should
+ * be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
+ * [Hidden] state when hiding the sheet, either programmatically or by user interaction.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun rememberSheetState(
+ skipHalfExpanded: Boolean = false,
+ confirmValueChange: (SheetValue) -> Boolean = { true }
+): SheetState {
+ return rememberSaveable(
+ skipHalfExpanded, confirmValueChange,
+ saver = SheetState.Saver(
+ skipHalfExpanded = skipHalfExpanded,
+ confirmValueChange = confirmValueChange
+ )
+ ) {
+ SheetState(skipHalfExpanded, confirmValueChange = confirmValueChange)
+ }
+}
+
+/**
+ * State of a sheet composable, such as [ModalBottomSheet]
+ *
+ * Contains states relating to it's swipe position as well as animations between state values.
+ *
+ * @param skipCollapsed Whether the collapsed state, if the bottom sheet is tall enough, should
+ * be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
+ * [Hidden] state when hiding the sheet, either programmatically or by user interaction.
+ * @param initialValue The initial value of the state.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
+ */
+@Stable
+@ExperimentalMaterial3Api
+class SheetState(
+ internal val skipCollapsed: Boolean,
+ initialValue: SheetValue = Hidden,
+ confirmValueChange: (SheetValue) -> Boolean = { true }
+) {
+ /**
+ * The current value of the state.
+ *
+ * If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
+ * currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
+ * was in before the swipe or animation started.
+ */
+ val currentValue: SheetValue get() = swipeableState.currentValue
+
+ /**
+ * The target value of the bottom sheet state.
+ *
+ * If a swipe is in progress, this is the value that the sheet would animate to if the
+ * swipe finishes. If an animation is running, this is the target value of that animation.
+ * Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
+ */
+ val targetValue: SheetValue get() = swipeableState.targetValue
+
+ /**
+ * Whether the bottom sheet is visible.
+ */
+ val isVisible: Boolean
+ get() = swipeableState.currentValue != Hidden
+
+ /**
+ * Require the current offset (in pixels) of the bottom sheet.
+ *
+ * @throws IllegalStateException If the offset has not been initialized yet
+ */
+ fun requireOffset(): Float = swipeableState.requireOffset()
+
+ /**
+ * Show the bottom sheet with animation and suspend until it's shown. If the sheet is taller
+ * than 50% of the parent's height, the bottom sheet will be half expanded. Otherwise it will be
+ * fully expanded.
+ *
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun show() {
+ val targetValue = when {
+ hasCollapsedState -> Collapsed
+ else -> Expanded
+ }
+ swipeableState.animateTo(targetValue)
+ }
+
+ /**
+ * Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
+ * been cancelled.
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun hide() {
+ swipeableState.animateTo(Hidden)
+ }
+
+ /**
+ * Hide the bottom sheet with animation and suspend until it is collapsed or animation has
+ * been cancelled.
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun collapse() {
+ swipeableState.animateTo(Collapsed)
+ }
+
+ companion object {
+ /**
+ * The default [Saver] implementation for [SheetState].
+ */
+ fun Saver(
+ skipHalfExpanded: Boolean,
+ confirmValueChange: (SheetValue) -> Boolean
+ ) = Saver<SheetState, SheetValue>(
+ save = { it.currentValue },
+ restore = { SheetState(skipHalfExpanded, it, confirmValueChange) }
+ )
+ }
+
+ internal var swipeableState = SwipeableV2State(
+ initialValue = initialValue,
+ animationSpec = SwipeableV2Defaults.AnimationSpec,
+ confirmValueChange = confirmValueChange,
+ )
+
+ internal val hasCollapsedState: Boolean
+ get() = swipeableState.hasAnchorForValue(Collapsed)
+
+ internal val hasExpandedState: Boolean
+ get() = swipeableState.hasAnchorForValue(Expanded)
+
+ internal val offset: Float? get() = swipeableState.offset
+
+ /**
+ * Fully expand the bottom sheet with animation and suspend until it if fully expanded or
+ * animation has been cancelled.
+ * *
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ internal suspend fun expand() {
+ swipeableState.animateTo(Expanded)
+ }
+
+ /**
+ * Find the closest anchor taking into account the velocity and settle at it with an animation.
+ */
+ internal suspend fun settle(velocity: Float) {
+ swipeableState.settle(velocity)
+ }
+}
+
+/**
+ * Possible values of [SheetState].
+ */
+@ExperimentalMaterial3Api
+enum class SheetValue {
+ /**
+ * The sheet is not visible.
+ */
+ Hidden,
+
+ /**
+ * The sheet is visible at full height.
+ */
+ Expanded,
+
+ /**
+ * The sheet is partially visible.
+ */
+ Collapsed,
+}
+
+/**
+ * Contains the default values used by [ModalBottomSheet].
+ */
+@Stable
+@ExperimentalMaterial3Api
+object BottomSheetDefaults {
+ /** The default shape for a [ModalBottomSheet] in a [Hidden] state. */
+ val MinimizedShape: Shape
+ @Composable get() =
+ SheetBottomTokens.DockedMinimizedContainerShape.toShape()
+
+ /** The default shape for a [ModalBottomSheet] in [Collapsed] and [Expanded] states. */
+ val ExpandedShape: Shape
+ @Composable get() =
+ SheetBottomTokens.DockedContainerShape.toShape()
+
+ /** The default container color for a bottom sheet. */
+ val ContainerColor: Color
+ @Composable get() =
+ SheetBottomTokens.DockedContainerColor.toColor()
+
+ /** The default elevation for a bottom sheet. */
+ val Elevation = SheetBottomTokens.DockedModalContainerElevation
+
+ /** The default color of the scrim overlay for background content. */
+ val ScrimColor: Color
+ @Composable get() =
+ ScrimTokens.ContainerColor.toColor().copy(ScrimTokens.ContainerOpacity)
+
+ @Composable
+ fun DragHandle(
+ modifier: Modifier = Modifier,
+ width: Dp = SheetBottomTokens.DockedDragHandleWidth,
+ height: Dp = SheetBottomTokens.DockedDragHandleHeight,
+ shape: Shape = MaterialTheme.shapes.extraLarge,
+ color: Color = SheetBottomTokens.DockedDragHandleColor.toColor()
+ .copy(SheetBottomTokens.DockedDragHandleOpacity),
+ ) {
+ Surface(
+ modifier = modifier.padding(vertical = DragHandleVerticalPadding),
+ color = color,
+ shape = shape
+ ) {
+ Box(
+ Modifier
+ .size(
+ width = width,
+ height = height
+ )
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+ state: SwipeableV2State<*>,
+ orientation: Orientation
+): NestedScrollConnection = object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ val delta = available.toFloat()
+ return if (delta < 0 && source == NestedScrollSource.Drag) {
+ state.dispatchRawDelta(delta).toOffset()
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource
+ ): Offset {
+ return if (source == NestedScrollSource.Drag) {
+ state.dispatchRawDelta(available.toFloat()).toOffset()
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override suspend fun onPreFling(available: Velocity): Velocity {
+ val toFling = available.toFloat()
+ val currentOffset = state.requireOffset()
+ return if (toFling < 0 && currentOffset > state.minBound) {
+ state.settle(velocity = toFling)
+ // since we go to the anchor with tween settling, consume all for the best UX
+ available
+ } else {
+ Velocity.Zero
+ }
+ }
+
+ override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+ state.settle(velocity = available.toFloat())
+ return available
+ }
+
+ private fun Float.toOffset(): Offset = Offset(
+ x = if (orientation == Orientation.Horizontal) this else 0f,
+ y = if (orientation == Orientation.Vertical) this else 0f
+ )
+
+ @JvmName("velocityToFloat")
+ private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
+
+ @JvmName("offsetToFloat")
+ private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
+}
+
+private val DragHandleVerticalPadding = 22.dp
\ No newline at end of file
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SwipeableV2.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SwipeableV2.kt
index 6a64e73..d57af0c 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SwipeableV2.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SwipeableV2.kt
@@ -119,9 +119,10 @@
}
}
if (previousAnchors != newAnchors) {
- state.updateAnchors(newAnchors)
- if (previousAnchors.isNotEmpty()) {
- anchorChangeHandler?.onAnchorsChanged(previousAnchors, newAnchors)
+ val previousTarget = state.targetValue
+ val stateRequiresCleanup = state.updateAnchors(newAnchors)
+ if (stateRequiresCleanup) {
+ anchorChangeHandler?.onAnchorsChanged(previousTarget, previousAnchors, newAnchors)
}
}
},
@@ -241,8 +242,8 @@
var lastVelocity: Float by mutableStateOf(0f)
private set
- private val minBound by derivedStateOf { anchors.minOrNull() ?: Float.NEGATIVE_INFINITY }
- private val maxBound by derivedStateOf { anchors.maxOrNull() ?: Float.POSITIVE_INFINITY }
+ val minBound by derivedStateOf { anchors.minOrNull() ?: Float.NEGATIVE_INFINITY }
+ val maxBound by derivedStateOf { anchors.maxOrNull() ?: Float.POSITIVE_INFINITY }
private var animationTarget: T? by mutableStateOf(null)
internal val draggableState = DraggableState {
@@ -253,12 +254,25 @@
internal var density: Density? = null
- internal fun updateAnchors(newAnchors: Map<T, Float>) {
+ /**
+ * Update the anchors.
+ * If the previous set of anchors was empty, attempt to update the offset to match the initial
+ * value's anchor.
+ *
+ * @return true if the state needs to be adjusted after updating the anchors, e.g. if the
+ * initial value is not found in the initial set of anchors. false if no further updates are
+ * needed.
+ */
+ internal fun updateAnchors(newAnchors: Map<T, Float>): Boolean {
val previousAnchorsEmpty = anchors.isEmpty()
anchors = newAnchors
- if (previousAnchorsEmpty) {
- offset = anchors.requireAnchor(this.currentValue)
- }
+ val initialValueHasAnchor = if (previousAnchorsEmpty) {
+ val initialValueAnchor = anchors[currentValue]
+ val initialValueHasAnchor = initialValueAnchor != null
+ if (initialValueHasAnchor) offset = initialValueAnchor
+ initialValueHasAnchor
+ } else true
+ return !initialValueHasAnchor || !previousAnchorsEmpty
}
/**
@@ -289,6 +303,8 @@
/**
* Animate to a [targetValue].
+ * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
+ * [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
@@ -300,30 +316,34 @@
targetValue: T,
velocity: Float = lastVelocity,
) {
- val targetOffset = anchors.requireAnchor(targetValue)
- try {
- draggableState.drag {
- animationTarget = targetValue
- var prev = offset ?: 0f
- animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
- // Our onDrag coerces the value within the bounds, but an animation may
- // overshoot, for example a spring animation or an overshooting interpolator
- // We respect the user's intention and allow the overshoot, but still use
- // DraggableState's drag for its mutex.
- offset = value
- prev = value
- lastVelocity = velocity
+ val targetOffset = anchors[targetValue]
+ if (targetOffset != null) {
+ try {
+ draggableState.drag {
+ animationTarget = targetValue
+ var prev = offset ?: 0f
+ animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
+ // Our onDrag coerces the value within the bounds, but an animation may
+ // overshoot, for example a spring animation or an overshooting interpolator
+ // We respect the user's intention and allow the overshoot, but still use
+ // DraggableState's drag for its mutex.
+ offset = value
+ prev = value
+ lastVelocity = velocity
+ }
+ lastVelocity = 0f
}
- lastVelocity = 0f
+ } finally {
+ animationTarget = null
+ val endOffset = requireOffset()
+ val endState = anchors
+ .entries
+ .firstOrNull { (_, anchorOffset) -> abs(anchorOffset - endOffset) < 0.5f }
+ ?.key
+ this.currentValue = endState ?: currentValue
}
- } finally {
- animationTarget = null
- val endOffset = requireOffset()
- val endState = anchors
- .entries
- .firstOrNull { (_, anchorOffset) -> abs(anchorOffset - endOffset) < 0.5f }
- ?.key
- this.currentValue = endState ?: currentValue
+ } else {
+ currentValue = targetValue
}
}
@@ -533,8 +553,7 @@
state: SwipeableV2State<T>,
animate: (target: T, velocity: Float) -> Unit,
snap: (target: T) -> Unit
- ) = AnchorChangeHandler { previousAnchors, newAnchors ->
- val previousTarget = state.targetValue
+ ) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors ->
val previousTargetOffset = previousAnchors[previousTarget]
val newTargetOffset = newAnchors[previousTarget]
if (previousTargetOffset != newTargetOffset) {
@@ -562,10 +581,15 @@
* Callback that is invoked when the anchors have changed, after the [SwipeableV2State] has been
* updated with them. Use this hook to re-launch animations or interrupt them if needed.
*
+ * @param previousTargetValue The target value before the anchors were updated
* @param previousAnchors The previously set anchors
* @param newAnchors The newly set anchors
*/
- fun onAnchorsChanged(previousAnchors: Map<T, Float>, newAnchors: Map<T, Float>)
+ fun onAnchorsChanged(
+ previousTargetValue: T,
+ previousAnchors: Map<T, Float>,
+ newAnchors: Map<T, Float>
+ )
}
@Stable
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/SheetBottomTokens.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/SheetBottomTokens.kt
new file mode 100644
index 0000000..ecc53a4
--- /dev/null
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/SheetBottomTokens.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+// VERSION: v0_126
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+package androidx.compose.material3.tokens
+
+import androidx.compose.ui.unit.dp
+
+internal object SheetBottomTokens {
+ val DockedContainerColor = ColorSchemeKeyTokens.Surface
+ val DockedContainerShape = ShapeKeyTokens.CornerExtraLargeTop
+ val DockedContainerSurfaceTintLayerColor = ColorSchemeKeyTokens.SurfaceTint
+ val DockedDragHandleColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val DockedDragHandleHeight = 4.0.dp
+ const val DockedDragHandleOpacity = 0.4f
+ val DockedDragHandleWidth = 32.0.dp
+ val DockedMinimizedContainerShape = ShapeKeyTokens.CornerNone
+ val DockedModalContainerElevation = ElevationTokens.Level1
+ val DockedStandardContainerElevation = ElevationTokens.Level1
+}
\ No newline at end of file