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)