[go: nahoru, domu]

Allow controlling WindowInsets

Relnote: "Added experimental imeNestedScroll()
modifier so that developers can control the IME through scrolling."

Test: new tests, manual testing on R and S

Change-Id: I6075942f67d2fbbdde97e5ce58f6fc871e51b7bc
diff --git a/compose/foundation/foundation-layout/api/current.txt b/compose/foundation/foundation-layout/api/current.txt
index ec3582e..1f5a59d 100644
--- a/compose/foundation/foundation-layout/api/current.txt
+++ b/compose/foundation/foundation-layout/api/current.txt
@@ -217,6 +217,9 @@
   public static final class WindowInsets.Companion {
   }
 
+  public final class WindowInsetsConnection_androidKt {
+  }
+
   public final class WindowInsetsKt {
     method public static androidx.compose.foundation.layout.WindowInsets WindowInsets(optional int left, optional int top, optional int right, optional int bottom);
     method public static androidx.compose.foundation.layout.WindowInsets WindowInsets(optional float left, optional float top, optional float right, optional float bottom);
diff --git a/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt b/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt
index 2e12685..9e0d3ee 100644
--- a/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt
+++ b/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt
@@ -220,6 +220,10 @@
   public static final class WindowInsets.Companion {
   }
 
+  public final class WindowInsetsConnection_androidKt {
+    method @androidx.compose.foundation.layout.ExperimentalLayoutApi public static androidx.compose.ui.Modifier imeNestedScroll(androidx.compose.ui.Modifier);
+  }
+
   public final class WindowInsetsKt {
     method public static androidx.compose.foundation.layout.WindowInsets WindowInsets(optional int left, optional int top, optional int right, optional int bottom);
     method public static androidx.compose.foundation.layout.WindowInsets WindowInsets(optional float left, optional float top, optional float right, optional float bottom);
diff --git a/compose/foundation/foundation-layout/api/restricted_current.txt b/compose/foundation/foundation-layout/api/restricted_current.txt
index 585f2e0..e1603e2 100644
--- a/compose/foundation/foundation-layout/api/restricted_current.txt
+++ b/compose/foundation/foundation-layout/api/restricted_current.txt
@@ -222,6 +222,9 @@
   public static final class WindowInsets.Companion {
   }
 
+  public final class WindowInsetsConnection_androidKt {
+  }
+
   public final class WindowInsetsKt {
     method public static androidx.compose.foundation.layout.WindowInsets WindowInsets(optional int left, optional int top, optional int right, optional int bottom);
     method public static androidx.compose.foundation.layout.WindowInsets WindowInsets(optional float left, optional float top, optional float right, optional float bottom);
diff --git a/compose/foundation/foundation-layout/build.gradle b/compose/foundation/foundation-layout/build.gradle
index 8498807..6a468dd 100644
--- a/compose/foundation/foundation-layout/build.gradle
+++ b/compose/foundation/foundation-layout/build.gradle
@@ -41,6 +41,7 @@
         implementation(project(":compose:runtime:runtime"))
         implementation("androidx.compose.ui:ui-util:1.0.0")
         implementation("androidx.core:core:1.7.0")
+        implementation("androidx.compose.animation:animation-core:1.1.1")
         implementation(libs.kotlinStdlibCommon)
 
         testImplementation(libs.testRules)
@@ -90,6 +91,7 @@
             androidMain.dependencies {
                 api("androidx.annotation:annotation:1.1.0")
                 implementation("androidx.core:core:1.7.0")
+                implementation("androidx.compose.animation:animation-core:1.1.1")
             }
 
             desktopMain.dependencies {
diff --git a/compose/foundation/foundation-layout/samples/src/main/java/androidx/compose/foundation/layout/samples/WindowInsetsConnectionSample.kt b/compose/foundation/foundation-layout/samples/src/main/java/androidx/compose/foundation/layout/samples/WindowInsetsConnectionSample.kt
new file mode 100644
index 0000000..8bd86e3
--- /dev/null
+++ b/compose/foundation/foundation-layout/samples/src/main/java/androidx/compose/foundation/layout/samples/WindowInsetsConnectionSample.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022 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.foundation.layout.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imeNestedScroll
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@OptIn(ExperimentalLayoutApi::class)
+@Sampled
+@Composable
+fun windowInsetsNestedScrollDemo() {
+    LazyColumn(
+        modifier = Modifier
+            .fillMaxSize() // fill the window
+            .imePadding() // pad out the bottom for the IME
+            .imeNestedScroll(), // scroll IME at the bottom
+        reverseLayout = true // First item is at the bottom
+    ) {
+        // content
+        items(50) {
+            Text("Hello World")
+        }
+    }
+}
diff --git a/compose/foundation/foundation-layout/src/androidAndroidTest/AndroidManifest.xml b/compose/foundation/foundation-layout/src/androidAndroidTest/AndroidManifest.xml
index 2f531ed..9f5c83b 100644
--- a/compose/foundation/foundation-layout/src/androidAndroidTest/AndroidManifest.xml
+++ b/compose/foundation/foundation-layout/src/androidAndroidTest/AndroidManifest.xml
@@ -19,5 +19,9 @@
         <activity
             android:name="androidx.compose.foundation.layout.TestActivity"
             android:theme="@android:style/Theme.Material.NoActionBar.Fullscreen" />
+        <activity
+            android:name="androidx.compose.foundation.layout.WindowInsetsActivity"
+            android:windowSoftInputMode="adjustResize"
+            android:theme="@android:style/Theme.Material.NoActionBar.Fullscreen" />
     </application>
 </manifest>
diff --git a/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsActivity.kt b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsActivity.kt
new file mode 100644
index 0000000..41e064f
--- /dev/null
+++ b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsActivity.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2019 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.foundation.layout
+
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.annotation.RequiresApi
+import java.util.concurrent.CountDownLatch
+
+class WindowInsetsActivity : ComponentActivity() {
+    val createdLatch = CountDownLatch(1)
+
+    @RequiresApi(Build.VERSION_CODES.R)
+    override fun onCreate(savedInstanceState: Bundle?) {
+        window.setDecorFitsSystemWindows(false)
+        super.onCreate(savedInstanceState)
+        createdLatch.countDown()
+    }
+}
diff --git a/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsControllerTest.kt b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsControllerTest.kt
new file mode 100644
index 0000000..410e493
--- /dev/null
+++ b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsControllerTest.kt
@@ -0,0 +1,689 @@
+/*
+ * Copyright 2022 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.foundation.layout
+
+import android.graphics.Insets
+import android.os.Build
+import android.os.SystemClock
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MonotonicFrameClock
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.onPlaced
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeDown
+import androidx.compose.ui.test.swipeUp
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Velocity
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.math.abs
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalLayoutApi::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+class WindowInsetsControllerTest {
+    @get:Rule
+    val rule = createAndroidComposeRule<WindowInsetsActivity>()
+
+    val testTag = "TestTag"
+
+    /**
+     * The size of the inset when shown.
+     */
+    private var shownSize = 0
+
+    /**
+     * This is the fling velocity that will move enough so that a spring will show at least
+     * 1 pixel of movement. This should be considered a small fling.
+     */
+    private val FlingToSpring1Pixel = 300f
+
+    // ========================================================
+    // The specific insets are extracted here so that different
+    // insets can be tested locally. IME works for R+, but
+    // status bars only work on S+. The following allows
+    // extracting out the insets particulars so that tests
+    // work with different insets types.
+    // ========================================================
+
+    /**
+     * The android WindowInsets type.
+     */
+    private val insetType = android.view.WindowInsets.Type.statusBars()
+    private val insetSide = WindowInsetsSides.Top
+
+    private val windowInsets: AndroidWindowInsets
+        @Composable
+        get() = WindowInsetsHolder.current().ime
+
+    private val WindowInsets.value: Int
+        get() = getBottom(Density(1f))
+
+    private val Insets.value: Int
+        get() = bottom
+
+    private val reverseLazyColumn = true
+
+    private fun TouchInjectionScope.swipeAwayFromInset() {
+        swipeUp()
+    }
+
+    private fun TouchInjectionScope.swipeTowardInset() {
+        swipeDown()
+    }
+
+    /**
+     * A motion in this direction moves away from the insets
+     */
+    val directionMultiplier: Float = -1f
+
+    private var shownAtStart = false
+
+    @Before
+    fun setup() {
+        rule.activity.createdLatch.await(1, TimeUnit.SECONDS)
+        rule.runOnUiThread {
+            val view = rule.activity.window.decorView
+            shownAtStart = view.rootWindowInsets.isVisible(insetType)
+        }
+    }
+    @After
+    fun teardown() {
+        rule.runOnUiThread {
+            val view = rule.activity.window.decorView
+            if (shownAtStart) {
+                view.windowInsetsController?.show(insetType)
+            } else {
+                view.windowInsetsController?.hide(insetType)
+            }
+        }
+    }
+
+    /**
+     * Scrolling away from the inset with the inset hidden should show it.
+     */
+    @Test
+    fun canScrollToShow() {
+        if (!initializeDeviceWithInsetsHidden()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+        lateinit var coordinates: LayoutCoordinates
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            Box(Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+                .onPlaced { coordinates = it }
+            )
+        }
+
+        val sizeBefore = coordinates.size
+
+        rule.runOnUiThread {
+            // The first scroll triggers the animation controller to be requested
+            val consumed = connection.onPostScroll(
+                consumed = Offset.Zero,
+                available = Offset(3f, directionMultiplier),
+                source = NestedScrollSource.Drag
+            )
+            assertThat(consumed).isEqualTo(Offset(0f, directionMultiplier))
+        }
+        // We don't know when the animation controller request will be fulfilled, so loop
+        // until we're sure
+        val startTime = SystemClock.uptimeMillis()
+        do {
+            assertThat(SystemClock.uptimeMillis()).isLessThan(startTime + 1000)
+            val size = rule.runOnUiThread {
+                connection.onPostScroll(
+                    consumed = Offset.Zero,
+                    available = Offset(3f, directionMultiplier * 5f),
+                    source = NestedScrollSource.Drag
+                )
+                coordinates.size
+            }
+        } while (size == sizeBefore)
+
+        rule.runOnIdle {
+            val sizeAfter = coordinates.size
+            assertThat(sizeBefore.height).isGreaterThan(sizeAfter.height)
+        }
+    }
+
+    /**
+     * Scrolling toward the inset with the inset shown should hide it.
+     */
+    @Test
+    fun canScrollToHide() {
+        if (!initializeDeviceWithInsetsShown()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+        lateinit var coordinates: LayoutCoordinates
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            Box(Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+                .onPlaced { coordinates = it }
+            )
+        }
+
+        val sizeBefore = coordinates.size
+
+        rule.runOnUiThread {
+            // The first scroll triggers the animation controller to be requested
+            val consumed = connection.onPreScroll(
+                available = Offset(3f, -directionMultiplier),
+                source = NestedScrollSource.Drag
+            )
+            assertThat(consumed).isEqualTo(Offset(0f, -directionMultiplier))
+        }
+        // We don't know when the animation controller request will be fulfilled, so loop
+        // until we're sure
+        val startTime = SystemClock.uptimeMillis()
+        do {
+            assertThat(SystemClock.uptimeMillis()).isLessThan(startTime + 1000)
+            val size = rule.runOnUiThread {
+                connection.onPreScroll(
+                    available = Offset(3f, directionMultiplier * -5f),
+                    source = NestedScrollSource.Drag
+                )
+                coordinates.size
+            }
+        } while (size == sizeBefore)
+
+        rule.runOnIdle {
+            val sizeAfter = coordinates.size
+            assertThat(sizeBefore.height).isLessThan(sizeAfter.height)
+        }
+    }
+
+    /**
+     * Flinging away from an inset should show it.
+     */
+    @Test
+    fun canFlingToShow() {
+        if (!initializeDeviceWithInsetsHidden()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+        lateinit var coordinates: LayoutCoordinates
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            Box(Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+                .onPlaced { coordinates = it }
+            )
+        }
+
+        val sizeBefore = coordinates.size
+
+        runBlockingOnUiThread {
+            val consumed = connection.onPostFling(
+                consumed = Velocity.Zero,
+                available = Velocity(3f, directionMultiplier * 5000f)
+            )
+            assertThat(consumed.x).isEqualTo(0f)
+            assertThat(abs(consumed.y)).isLessThan(5000f)
+        }
+
+        rule.runOnIdle {
+            val sizeAfter = coordinates.size
+            assertThat(sizeBefore.height).isGreaterThan(sizeAfter.height)
+        }
+    }
+
+    /**
+     * Flinging toward an inset should hide it.
+     */
+    @Test
+    fun canFlingToHide() {
+        if (!initializeDeviceWithInsetsShown()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+        lateinit var coordinates: LayoutCoordinates
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            Box(Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+                .onPlaced { coordinates = it }
+            )
+        }
+
+        val sizeBefore = coordinates.size
+
+        runBlockingOnUiThread {
+            val consumed = connection.onPreFling(
+                available = Velocity(3f, -directionMultiplier * 5000f)
+            )
+            assertThat(consumed.x).isEqualTo(0f)
+            assertThat(abs(consumed.y)).isLessThan(5000f)
+        }
+
+        rule.runOnIdle {
+            val sizeAfter = coordinates.size
+            assertThat(sizeBefore.height).isLessThan(sizeAfter.height)
+        }
+    }
+
+    /**
+     * A small fling should use an animation to bounce back to hiding the inset
+     */
+    @Test
+    fun smallFlingSpringsBackToHide() {
+        if (!initializeDeviceWithInsetsHidden()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+
+        var maxVisible = 0
+        var isVisible = false
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            maxVisible = maxOf(maxVisible, windowInsets.value)
+            isVisible = windowInsets.isVisible
+            Box(Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+            )
+        }
+
+        runBlockingOnUiThread {
+            connection.onPostFling(
+                consumed = Velocity.Zero,
+                available = Velocity(0f, directionMultiplier * FlingToSpring1Pixel)
+            )
+            assertThat(maxVisible).isGreaterThan(0)
+        }
+
+        rule.runOnIdle {
+            assertThat(isVisible).isFalse()
+        }
+    }
+
+    /**
+     * A small fling should use an animation to bounce back to showing the inset
+     */
+    @Test
+    fun smallFlingSpringsBackToShow() {
+        if (!initializeDeviceWithInsetsShown()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+
+        var minVisible = 0
+        var isVisible = false
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            minVisible = minOf(minVisible, windowInsets.value)
+            isVisible = windowInsets.isVisible
+            Box(Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+            )
+        }
+
+        runBlockingOnUiThread {
+            connection.onPostFling(
+                consumed = Velocity.Zero,
+                available = Velocity(0f, directionMultiplier * FlingToSpring1Pixel)
+            )
+            assertThat(minVisible).isLessThan(shownSize)
+        }
+
+        rule.runOnIdle {
+            assertThat(isVisible).isTrue()
+        }
+    }
+
+    /**
+     * A fling past the middle should animate to fully showing the inset
+     */
+    @Test
+    fun flingPastMiddleSpringsToShow() {
+        if (!initializeDeviceWithInsetsHidden()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+
+        var isVisible = false
+        var insetsSize = 0
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            isVisible = windowInsets.isVisible
+            insetsSize = windowInsets.value
+            Box(Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+            )
+        }
+
+        // We don't know when the animation controller request will be fulfilled, so loop
+        // we scroll
+        val startTime = SystemClock.uptimeMillis()
+        do {
+            assertThat(SystemClock.uptimeMillis()).isLessThan(startTime + 1000)
+            rule.runOnIdle {
+                connection.onPostScroll(
+                    consumed = Offset.Zero,
+                    available = Offset(0f, directionMultiplier),
+                    source = NestedScrollSource.Drag
+                )
+            }
+        } while (!isVisible)
+
+        // now scroll to just short of half way
+        rule.runOnIdle {
+            val sizeDifference = shownSize / 2f - 1f - insetsSize
+            connection.onPostScroll(
+                consumed = Offset.Zero,
+                available = Offset(0f, directionMultiplier * sizeDifference),
+                source = NestedScrollSource.Drag
+            )
+        }
+
+        rule.waitForIdle()
+
+        runBlockingOnUiThread {
+            connection.onPostFling(
+                consumed = Velocity.Zero,
+                available = Velocity(0f, directionMultiplier * FlingToSpring1Pixel)
+            )
+        }
+
+        rule.runOnIdle {
+            assertThat(isVisible).isTrue()
+        }
+    }
+
+    /**
+     * A fling that moves more than half way toward hiding should animate to fully hiding the inset
+     */
+    @Test
+    fun flingPastMiddleSpringsToHide() {
+        if (!initializeDeviceWithInsetsShown()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+
+        var isVisible = false
+        var insetsSize = 0
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            isVisible = windowInsets.isVisible
+            insetsSize = windowInsets.value
+            Box(Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+            )
+        }
+
+        // We don't know when the animation controller request will be fulfilled, so loop
+        // we scroll
+        val startTime = SystemClock.uptimeMillis()
+        do {
+            assertThat(SystemClock.uptimeMillis()).isLessThan(startTime + 1000)
+            rule.runOnIdle {
+                connection.onPreScroll(
+                    available = Offset(0f, directionMultiplier * -1f),
+                    source = NestedScrollSource.Drag
+                )
+            }
+        } while (insetsSize != shownSize)
+
+        // now scroll to just short of half way
+        rule.runOnIdle {
+            val sizeDifference = shownSize / 2f + 1f - insetsSize
+            connection.onPreScroll(
+                available = Offset(0f, directionMultiplier * sizeDifference),
+                source = NestedScrollSource.Drag
+            )
+        }
+
+        runBlockingOnUiThread {
+            // should fling at least one pixel past the middle
+            connection.onPreFling(
+                available = Velocity(0f, directionMultiplier * -FlingToSpring1Pixel)
+            )
+        }
+
+        rule.runOnIdle {
+            assertThat(isVisible).isFalse()
+        }
+    }
+
+    /**
+     * The insets shouldn't get in the way of normal scrolling on the normal content.
+     */
+    @Test
+    fun allowsContentScroll() {
+        if (!initializeDeviceWithInsetsHidden()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+        lateinit var coordinates: LayoutCoordinates
+        val lazyListState = LazyListState()
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            val boxSize = with(LocalDensity.current) { 100.toDp() }
+            LazyColumn(
+                reverseLayout = reverseLazyColumn,
+                state = lazyListState,
+                modifier = Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+                .testTag(testTag)
+                .onPlaced { coordinates = it }
+            ) {
+                items(1000) {
+                    Box(Modifier.size(boxSize))
+                }
+            }
+        }
+
+        val sizeBefore = coordinates.size
+
+        rule.onNodeWithTag(testTag)
+            .performTouchInput {
+                swipeTowardInset()
+            }
+
+        rule.runOnIdle {
+            assertThat(coordinates.size.height).isEqualTo(sizeBefore.height)
+            // The drag should result in 1 item scrolled, but the fling should give more than 1
+            assertThat(lazyListState.firstVisibleItemIndex).isGreaterThan(2)
+        }
+
+        val firstVisibleIndex = lazyListState.firstVisibleItemIndex
+
+        rule.onNodeWithTag(testTag)
+            .performTouchInput {
+                swipeAwayFromInset()
+            }
+
+        rule.runOnIdle {
+            assertThat(coordinates.size.height).isEqualTo(sizeBefore.height)
+            // The drag should result in 1 item scrolled, but the fling should give more than 1
+            assertThat(lazyListState.firstVisibleItemIndex).isLessThan(firstVisibleIndex - 2)
+        }
+    }
+
+    /**
+     * When flinging more than the inset, it should animate the insets closed and then fling
+     * the content.
+     */
+    @Test
+    fun flingRemainderMovesContent() {
+        if (!initializeDeviceWithInsetsShown()) {
+            return // The insets don't exist on this device
+        }
+        lateinit var connection: NestedScrollConnection
+        lateinit var coordinates: LayoutCoordinates
+        val lazyListState = LazyListState()
+
+        rule.setContent {
+            connection =
+                rememberWindowInsetsConnection(windowInsets, insetSide)
+            val boxSize = with(LocalDensity.current) { 100.toDp() }
+            LazyColumn(
+                reverseLayout = reverseLazyColumn,
+                state = lazyListState,
+                modifier = Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(windowInsets)
+                .nestedScroll(connection)
+                .testTag(testTag)
+                .onPlaced { coordinates = it }
+            ) {
+                items(1000) {
+                    Box(Modifier.size(boxSize))
+                }
+            }
+        }
+
+        val sizeBefore = coordinates.size
+
+        rule.onNodeWithTag(testTag)
+            .performTouchInput {
+                swipeTowardInset()
+            }
+
+        rule.runOnIdle {
+            assertThat(coordinates.size.height).isGreaterThan(sizeBefore.height)
+            // The fling should get at least one item moved
+            assertThat(lazyListState.firstVisibleItemIndex).isGreaterThan(0)
+        }
+    }
+
+    private fun initializeDeviceWithInsetsShown(): Boolean {
+        val view = rule.activity.window.decorView
+
+        rule.runOnUiThread {
+            view.windowInsetsController?.show(insetType)
+        }
+
+        return rule.runOnIdle {
+            val windowInsets = view.rootWindowInsets
+            val insets = windowInsets.getInsets(insetType)
+            shownSize = insets.value
+            windowInsets.isVisible(insetType) && insets.value != 0
+        }
+    }
+
+    private fun initializeDeviceWithInsetsHidden(): Boolean {
+        if (!initializeDeviceWithInsetsShown()) {
+            return false
+        }
+        val view = rule.activity.window.decorView
+        rule.runOnUiThread {
+            view.windowInsetsController?.hide(insetType)
+        }
+        return rule.runOnUiThread {
+            val windowInsets = view.rootWindowInsets
+            !windowInsets.isVisible(insetType)
+        }
+    }
+
+    @OptIn(DelicateCoroutinesApi::class)
+    private fun runBlockingOnUiThread(block: suspend CoroutineScope.() -> Unit) {
+        val latch = CountDownLatch(1)
+        val clock = MyTestFrameClock()
+        GlobalScope.launch(Dispatchers.Main) {
+            val context = coroutineContext + clock
+            withContext(context, block)
+            latch.countDown()
+        }
+        var frameTimeNanos = 0L
+        while (latch.count > 0) {
+            frameTimeNanos += 4_000_000L // 4ms
+            clock.trySendFrame(frameTimeNanos)
+            rule.waitForIdle()
+        }
+    }
+
+    private class MyTestFrameClock : MonotonicFrameClock {
+        private val frameCh = Channel<Long>(1)
+
+        fun trySendFrame(frameTimeNanos: Long) {
+            frameCh.trySend(frameTimeNanos)
+        }
+
+        override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
+            return onFrame(frameCh.receive())
+        }
+    }
+}
diff --git a/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsIgnoringVisibilityTest.kt b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsIgnoringVisibilityTest.kt
index 671d982..7481422 100644
--- a/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsIgnoringVisibilityTest.kt
+++ b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsIgnoringVisibilityTest.kt
@@ -404,4 +404,4 @@
             return insets.toWindowInsets()!!
         }
     }
