[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