Added WindowInsets IME animation source and target
Fixes: 217770337
Relnote: "Added WindowInsets.imeAnimationSource and
WindowInsets.imeAnimationTarget to determine the
animation progress and know where the IME will be
after animation completes."
Test: new tests, manual
Change-Id: I356f1bac4ac4ff311573eb8df7227098b9186c20
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 75303a4..f788920 100644
--- a/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt
+++ b/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt
@@ -310,6 +310,8 @@
method public static boolean getConsumeWindowInsets(androidx.compose.ui.platform.ComposeView);
method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static androidx.compose.foundation.layout.WindowInsets getDisplayCutout(androidx.compose.foundation.layout.WindowInsets.Companion);
method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static androidx.compose.foundation.layout.WindowInsets getIme(androidx.compose.foundation.layout.WindowInsets.Companion);
+ method @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static androidx.compose.foundation.layout.WindowInsets getImeAnimationSource(androidx.compose.foundation.layout.WindowInsets.Companion);
+ method @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static androidx.compose.foundation.layout.WindowInsets getImeAnimationTarget(androidx.compose.foundation.layout.WindowInsets.Companion);
method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static androidx.compose.foundation.layout.WindowInsets getMandatorySystemGestures(androidx.compose.foundation.layout.WindowInsets.Companion);
method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static androidx.compose.foundation.layout.WindowInsets getNavigationBars(androidx.compose.foundation.layout.WindowInsets.Companion);
method @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static androidx.compose.foundation.layout.WindowInsets getNavigationBarsIgnoringVisibility(androidx.compose.foundation.layout.WindowInsets.Companion);
diff --git a/compose/foundation/foundation-layout/src/androidAndroidTest/AndroidManifest.xml b/compose/foundation/foundation-layout/src/androidAndroidTest/AndroidManifest.xml
index 9f5c83b..32edf28 100644
--- a/compose/foundation/foundation-layout/src/androidAndroidTest/AndroidManifest.xml
+++ b/compose/foundation/foundation-layout/src/androidAndroidTest/AndroidManifest.xml
@@ -23,5 +23,9 @@
android:name="androidx.compose.foundation.layout.WindowInsetsActivity"
android:windowSoftInputMode="adjustResize"
android:theme="@android:style/Theme.Material.NoActionBar.Fullscreen" />
+ <activity
+ android:name="androidx.compose.foundation.layout.WindowInsetsActionBarActivity"
+ android:windowSoftInputMode="adjustResize"
+ android:theme="@android:style/Theme.Material.Light" />
</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
index cc38754..245cff1 100644
--- 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
@@ -20,7 +20,7 @@
import androidx.core.view.WindowCompat
import java.util.concurrent.CountDownLatch
-class WindowInsetsActivity : ComponentActivity() {
+open class WindowInsetsActivity : ComponentActivity() {
val createdLatch = CountDownLatch(1)
val attachedToWindowLatch = CountDownLatch(1)
@@ -35,3 +35,5 @@
super.onAttachedToWindow()
}
}
+
+class WindowInsetsActionBarActivity : WindowInsetsActivity()
\ No newline at end of file
diff --git a/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsAnimationTest.kt b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsAnimationTest.kt
new file mode 100644
index 0000000..894b35c
--- /dev/null
+++ b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/WindowInsetsAnimationTest.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.view.View
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.test.filters.MediumTest
+import java.util.concurrent.TimeUnit
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@MediumTest
+class WindowInsetsAnimationTest {
+ @get:Rule
+ val rule = createAndroidComposeRule<WindowInsetsActionBarActivity>()
+
+ @Before
+ fun setup() {
+ rule.activity.createdLatch.await(1, TimeUnit.SECONDS)
+ rule.activity.attachedToWindowLatch.await(1, TimeUnit.SECONDS)
+ }
+
+ @After
+ fun teardown() {
+ rule.runOnUiThread {
+ val window = rule.activity.window
+ val view = window.decorView
+ WindowInsetsControllerCompat(window, view).hide(WindowInsetsCompat.Type.ime())
+ }
+ }
+
+ @OptIn(ExperimentalLayoutApi::class)
+ @Test
+ fun imeAnimationWhenShowingIme() {
+ val imeAnimationSourceValues = mutableListOf<Int>()
+ val imeAnimationTargetValues = mutableListOf<Int>()
+ val focusRequester = FocusRequester()
+ rule.setContent {
+ val density = LocalDensity.current
+ val source = WindowInsets.imeAnimationSource
+ val target = WindowInsets.imeAnimationTarget
+ val sourceBottom = source.getBottom(density)
+ imeAnimationSourceValues += sourceBottom
+ val targetBottom = target.getBottom(density)
+ imeAnimationTargetValues += targetBottom
+ BasicTextField(
+ value = "Hello World",
+ onValueChange = {},
+ Modifier.focusRequester(focusRequester)
+ )
+ }
+
+ rule.waitForIdle()
+ rule.runOnUiThread {
+ focusRequester.requestFocus()
+ }
+
+ rule.waitForIdle()
+ rule.waitUntil(timeoutMillis = 3000) {
+ imeAnimationSourceValues.last() > imeAnimationTargetValues.first()
+ }
+
+ rule.waitUntil(timeoutMillis = 3000) {
+ imeAnimationTargetValues.last() == imeAnimationSourceValues.last()
+ }
+ }
+
+ @OptIn(ExperimentalLayoutApi::class)
+ @Test
+ fun imeAnimationWhenHidingIme() {
+ val imeAnimationSourceValues = mutableListOf<Int>()
+ val imeAnimationTargetValues = mutableListOf<Int>()
+ val focusRequester = FocusRequester()
+ lateinit var view: View
+ rule.setContent {
+ view = LocalView.current
+ val density = LocalDensity.current
+ val source = WindowInsets.imeAnimationSource
+ val target = WindowInsets.imeAnimationTarget
+ val sourceBottom = source.getBottom(density)
+ imeAnimationSourceValues += sourceBottom
+ val targetBottom = target.getBottom(density)
+ imeAnimationTargetValues += targetBottom
+ BasicTextField(
+ value = "Hello World",
+ onValueChange = {},
+ Modifier.focusRequester(focusRequester)
+ )
+ }
+
+ rule.waitForIdle()
+ rule.runOnUiThread {
+ focusRequester.requestFocus()
+ }
+
+ rule.waitUntil(timeoutMillis = 3000) {
+ val target = imeAnimationTargetValues.last()
+ val source = imeAnimationSourceValues.last()
+ target > imeAnimationSourceValues.first() && target == source
+ }
+
+ rule.runOnUiThread {
+ val window = rule.activity.window
+ WindowInsetsControllerCompat(window, view).hide(WindowInsetsCompat.Type.ime())
+ }
+
+ rule.waitForIdle()
+
+ rule.waitUntil(timeoutMillis = 3000) {
+ imeAnimationTargetValues.last() == imeAnimationSourceValues.first()
+ }
+ }
+}
\ 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 1ea0f98..c77441a 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
@@ -373,6 +373,36 @@
get() = WindowInsetsHolder.current().tappableElement.isVisible
/**
+ * The [WindowInsets] for the IME before the IME started animating in. The current
+ * animated value is [WindowInsets.Companion.ime].
+ *
+ * This will be the same as [imeAnimationTarget] when there is no IME animation
+ * in progress.
+ */
+@ExperimentalLayoutApi
+val WindowInsets.Companion.imeAnimationSource: WindowInsets
+ @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+ @ExperimentalLayoutApi
+ @Composable
+ @NonRestartableComposable
+ get() = WindowInsetsHolder.current().imeAnimationSource
+
+/**
+ * The [WindowInsets] for the IME when the animation completes, if it is allowed
+ * to complete successfully. The current animated value is [WindowInsets.Companion.ime].
+ *
+ * This will be the same as [imeAnimationSource] when there is no IME animation
+ * in progress.
+ */
+@ExperimentalLayoutApi
+val WindowInsets.Companion.imeAnimationTarget: WindowInsets
+ @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+ @ExperimentalLayoutApi
+ @Composable
+ @NonRestartableComposable
+ get() = WindowInsetsHolder.current().imeAnimationTarget
+
+/**
* The insets for various values in the current window.
*/
internal class WindowInsetsHolder private constructor(insets: WindowInsetsCompat?, view: View) {
@@ -427,6 +457,16 @@
WindowInsetsCompat.Type.tappableElement(),
"tappableElementIgnoringVisibility"
)
+ val imeAnimationTarget = valueInsetsIgnoringVisibility(
+ insets,
+ WindowInsetsCompat.Type.ime(),
+ "imeAnimationTarget"
+ )
+ val imeAnimationSource = valueInsetsIgnoringVisibility(
+ insets,
+ WindowInsetsCompat.Type.ime(),
+ "imeAnimationSource"
+ )
/**
* `true` unless the `ComposeView` [ComposeView.consumeWindowInsets] is set to `false`.
@@ -527,6 +567,24 @@
Snapshot.sendApplyNotifications()
}
+ /**
+ * Updates [WindowInsets.Companion.imeAnimationSource]. It should be called prior to
+ * [update].
+ */
+ fun updateImeAnimationSource(windowInsets: WindowInsetsCompat) {
+ imeAnimationSource.value =
+ windowInsets.getInsets(WindowInsetsCompat.Type.ime()).toInsetsValues()
+ }
+
+ /**
+ * Updates [WindowInsets.Companion.imeAnimationTarget]. It should be called prior to
+ * [update].
+ */
+ fun updateImeAnimationTarget(windowInsets: WindowInsetsCompat) {
+ imeAnimationTarget.value =
+ windowInsets.getInsets(WindowInsetsCompat.Type.ime()).toInsetsValues()
+ }
+
companion object {
/**
* A mapping of AndroidComposeView to ComposeWindowInsets. Normally a tag is a great
@@ -649,6 +707,8 @@
runningAnimation = false
val insets = savedInsets
if (animation.durationMillis != 0L && insets != null) {
+ composeInsets.updateImeAnimationSource(insets)
+ composeInsets.updateImeAnimationTarget(insets)
composeInsets.update(insets)
}
savedInsets = null
@@ -659,6 +719,7 @@
// Keep track of the most recent insets we've seen, to ensure onEnd will always use the
// most recently acquired insets
savedInsets = insets
+ composeInsets.updateImeAnimationTarget(insets)
if (prepared) {
// 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 this is an
@@ -671,6 +732,7 @@
// If an animation is running, rely on onProgress() to update the insets
// On APIs less than 30 where the IME animation is backported, this avoids reporting
// the final insets for a frame while the animation is running.
+ composeInsets.updateImeAnimationSource(insets)
composeInsets.update(insets)
}
return if (composeInsets.consumes) WindowInsetsCompat.CONSUMED else insets
@@ -688,6 +750,7 @@
prepared = false
runningAnimation = false
savedInsets?.let {
+ composeInsets.updateImeAnimationSource(it)
composeInsets.update(it)
savedInsets = null
}