-}
\ No newline at end of file
+}
diff --git a/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsets.android.kt b/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsets.android.kt
index 9d59fb7..d0b595673 100644
--- a/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsets.android.kt
+++ b/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsets.android.kt
@@ -18,13 +18,14 @@
 
 import androidx.core.graphics.Insets as AndroidXInsets
 import android.os.Build
+import android.os.SystemClock
 import android.view.View
 import androidx.annotation.DoNotInline
 import androidx.annotation.RequiresApi
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
@@ -37,6 +38,8 @@
 import androidx.core.view.WindowInsetsCompat
 import java.util.WeakHashMap
 import androidx.compose.ui.R
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
 import org.jetbrains.annotations.TestOnly
 
 internal fun AndroidXInsets.toInsetsValues(): InsetsValues =
@@ -46,6 +49,62 @@
     ValueInsets(insets.toInsetsValues(), name)
 
 /**
+ * [WindowInsets] provided by the Android framework. These can be used in
+ * [rememberWindowInsetsConnection] to control the insets.
+ */
+@Stable
+internal class AndroidWindowInsets(
+    internal val type: Int,
+    private val name: String
+) : WindowInsets {
+    internal var insets by mutableStateOf(AndroidXInsets.NONE)
+
+    /**
+     * Returns whether the insets are visible, irrespective of whether or not they
+     * intersect with the Window.
+     */
+    var isVisible by mutableStateOf(true)
+        private set
+
+    override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int {
+        return insets.left
+    }
+
+    override fun getTop(density: Density): Int {
+        return insets.top
+    }
+
+    override fun getRight(density: Density, layoutDirection: LayoutDirection): Int {
+        return insets.right
+    }
+
+    override fun getBottom(density: Density): Int {
+        return insets.bottom
+    }
+
+    @OptIn(ExperimentalLayoutApi::class)
+    internal fun update(windowInsetsCompat: WindowInsetsCompat) {
+        insets = windowInsetsCompat.getInsets(type)
+        isVisible = windowInsetsCompat.isVisible(type)
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is AndroidWindowInsets) return false
+
+        return type == other.type
+    }
+
+    override fun hashCode(): Int {
+        return type
+    }
+
+    override fun toString(): String {
+        return "$name(${insets.left}, ${insets.top}, ${insets.right}, ${insets.bottom})"
+    }
+}
+
+/**
  * Indicates whether access to [WindowInsets] within the [content][ComposeView.setContent]
  * should consume the Android  [android.view.WindowInsets]. The default value is `true`, meaning
  * that access to [WindowInsets.Companion] will consume the Android WindowInsets.
@@ -251,7 +310,7 @@
     @ExperimentalLayoutApi
     @Composable
     @NonRestartableComposable
-    get() = WindowInsetsHolder.current().isCaptionBarVisible
+    get() = WindowInsetsHolder.current().captionBar.isVisible
 
 /**
  * `true` when the [soft keyboard][ime] is being displayed, irrespective of
@@ -263,7 +322,7 @@
     @ExperimentalLayoutApi
     @Composable
     @NonRestartableComposable
-    get() = WindowInsetsHolder.current().isImeVisible
+    get() = WindowInsetsHolder.current().ime.isVisible
 
 /**
  * `true` when the [statusBars] are being displayed, irrespective of
@@ -275,7 +334,7 @@
     @ExperimentalLayoutApi
     @Composable
     @NonRestartableComposable
-    get() = WindowInsetsHolder.current().areStatusBarsVisible
+    get() = WindowInsetsHolder.current().statusBars.isVisible
 
 /**
  * `true` when the [navigationBars] are being displayed, irrespective of
@@ -287,7 +346,7 @@
     @ExperimentalLayoutApi
     @Composable
     @NonRestartableComposable
-    get() = WindowInsetsHolder.current().areNavigationBarsVisible
+    get() = WindowInsetsHolder.current().navigationBars.isVisible
 
 /**
  * `true` when the [systemBars] are being displayed, irrespective of
@@ -299,7 +358,7 @@
     @ExperimentalLayoutApi
     @Composable
     @NonRestartableComposable
-    get() = WindowInsetsHolder.current().areSystemBarsVisible
+    get() = WindowInsetsHolder.current().systemBars.isVisible
 /**
  * `true` when the [tappableElement] is being displayed, irrespective of
  * whether they intersects with the Window.
@@ -310,32 +369,33 @@
     @ExperimentalLayoutApi
     @Composable
     @NonRestartableComposable
-    get() = WindowInsetsHolder.current().isTappableElementVisible
+    get() = WindowInsetsHolder.current().tappableElement.isVisible
 
 /**
  * The insets for various values in the current window.
  */
+@OptIn(ExperimentalLayoutApi::class)
 internal class WindowInsetsHolder private constructor(insets: WindowInsetsCompat?) {
     val captionBar =
-        valueInsets(insets, WindowInsetsCompat.Type.captionBar(), "captionBar")
+        systemInsets(insets, WindowInsetsCompat.Type.captionBar(), "captionBar")
     val displayCutout =
-        valueInsets(insets, WindowInsetsCompat.Type.displayCutout(), "displayCutout")
-    val ime = valueInsets(insets, WindowInsetsCompat.Type.ime(), "ime")
-    val mandatorySystemGestures = valueInsets(
+        systemInsets(insets, WindowInsetsCompat.Type.displayCutout(), "displayCutout")
+    val ime = systemInsets(insets, WindowInsetsCompat.Type.ime(), "ime")
+    val mandatorySystemGestures = systemInsets(
         insets,
         WindowInsetsCompat.Type.mandatorySystemGestures(),
         "mandatorySystemGestures"
     )
     val navigationBars =
-        valueInsets(insets, WindowInsetsCompat.Type.navigationBars(), "navigationBars")
+        systemInsets(insets, WindowInsetsCompat.Type.navigationBars(), "navigationBars")
     val statusBars =
-        valueInsets(insets, WindowInsetsCompat.Type.statusBars(), "statusBars")
+        systemInsets(insets, WindowInsetsCompat.Type.statusBars(), "statusBars")
     val systemBars =
-        valueInsets(insets, WindowInsetsCompat.Type.systemBars(), "systemBars")
+        systemInsets(insets, WindowInsetsCompat.Type.systemBars(), "systemBars")
     val systemGestures =
-        valueInsets(insets, WindowInsetsCompat.Type.systemGestures(), "systemGestures")
+        systemInsets(insets, WindowInsetsCompat.Type.systemGestures(), "systemGestures")
     val tappableElement =
-        valueInsets(insets, WindowInsetsCompat.Type.tappableElement(), "tappableElement")
+        systemInsets(insets, WindowInsetsCompat.Type.tappableElement(), "tappableElement")
     val waterfall =
         ValueInsets(insets?.displayCutout?.waterfallInsets ?: AndroidXInsets.NONE, "waterfall")
     val safeDrawing =
@@ -368,19 +428,6 @@
         "tappableElementIgnoringVisibility"
     )
 
-    var isCaptionBarVisible by mutableStateIsVisible(insets, WindowInsetsCompat.Type.captionBar())
-    var isImeVisible by mutableStateIsVisible(insets, WindowInsetsCompat.Type.ime())
-    var areNavigationBarsVisible by mutableStateIsVisible(
-        insets,
-        WindowInsetsCompat.Type.navigationBars()
-    )
-    var areStatusBarsVisible by mutableStateIsVisible(insets, WindowInsetsCompat.Type.statusBars())
-    var areSystemBarsVisible by mutableStateIsVisible(insets, WindowInsetsCompat.Type.systemBars())
-    var isTappableElementVisible by mutableStateIsVisible(
-        insets,
-        WindowInsetsCompat.Type.tappableElement()
-    )
-
     /**
      * `true` unless the `ComposeView` [ComposeView.consumeWindowInsets] is set to `false`.
      */
@@ -431,63 +478,48 @@
      * Updates the WindowInsets values and notifies changes.
      */
     fun update(windowInsets: WindowInsetsCompat) {
-        Snapshot.withMutableSnapshot {
-            val insets = if (testInsets) {
-                // WindowInsetsCompat erases insets that aren't part of the device.
-                // For example, if there is no navigation bar because of hardware keys,
-                // the bottom navigation bar will be removed. By using the constructor
-                // that doesn't accept a View, it doesn't remove the insets that aren't
-                // possible. This is important for testing on arbitrary hardware.
-                WindowInsetsCompat.toWindowInsetsCompat(windowInsets.toWindowInsets()!!)
-            } else {
-                windowInsets
-            }
-            captionBar.value =
-                insets.getInsets(WindowInsetsCompat.Type.captionBar()).toInsetsValues()
-            captionBarIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
-                WindowInsetsCompat.Type.captionBar()
-            ).toInsetsValues()
-            isCaptionBarVisible = insets.isVisible(WindowInsetsCompat.Type.captionBar())
-            ime.value =
-                insets.getInsets(WindowInsetsCompat.Type.ime()).toInsetsValues()
-            isImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
-            displayCutout.value =
-                insets.getInsets(WindowInsetsCompat.Type.displayCutout()).toInsetsValues()
-            navigationBars.value =
-                insets.getInsets(WindowInsetsCompat.Type.navigationBars()).toInsetsValues()
-            navigationBarsIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
-                WindowInsetsCompat.Type.navigationBars()
-            ).toInsetsValues()
-            areNavigationBarsVisible = insets.isVisible(WindowInsetsCompat.Type.navigationBars())
-            statusBars.value =
-                insets.getInsets(WindowInsetsCompat.Type.statusBars()).toInsetsValues()
-            statusBarsIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
-                WindowInsetsCompat.Type.statusBars()
-            ).toInsetsValues()
-            areStatusBarsVisible = insets.isVisible(WindowInsetsCompat.Type.statusBars())
-            systemBars.value =
-                insets.getInsets(WindowInsetsCompat.Type.systemBars()).toInsetsValues()
-            systemBarsIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
-                WindowInsetsCompat.Type.systemBars()
-            ).toInsetsValues()
-            areSystemBarsVisible = insets.isVisible(WindowInsetsCompat.Type.systemBars())
-            systemGestures.value =
-                insets.getInsets(WindowInsetsCompat.Type.systemGestures()).toInsetsValues()
-            tappableElement.value =
-                insets.getInsets(WindowInsetsCompat.Type.tappableElement()).toInsetsValues()
-            tappableElementIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
-                WindowInsetsCompat.Type.tappableElement()
-            ).toInsetsValues()
-            isTappableElementVisible = insets.isVisible(WindowInsetsCompat.Type.tappableElement())
-            mandatorySystemGestures.value =
-                insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()).toInsetsValues()
-
-            val cutout = insets.displayCutout
-            if (cutout != null) {
-                val waterfallInsets = cutout.waterfallInsets
-                waterfall.value = waterfallInsets.toInsetsValues()
-            }
+        val insets = if (testInsets) {
+            // WindowInsetsCompat erases insets that aren't part of the device.
+            // For example, if there is no navigation bar because of hardware keys,
+            // the bottom navigation bar will be removed. By using the constructor
+            // that doesn't accept a View, it doesn't remove the insets that aren't
+            // possible. This is important for testing on arbitrary hardware.
+            WindowInsetsCompat.toWindowInsetsCompat(windowInsets.toWindowInsets()!!)
+        } else {
+            windowInsets
         }
+        captionBar.update(insets)
+        ime.update(insets)
+        displayCutout.update(insets)
+        navigationBars.update(insets)
+        statusBars.update(insets)
+        systemBars.update(insets)
+        systemGestures.update(insets)
+        tappableElement.update(insets)
+        mandatorySystemGestures.update(insets)
+
+        captionBarIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
+            WindowInsetsCompat.Type.captionBar()
+        ).toInsetsValues()
+        navigationBarsIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
+            WindowInsetsCompat.Type.navigationBars()
+        ).toInsetsValues()
+        statusBarsIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
+            WindowInsetsCompat.Type.statusBars()
+        ).toInsetsValues()
+        systemBarsIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
+            WindowInsetsCompat.Type.systemBars()
+        ).toInsetsValues()
+        tappableElementIgnoringVisibility.value = insets.getInsetsIgnoringVisibility(
+            WindowInsetsCompat.Type.tappableElement()
+        ).toInsetsValues()
+
+        val cutout = insets.displayCutout
+        if (cutout != null) {
+            val waterfallInsets = cutout.waterfallInsets
+            waterfall.value = waterfallInsets.toInsetsValues()
+        }
+        Snapshot.sendApplyNotifications()
     }
 
     companion object {
@@ -546,14 +578,11 @@
         /**
          * Creates a [ValueInsets] using the value from [windowInsets] if it isn't `null`
          */
-        private fun valueInsets(
+        private fun systemInsets(
             windowInsets: WindowInsetsCompat?,
             type: Int,
             name: String
-        ): ValueInsets {
-            val initial = windowInsets?.getInsets(type) ?: AndroidXInsets.NONE
-            return ValueInsets(initial, name)
-        }
+        ) = AndroidWindowInsets(type, name).apply { windowInsets?.let { update(it) } }
 
         /**
          * Creates a [ValueInsets] using the "ignoring visibility" value from [windowInsets]
@@ -567,18 +596,6 @@
             val initial = windowInsets?.getInsetsIgnoringVisibility(type) ?: AndroidXInsets.NONE
             return ValueInsets(initial, name)
         }
-
-        /**
-         * Creates a [ValueInsets] using the "ignoring visibility" value from [windowInsets]
-         * if it isn't `null`
-         */
-        private fun mutableStateIsVisible(
-            windowInsets: WindowInsetsCompat?,
-            type: Int
-        ): MutableState<Boolean> {
-            val initial = windowInsets?.isVisible(type) ?: true
-            return mutableStateOf(initial)
-        }
     }
 }
 
@@ -598,17 +615,78 @@
 private class InsetsListener(
     val composeInsets: WindowInsetsHolder,
 ) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP), OnApplyWindowInsetsListener {
+    /**
+     * When [android.view.WindowInsetsController.controlWindowInsetsAnimation] is called,
+     * the [onApplyWindowInsets] is called after [onPrepare] with the target size. We
+     * don't want to report the target size, we want to always report the current size,
+     * so we must ignore those calls. However, the animation may be canceled before it
+     * progresses. On R, it won't make any callbacks, so we have to figure out whether
+     * the [onApplyWindowInsets] is from a canceled animation or if it is from the
+     * controlled animation. We just have to guess that if we don't receive an [onStart]
+     * before a certain time that the animation has been canceled, and to treat the
+     * [onApplyWindowInsets] as a real call. [prepareGiveUpTime] has the time that we
+     * give up waiting for the [onStart] or [onEnd].
+     */
+    var prepareGiveUpTime = 0L
+
+    /**
+     * `true` if the [onStart] has been called, so we know that we're part of an animation
+     * and [onApplyWindowInsets] calls should be ignored.
+     */
+    var started = false
+
+    override fun onPrepare(animation: WindowInsetsAnimationCompat) {
+        prepareGiveUpTime = SystemClock.uptimeMillis() + AnimationCanceledMillis
+        super.onPrepare(animation)
+    }
+
+    override fun onStart(
+        animation: WindowInsetsAnimationCompat,
+        bounds: WindowInsetsAnimationCompat.BoundsCompat
+    ): WindowInsetsAnimationCompat.BoundsCompat {
+        started = true
+        return super.onStart(animation, bounds)
+    }
 
     override fun onProgress(
         insets: WindowInsetsCompat,
         runningAnimations: MutableList<WindowInsetsAnimationCompat>
     ): WindowInsetsCompat {
+        prepareGiveUpTime = 0L
         composeInsets.update(insets)
         return if (composeInsets.consumes) WindowInsetsCompat.CONSUMED else insets
     }
 
+    override fun onEnd(animation: WindowInsetsAnimationCompat) {
+        started = false
+        prepareGiveUpTime = 0L
+        super.onEnd(animation)
+    }
+
     override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat {
+        val prepareGiveUpTime = prepareGiveUpTime
+        this.prepareGiveUpTime = 0L
+
+        // There may be no callback on R if the animation is canceled after onPrepare(),
+        // so we won't know if the onPrepare() was canceled or if the
+        // So we must allow onApplyWindowInsets() to run if it isn't directly after the
+        // onPrepare().
+        val preparing = prepareGiveUpTime != 0L &&
+            (Build.VERSION.SDK_INT > Build.VERSION_CODES.R ||
+                prepareGiveUpTime > SystemClock.uptimeMillis())
+        if (started || preparing) {
+            // Just ignore this one. It came from the onPrepare.
+            return insets
+        }
         composeInsets.update(insets)
         return if (composeInsets.consumes) WindowInsetsCompat.CONSUMED else insets
     }
+
+    companion object {
+        // If an [onApplyWindowInsets] is received this number of milliseconds after
+        // [onPrepare] and the animation hasn't started, then it is assumed that the
+        // animation was canceled before starting. On R and earlier, we don't get any
+        // signal about cancellation.
+        const val AnimationCanceledMillis = 100L
+    }
 }
diff --git a/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsetsConnection.android.kt b/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsetsConnection.android.kt
new file mode 100644
index 0000000..e406da8
--- /dev/null
+++ b/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsetsConnection.android.kt
@@ -0,0 +1,708 @@
+/*
+ * Copyright 2022 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.foundation.layout
+
+import android.graphics.Insets
+import android.os.Build
+import android.os.CancellationSignal
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.WindowInsetsAnimationControlListener
+import android.view.WindowInsetsAnimationController
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FloatDecayAnimationSpec
+import androidx.compose.animation.core.animateDecay
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.Velocity
+import kotlin.math.roundToInt
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.util.packFloats
+import androidx.compose.ui.util.unpackFloat1
+import androidx.compose.ui.util.unpackFloat2
+import kotlin.math.abs
+import kotlin.math.exp
+import kotlin.math.ln
+import kotlin.math.sign
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+/**
+ * Controls the soft keyboard as a nested scrolling on Android [R][Build.VERSION_CODES.R]
+ * and later. This allows the user to drag the soft keyboard up and down.
+ *
+ * After scrolling, the IME will animate either to the fully shown or fully hidden position,
+ * depending on the position and fling.
+ *
+ * @sample androidx.compose.foundation.layout.samples.windowInsetsNestedScrollDemo
+ */
+@ExperimentalLayoutApi
+fun Modifier.imeNestedScroll(): Modifier {
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+        return this
+    }
+    return composed(
+        debugInspectorInfo {
+            name = "imeNestedScroll"
+        }
+    ) {
+        val nestedScrollConnection = rememberWindowInsetsConnection(
+            WindowInsetsHolder.current().ime,
+            WindowInsetsSides.Bottom
+        )
+        nestedScroll(nestedScrollConnection)
+    }
+}
+
+/**
+ * Returns a [NestedScrollConnection] that can be used with [WindowInsets] on Android
+ * [R][Build.VERSION_CODES.R] and later.
+ *
+ * The [NestedScrollConnection] can be used when a developer wants to control a [WindowInsets],
+ * either directly animating it or allowing the user to manually manipulate it. User interactions
+ * will result in the [WindowInsets] animating either hidden or shown, depending on its
+ * current position and the fling velocity received in [NestedScrollConnection.onPreFling] and
+ * [NestedScrollConnection.onPostFling].
+ *
+ * @param windowInsets The insets to be changed by the scroll effect
+ * @param side The side of the [windowInsets] that is to be affected. Can only be one of
+ * [WindowInsetsSides.Left], [WindowInsetsSides.Top], [WindowInsetsSides.Right],
+ * [WindowInsetsSides.Bottom], [WindowInsetsSides.Start], [WindowInsetsSides.End].
+ */
+@ExperimentalLayoutApi
+@Composable
+internal fun rememberWindowInsetsConnection(
+    windowInsets: AndroidWindowInsets,
+    side: WindowInsetsSides
+): NestedScrollConnection {
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+        return DoNothingNestedScrollConnection
+    }
+    val layoutDirection = LocalLayoutDirection.current
+    val sideCalculator = SideCalculator.chooseCalculator(side, layoutDirection)
+    val view = LocalView.current
+    val density = LocalDensity.current
+    val connection = remember(windowInsets, view, sideCalculator, density) {
+        WindowInsetsNestedScrollConnection(windowInsets, view, sideCalculator, density)
+    }
+    DisposableEffect(connection) {
+        onDispose {
+            connection.dispose()
+        }
+    }
+    return connection
+}
+
+/**
+ * A [NestedScrollConnection] that does nothing, for versions before R.
+ */
+private object DoNothingNestedScrollConnection : NestedScrollConnection
+
+@OptIn(ExperimentalCoroutinesApi::class, ExperimentalLayoutApi::class)
+@RequiresApi(Build.VERSION_CODES.R)
+private class WindowInsetsNestedScrollConnection(
+    val windowInsets: AndroidWindowInsets,
+    val view: View,
+    val sideCalculator: SideCalculator,
+    val density: Density
+) : NestedScrollConnection,
+    WindowInsetsAnimationControlListener {
+
+    /**
+     * The [WindowInsetsAnimationController] is only available once the insets are starting
+     * to be manipulated. This is used to set the current insets position.
+     */
+    private var animationController: WindowInsetsAnimationController? = null
+
+    /**
+     * `true` when we've requested a [WindowInsetsAnimationController] so that we don't
+     * ask for one when we've already asked for one. This should be `false` until we've
+     * made a request or when we've cleared [animationController] after it is finished.
+     */
+    private var isControllerRequested = false
+
+    /**
+     * We never need to cancel the animation because we always control it directly instead
+     * of using the [WindowInsetsAnimationController] to animate its value.
+     */
+    private val cancellationSignal = CancellationSignal()
+
+    /**
+     * Because touch motion has finer granularity than integers, we capture the fractions of
+     * integers here so that we can keep the finger more in line with the touch. Without this,
+     * we'd accumulate error.
+     */
+    private var partialConsumption = 0f
+
+    /**
+     * The [Job] that is launched to animate the insets during a fling. This can be canceled
+     * when the user touches the screen.
+     */
+    private var animationJob: Job? = null
+
+    /**
+     * Request an animation controller because it is `null`. If one has already been requested,
+     * this method does nothing.
+     */
+    private fun requestAnimationController() {
+        if (!isControllerRequested) {
+            isControllerRequested = true
+            view.windowInsetsController?.controlWindowInsetsAnimation(
+                windowInsets.type, // type
+                -1, // durationMillis
+                null, // interpolator
+                cancellationSignal,
+                this
+            )
+        }
+    }
+
+    private var continuation: CancellableContinuation<WindowInsetsAnimationController?>? = null
+
+    /**
+     * Allows us to suspend, waiting for the animation controller to be returned.
+     */
+    private suspend fun getAnimationController(): WindowInsetsAnimationController? =
+        animationController ?: suspendCancellableCoroutine { continuation ->
+            this.continuation = continuation
+            requestAnimationController()
+        }
+
+    /**
+     * Handle the dragging that hides the WindowInsets.
+     */
+    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
+        scroll(available, sideCalculator.hideMotion(available.x, available.y))
+
+    /**
+     * Handle the dragging that exposes the WindowInsets.
+     */
+    override fun onPostScroll(
+        consumed: Offset,
+        available: Offset,
+        source: NestedScrollSource
+    ): Offset = scroll(available, sideCalculator.showMotion(available.x, available.y))
+
+    /**
+     * Scrolls [scrollAmount] and returns the consumed amount of [available].
+     */
+    private fun scroll(available: Offset, scrollAmount: Float): Offset {
+        animationJob?.let {
+            it.cancel()
+            animationJob = null
+        }
+
+        val animationController = animationController
+
+        if (scrollAmount == 0f ||
+            (windowInsets.isVisible == (scrollAmount > 0f) && animationController == null)
+        ) {
+            // No motion in the right direction or this is already fully shown/hidden.
+            return Offset.Zero
+        }
+
+        if (animationController == null) {
+            partialConsumption = 0f
+            // The animation controller isn't ready yet. Just consume the scroll.
+            requestAnimationController()
+            return sideCalculator.consumedOffsets(available)
+        }
+
+        val hidden = sideCalculator.valueOf(animationController.hiddenStateInsets)
+        val shown = sideCalculator.valueOf(animationController.shownStateInsets)
+        val currentInsets = animationController.currentInsets
+        val current = sideCalculator.valueOf(currentInsets)
+
+        val target = if (scrollAmount > 0f) shown else hidden
+
+        if (current == target) {
+            // This is already correct, so nothing to consume
+            partialConsumption = 0f
+            return Offset.Zero
+        }
+
+        val total = current + scrollAmount + partialConsumption
+        val next = total.roundToInt().coerceIn(hidden, shown)
+        partialConsumption = total - total.roundToInt()
+
+        if (next != current) {
+            animationController.setInsetsAndAlpha(
+                sideCalculator.adjustInsets(currentInsets, next),
+                1f, // alpha
+                0f, // progress
+            )
+        }
+        return sideCalculator.consumedOffsets(available)
+    }
+
+    /**
+     * Handle flinging toward hiding the insets.
+     */
+    override suspend fun onPreFling(available: Velocity): Velocity =
+        fling(available, sideCalculator.hideMotion(available.x, available.y), false)
+
+    /**
+     * Handle flinging toward showing the insets.
+     */
+    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity =
+        fling(available, sideCalculator.showMotion(available.x, available.y), true)
+
+    /**
+     * Handle flinging by [flingAmount] and return the consumed velocity of [available].
+     * [towardShown] should be `true` when the intended motion is to show the insets or `false`
+     * if to hide them. We always handle flinging toward the insets if the [flingAmount] is
+     * `0` so that the insets animate to a fully-shown or fully-hidden state.
+     */
+    private suspend fun fling(
+        available: Velocity,
+        flingAmount: Float,
+        towardShown: Boolean
+    ): Velocity {
+        animationJob?.cancel()
+        animationJob = null
+        partialConsumption = 0f
+
+        if ((flingAmount == 0f && !towardShown) ||
+            (animationController == null && windowInsets.isVisible == towardShown)
+        ) {
+            // Either there's no motion to hide or we're certain that
+            // the inset is already correct.
+            return Velocity.Zero
+        }
+
+        val animationController = getAnimationController() ?: return Velocity.Zero
+
+        val hidden = sideCalculator.valueOf(animationController.hiddenStateInsets)
+        val shown = sideCalculator.valueOf(animationController.shownStateInsets)
+        val currentInsets = animationController.currentInsets
+        val current = sideCalculator.valueOf(currentInsets)
+
+        if ((flingAmount <= 0 && current == hidden) || (flingAmount >= 0 && current == shown)) {
+            // We've already reached the destination
+            animationController.finish(current == shown)
+            this@WindowInsetsNestedScrollConnection.animationController = null
+            return Velocity.Zero
+        }
+
+        // Let's see if the velocity is enough to get open
+        val spec = SplineBasedFloatDecayAnimationSpec(density)
+        val distance = current + spec.flingDistance(flingAmount)
+
+        val endPercent = (distance - hidden) / (shown - hidden)
+        val targetShown = endPercent > 0.5f
+        val target = if (targetShown) shown else hidden
+
+        if (distance > shown || distance < hidden) {
+            var endVelocity = 0f
+            // This is enough to reach hidden or shown state, so we can use the Android
+            // spline animation.
+            coroutineScope {
+                animationJob = launch {
+                    animateDecay(
+                        initialValue = current.toFloat(),
+                        initialVelocity = flingAmount,
+                        animationSpec = spec
+                    ) { value, velocity ->
+                        if (value in hidden.toFloat()..shown.toFloat()) {
+                            adjustInsets(value)
+                        } else {
+                            // We've reached the end
+                            endVelocity = velocity
+                            animationController.finish(targetShown)
+                            this@WindowInsetsNestedScrollConnection.animationController = null
+                            animationJob?.cancel()
+                        }
+                    }
+                }
+                animationJob?.join()
+                animationJob = null
+            }
+            return sideCalculator.consumedVelocity(available, endVelocity)
+        } else {
+            // This fling won't make it to the end, so animate to shown or hidden state using
+            // a spring animation
+            coroutineScope {
+                animationJob = launch {
+                    val animatedValue = Animatable(current.toFloat())
+                    animatedValue.animateTo(target.toFloat(), initialVelocity = flingAmount) {
+                        adjustInsets(value)
+                    }
+                    animationController.finish(targetShown)
+                    this@WindowInsetsNestedScrollConnection.animationController = null
+                }
+            }
+            return sideCalculator.consumedVelocity(available, 0f)
+        }
+    }
+
+    /**
+     * Change the inset's side to [inset].
+     */
+    private fun adjustInsets(inset: Float) {
+        animationController?.let {
+            val currentInsets = it.currentInsets
+            val nextInsets = sideCalculator.adjustInsets(currentInsets, inset.roundToInt())
+            it.setInsetsAndAlpha(
+                nextInsets,
+                1f, // alpha
+                0f, // progress
+            )
+        }
+    }
+
+    /**
+     * Called after [requestAnimationController] and the [animationController] is ready.
+     */
+    override fun onReady(controller: WindowInsetsAnimationController, types: Int) {
+        animationController = controller
+        isControllerRequested = false
+        continuation?.resume(controller) { }
+        continuation = null
+    }
+
+    fun dispose() {
+        continuation?.resume(null) { }
+        animationJob?.cancel()
+        val animationController = animationController
+        if (animationController != null) {
+            // We don't want to leave the insets in a partially open or closed state, so finish
+            // the animation
+            val visible = animationController.currentInsets != animationController.hiddenStateInsets
+            animationController.finish(visible)
+        }
+    }
+
+    override fun onFinished(controller: WindowInsetsAnimationController) {
+        animationEnded()
+    }
+
+    override fun onCancelled(controller: WindowInsetsAnimationController?) {
+        animationEnded()
+    }
+
+    /**
+     * The controlled animation has been terminated.
+     */
+    private fun animationEnded() {
+        if (animationController?.isReady == true) {
+            animationController?.finish(windowInsets.isVisible)
+        }
+        animationController = null
+
+        // The animation controller may not have been given to us, so we have to cancel animations
+        // waiting for it.
+        continuation?.resume(null) { }
+        continuation = null
+
+        // Cancel any animation that's running.
+        animationJob?.cancel()
+        animationJob = null
+
+        partialConsumption = 0f
+        isControllerRequested = false
+    }
+}
+
+/**
+ * This interface allows logic for the specific side (left, top, right, bottom) to be
+ * extracted from the logic controlling showing and hiding insets. For example, an inset
+ * at the top will show when dragging down, while an inset at the bottom will hide
+ * when dragging down.
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+private interface SideCalculator {
+    /**
+     * Returns the insets value for the side that this [SideCalculator] is associated with.
+     */
+    fun valueOf(insets: Insets): Int
+
+    /**
+     * Returns the motion, adjusted for side direction, that the [x], and [y] grant. A positive
+     * result indicates that it is in the direction of opening the insets on that side and
+     * a negative result indicates a closing of the insets on that side.
+     */
+    fun motionOf(x: Float, y: Float): Float
+
+    /**
+     * The motion of [x], [y] that indicates showing more of the insets on the side or `0` if
+     * no motion is given to showing more insets.
+     */
+    fun showMotion(x: Float, y: Float): Float = motionOf(x, y).coerceAtLeast(0f)
+
+    /**
+     * The motion of [x], [y] that indicates showing less of the insets on the side or `0` if
+     * no motion is given to showing less insets.
+     */
+    fun hideMotion(x: Float, y: Float): Float = motionOf(x, y).coerceAtMost(0f)
+
+    /**
+     * Takes all values of [oldInsets], except for this side and replaces this side with [newValue].
+     */
+    fun adjustInsets(oldInsets: Insets, newValue: Int): Insets
+
+    /**
+     * Returns the [Offset] that consumes [available] in the direction of this side.
+     */
+    fun consumedOffsets(available: Offset): Offset
+
+    /**
+     * Returns the [Velocity] that consumes [available] in the direction of this side.
+     */
+    fun consumedVelocity(available: Velocity, remaining: Float): Velocity
+
+    companion object {
+        /**
+         * Returns a [SideCalculator] for [side] and the given [layoutDirection]. This only
+         * works for one side and no combination of sides.
+         */
+        fun chooseCalculator(side: WindowInsetsSides, layoutDirection: LayoutDirection) =
+            when (side) {
+                WindowInsetsSides.Left -> LeftSideCalculator
+                WindowInsetsSides.Top -> TopSideCalculator
+                WindowInsetsSides.Right -> RightSideCalculator
+                WindowInsetsSides.Bottom -> BottomSideCalculator
+                WindowInsetsSides.Start -> if (layoutDirection == LayoutDirection.Ltr) {
+                    LeftSideCalculator
+                } else {
+                    RightSideCalculator
+                }
+                WindowInsetsSides.End -> if (layoutDirection == LayoutDirection.Ltr) {
+                    RightSideCalculator
+                } else {
+                    LeftSideCalculator
+                }
+                else -> error("Only Left, Top, Right, Bottom, Start and End are allowed")
+            }
+
+        private val LeftSideCalculator = object : SideCalculator {
+            override fun valueOf(insets: Insets): Int = insets.left
+            override fun motionOf(x: Float, y: Float): Float = x
+            override fun adjustInsets(oldInsets: Insets, newValue: Int): Insets =
+                Insets.of(newValue, oldInsets.top, oldInsets.right, oldInsets.bottom)
+            override fun consumedOffsets(available: Offset): Offset = Offset(available.x, 0f)
+            override fun consumedVelocity(available: Velocity, remaining: Float): Velocity =
+                Velocity(available.x - remaining, 0f)
+        }
+
+        private val TopSideCalculator = object : SideCalculator {
+            override fun valueOf(insets: Insets): Int = insets.top
+            override fun motionOf(x: Float, y: Float): Float = y
+            override fun adjustInsets(oldInsets: Insets, newValue: Int): Insets =
+                Insets.of(oldInsets.left, newValue, oldInsets.right, oldInsets.bottom)
+            override fun consumedOffsets(available: Offset): Offset = Offset(0f, available.y)
+            override fun consumedVelocity(available: Velocity, remaining: Float): Velocity =
+                Velocity(0f, available.y - remaining)
+        }
+
+        private val RightSideCalculator = object : SideCalculator {
+            override fun valueOf(insets: Insets): Int = insets.right
+            override fun motionOf(x: Float, y: Float): Float = -x
+            override fun adjustInsets(oldInsets: Insets, newValue: Int): Insets =
+                Insets.of(oldInsets.left, oldInsets.top, newValue, oldInsets.bottom)
+            override fun consumedOffsets(available: Offset): Offset = Offset(available.x, 0f)
+            override fun consumedVelocity(available: Velocity, remaining: Float): Velocity =
+                Velocity(available.x + remaining, 0f)
+        }
+
+        private val BottomSideCalculator = object : SideCalculator {
+            override fun valueOf(insets: Insets): Int = insets.bottom
+            override fun motionOf(x: Float, y: Float): Float = -y
+            override fun adjustInsets(oldInsets: Insets, newValue: Int): Insets =
+                Insets.of(oldInsets.left, oldInsets.top, oldInsets.right, newValue)
+            override fun consumedOffsets(available: Offset): Offset = Offset(0f, available.y)
+            override fun consumedVelocity(available: Velocity, remaining: Float): Velocity =
+                Velocity(0f, available.y + remaining)
+        }
+    }
+}
+
+// SplineBasedFloatDecayAnimationSpec is in animation:animation library, which depends on
+// foundation-layout, so I've copied it below, but a bit trimmed to only have what is needed.
+
+// These constants are copied from the Android spline decay rate
+private const val Inflection = 0.35f // Tension lines cross at (Inflection, 1)
+private val PlatformFlingScrollFriction = ViewConfiguration.getScrollFriction()
+private const val GravityEarth = 9.80665f
+private const val InchesPerMeter = 39.37f
+private val DecelerationRate = ln(0.78) / ln(0.9)
+private val DecelMinusOne = DecelerationRate - 1.0
+private const val StartTension = 0.5f
+private const val EndTension = 1.0f
+private const val P1 = StartTension * Inflection
+private const val P2 = 1.0f - EndTension * (1.0f - Inflection)
+
+private class SplineBasedFloatDecayAnimationSpec(density: Density) :
+    FloatDecayAnimationSpec {
+
+    override val absVelocityThreshold: Float get() = 0f
+
+    /**
+     * A density-specific coefficient adjusted to physical values.
+     */
+    private val magicPhysicalCoefficient: Float =
+        GravityEarth * InchesPerMeter * density.density * 160f * 0.84f
+
+    private fun getSplineDeceleration(velocity: Float): Double =
+        AndroidFlingSpline.deceleration(
+            velocity,
+            PlatformFlingScrollFriction * magicPhysicalCoefficient
+        )
+
+    /**
+     * Compute the distance of a fling in units given an initial [velocity] of units/second
+     */
+    fun flingDistance(velocity: Float): Float {
+        val l = getSplineDeceleration(velocity)
+        return (
+            PlatformFlingScrollFriction * magicPhysicalCoefficient
+                * exp(DecelerationRate / DecelMinusOne * l)
+            ).toFloat() * sign(velocity)
+    }
+
+    override fun getTargetValue(initialValue: Float, initialVelocity: Float): Float =
+        initialValue + flingDistance(initialVelocity)
+
+    @Suppress("MethodNameUnits")
+    override fun getValueFromNanos(
+        playTimeNanos: Long,
+        initialValue: Float,
+        initialVelocity: Float
+    ): Float {
+        val duration = getDurationNanos(0f, initialVelocity)
+        val splinePos = if (duration > 0) playTimeNanos / duration.toFloat() else 1f
+        val distance = flingDistance(initialVelocity)
+        return initialValue + distance *
+            AndroidFlingSpline.flingPosition(splinePos).distanceCoefficient
+    }
+
+    @Suppress("MethodNameUnits")
+    override fun getDurationNanos(initialValue: Float, initialVelocity: Float): Long {
+        val l = getSplineDeceleration(initialVelocity)
+        return (1_000_000_000.0 * exp(l / DecelMinusOne)).toLong()
+    }
+
+    @Suppress("MethodNameUnits")
+    override fun getVelocityFromNanos(
+        playTimeNanos: Long,
+        initialValue: Float,
+        initialVelocity: Float
+    ): Float {
+        val duration = getDurationNanos(0f, initialVelocity)
+        val splinePos = if (duration > 0L) playTimeNanos / duration.toFloat() else 1f
+        val distance = flingDistance(initialVelocity)
+        return AndroidFlingSpline.flingPosition(splinePos).velocityCoefficient *
+            distance / duration * 1_000_000_000.0f
+    }
+}
+
+private object AndroidFlingSpline {
+    private const val NbSamples = 100
+    private val SplinePositions = FloatArray(NbSamples + 1)
+    private val SplineTimes = FloatArray(NbSamples + 1)
+
+    init {
+        var xMin = 0.0f
+        var yMin = 0.0f
+        for (i in 0 until NbSamples) {
+            val alpha = i.toFloat() / NbSamples
+            var xMax = 1.0f
+            var x: Float
+            var tx: Float
+            var coef: Float
+            while (true) {
+                x = xMin + (xMax - xMin) / 2.0f
+                coef = 3.0f * x * (1.0f - x)
+                tx = coef * ((1.0f - x) * P1 + x * P2) + x * x * x
+                if (abs(tx - alpha) < 1E-5) break
+                if (tx > alpha) xMax = x else xMin = x
+            }
+            SplinePositions[i] = coef * ((1.0f - x) * StartTension + x) + x * x * x
+            var yMax = 1.0f
+            var y: Float
+            var dy: Float
+            while (true) {
+                y = yMin + (yMax - yMin) / 2.0f
+                coef = 3.0f * y * (1.0f - y)
+                dy = coef * ((1.0f - y) * StartTension + y) + y * y * y
+                if (abs(dy - alpha) < 1E-5) break
+                if (dy > alpha) yMax = y else yMin = y
+            }
+            SplineTimes[i] = coef * ((1.0f - y) * P1 + y * P2) + y * y * y
+        }
+        SplineTimes[NbSamples] = 1.0f
+        SplinePositions[NbSamples] = SplineTimes[NbSamples]
+    }
+
+    /**
+     * Compute an instantaneous fling position along the scroller spline.
+     *
+     * @param time progress through the fling animation from 0-1
+     */
+    fun flingPosition(time: Float): FlingResult {
+        val index = (NbSamples * time).toInt()
+        var distanceCoef = 1f
+        var velocityCoef = 0f
+        if (index < NbSamples) {
+            val tInf = index.toFloat() / NbSamples
+            val tSup = (index + 1).toFloat() / NbSamples
+            val dInf = SplinePositions[index]
+            val dSup = SplinePositions[index + 1]
+            velocityCoef = (dSup - dInf) / (tSup - tInf)
+            distanceCoef = dInf + (time - tInf) * velocityCoef
+        }
+        return FlingResult(packFloats(distanceCoef, velocityCoef))
+    }
+
+    /**
+     * The rate of deceleration along the spline motion given [velocity] and [friction].
+     */
+    fun deceleration(velocity: Float, friction: Float): Double =
+        ln(Inflection * abs(velocity) / friction.toDouble())
+
+    /**
+     * Result coefficients of a scroll computation
+     */
+    @JvmInline
+    value class FlingResult(private val packedValue: Long) {
+        /**
+         * Linear distance traveled from 0-1, from source (0) to destination (1)
+         */
+        val distanceCoefficient: Float get() = unpackFloat1(packedValue)
+        /**
+         * Instantaneous velocity coefficient at this point in the fling expressed in
+         * total distance per unit time
+         */
+        val velocityCoefficient: Float get() = unpackFloat2(packedValue)
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsetsPadding.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsetsPadding.kt
index 5fbb82fc..ffc7e85 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsetsPadding.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsetsPadding.kt
@@ -129,7 +129,8 @@
         val horizontal = left + right
         val vertical = top + bottom
 
-        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
+        val childConstraints = constraints.offset(-horizontal, -vertical)
+        val placeable = measurable.measure(childConstraints)
 
         val width = constraints.constrainWidth(placeable.width + horizontal)
         val height = constraints.constrainHeight(placeable.height + vertical)