Fix insets handling when using OnExitAnimationListener
On API 31, the SplashScreenView sets window.setDecorFitsSystemWindows(false) when an
OnExitAnimationListener is used. This also affects the application content that will
be pushed up under the status bar even though it didn't requested it.
And once the SplashScreenView is removed, the the whole layout jumps back below the
status bar.
Fortunately, this happens only after the view is attached, so we have time to record
the value of window.setDecorFitsSystemWindows() before the splash screen
modifies it and reapply the correct value to the window.
Test: SplashscreenParametrizedTest#decorFitSystemStableContentView#decorFitSystemStableContentView
Bug: 197870763
Change-Id: I59850c8e66c38ca9a7188863735489547f7efe9b
diff --git a/core/core-splashscreen/samples/src/main/res/values/styles.xml b/core/core-splashscreen/samples/src/main/res/values/styles.xml
index 06982bf..549060b 100644
--- a/core/core-splashscreen/samples/src/main/res/values/styles.xml
+++ b/core/core-splashscreen/samples/src/main/res/values/styles.xml
@@ -16,11 +16,13 @@
<resources>
- <style name="Theme.App" parent="Theme.AppCompat.Light.NoActionBar">
- <item name="android:windowBackground">@color/windowBackground</item>
- <item name="android:statusBarColor">@android:color/transparent</item>
- <item name="android:navigationBarColor">@android:color/transparent</item>
+ <style name="Theme.App" parent="Theme.AppCompat.DayNight.NoActionBar">
+ <item name="android:statusBarColor">#AAFE0000</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
+ <item name="android:navigationBarColor">@android:color/transparent</item>
+ <item name="android:enforceNavigationBarContrast">true</item>
+ <item name="android:windowLightNavigationBar">true</item>
+ <item name="android:windowLightStatusBar">true</item>
</style>
<style name="Theme.App.Starting" parent="Theme.SplashScreen.IconBackground">
diff --git a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashScreenTestController.kt b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashScreenTestController.kt
index dd4d89a..25a66c6 100644
--- a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashScreenTestController.kt
+++ b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashScreenTestController.kt
@@ -19,24 +19,43 @@
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
+import android.os.Bundle
import android.util.TypedValue
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.splashscreen.SplashScreenViewProvider
import androidx.test.runner.screenshot.Screenshot
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicBoolean
import androidx.core.splashscreen.R as SR
+/**
+ * If true, sets an [androidx.core.splashscreen.SplashScreen.OnExitAnimationListener] on the
+ * Activity
+ */
internal const val EXTRA_ANIMATION_LISTENER = "AnimationListener"
+
+/**
+ * If true, sets a [androidx.core.splashscreen.SplashScreen.KeepOnScreenCondition] waiting until
+ * [SplashScreenTestController.waitBarrier] is set to `false`.
+ * Activity
+ */
internal const val EXTRA_SPLASHSCREEN_WAIT = "splashscreen_wait"
+
+/**
+ * If set to true, takes a screenshot of the splash screen and saves it in
+ * [SplashScreenTestController.splashScreenScreenshot] and a second screenshot of
+ * [androidx.core.splashscreen.SplashScreenViewProvider.view]
+ * and saves it in [SplashScreenTestController.splashScreenViewScreenShot]
+ */
internal const val EXTRA_SPLASHSCREEN_VIEW_SCREENSHOT = "SplashScreenViewScreenShot"
public interface SplashScreenTestControllerHolder {
public var controller: SplashScreenTestController
}
-public class SplashScreenTestController(private val activity: Activity) {
+public class SplashScreenTestController(internal val activity: Activity) {
public var splashScreenViewScreenShot: Bitmap? = null
public var splashScreenScreenshot: Bitmap? = null
@@ -58,14 +77,27 @@
public val waitBarrier: AtomicBoolean = AtomicBoolean(true)
public var hasDrawn: Boolean = false
+ private var onExitAnimationListener: (SplashScreenViewProvider) -> Boolean = { false }
+
+ /**
+ * Call [onExitAnimation] when the
+ * [androidx.core.splashscreen.SplashScreen.OnExitAnimationListener] is called. This requires
+ * [EXTRA_ANIMATION_LISTENER] to be set to true.
+ * If [onExitAnimation] returns true, [SplashScreenViewProvider] won't be removed and the
+ * OnExitAnimationListener returns immediately.
+ */
+ fun doOnExitAnimation(onExitAnimation: (SplashScreenViewProvider) -> Boolean) {
+ onExitAnimationListener = onExitAnimation
+ }
+
public fun onCreate() {
val intent = activity.intent
val theme = activity.theme
+ val extras = intent.extras ?: Bundle.EMPTY
- val useListener = intent.extras?.getBoolean(EXTRA_ANIMATION_LISTENER) ?: false
- val takeScreenShot =
- intent.extras?.getBoolean(EXTRA_SPLASHSCREEN_VIEW_SCREENSHOT) ?: false
- val waitForSplashscreen = intent.extras?.getBoolean(EXTRA_SPLASHSCREEN_WAIT) ?: false
+ val useListener = extras.getBoolean(EXTRA_ANIMATION_LISTENER)
+ val takeScreenShot = extras.getBoolean(EXTRA_SPLASHSCREEN_VIEW_SCREENSHOT)
+ val waitForSplashscreen = extras.getBoolean(EXTRA_SPLASHSCREEN_WAIT)
val tv = TypedValue()
theme.resolveAttribute(SR.attr.windowSplashScreenAnimatedIcon, tv, true)
@@ -84,6 +116,7 @@
duration = tv.data
val splashScreen = activity.installSplashScreen()
+
activity.setContentView(R.layout.main_activity)
if (waitForSplashscreen) {
@@ -102,6 +135,9 @@
splashScreenView = splashScreenViewProvider.view
splashScreenIconView = splashScreenViewProvider.iconView
splashScreenIconViewBackground = splashScreenViewProvider.iconView.background
+ if (onExitAnimationListener(splashScreenViewProvider)) {
+ return@setOnExitAnimationListener
+ }
if (takeScreenShot) {
splashScreenViewProvider.view.postDelayed(
{
diff --git a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
index acecf31..3418529 100644
--- a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
+++ b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
@@ -19,6 +19,9 @@
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
+import android.view.View
+import android.view.ViewTreeObserver
+import androidx.core.splashscreen.SplashScreenViewProvider
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
@@ -36,6 +39,7 @@
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
+import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.reflect.KClass
@@ -155,6 +159,48 @@
compareBitmaps(activity.splashScreenScreenshot!!, activity.splashScreenViewScreenShot!!)
}
+ /**
+ * The splash screen is drawn full screen. On Android 12, this is achieved using
+ * [Window.setDecorFitsSystemWindow(false)].
+ */
+ @Test
+ public fun decorFitSystemStableContentView() {
+ val activityController = startActivityWithSplashScreen {
+ // Clear out any previous instances
+ it.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ it.putExtra(EXTRA_ANIMATION_LISTENER, true)
+ it.putExtra(EXTRA_SPLASHSCREEN_WAIT, true)
+ }
+
+ // We wait for 2 draw passes and check that out content view's height is stable
+ val drawLatch = CountDownLatch(2)
+ val container = activityController.activity.findViewById<View>(R.id.container)
+ val contentViewHeights = mutableListOf<Int>()
+ var splashScreenViewProvider: SplashScreenViewProvider? = null
+
+ val onDrawListener = ViewTreeObserver.OnDrawListener {
+ contentViewHeights.add(container.height)
+ drawLatch.countDown()
+ if (drawLatch.count == 1L) {
+ splashScreenViewProvider!!.remove()
+ }
+ }
+
+ activityController.doOnExitAnimation {
+ splashScreenViewProvider = it
+ container.viewTreeObserver.addOnDrawListener(onDrawListener)
+ activityController.splashScreenView!!.alpha = 0f
+ true
+ }
+
+ activityController.waitBarrier.set(false)
+ assertTrue("Missing ${drawLatch.count} draw passes.", drawLatch.await(2, TimeUnit.SECONDS))
+ assertTrue(
+ "Content view height must be stable but was ${
+ contentViewHeights.joinToString(",")
+ }", contentViewHeights.all { it == contentViewHeights.first() })
+ }
+
private fun compareBitmaps(
beforeScreenshot: Bitmap,
afterScreenshot: Bitmap
diff --git a/core/core-splashscreen/src/androidTest/res/values/styles.xml b/core/core-splashscreen/src/androidTest/res/values/styles.xml
index d028ccc..4a9e72b 100644
--- a/core/core-splashscreen/src/androidTest/res/values/styles.xml
+++ b/core/core-splashscreen/src/androidTest/res/values/styles.xml
@@ -21,7 +21,6 @@
<item name="android:fitsSystemWindows">false</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
- <item name="android:windowLightStatusBar">false</item>
</style>
<style name="Theme.Test.Starting" parent="Theme.SplashScreen">
diff --git a/core/core-splashscreen/src/main/java/androidx/core/splashscreen/SplashScreen.kt b/core/core-splashscreen/src/main/java/androidx/core/splashscreen/SplashScreen.kt
index d99ffd6..1fc3ebe 100644
--- a/core/core-splashscreen/src/main/java/androidx/core/splashscreen/SplashScreen.kt
+++ b/core/core-splashscreen/src/main/java/androidx/core/splashscreen/SplashScreen.kt
@@ -19,14 +19,18 @@
import android.annotation.SuppressLint
import android.app.Activity
import android.content.res.Resources
+import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.util.TypedValue
import android.view.View
import android.view.View.OnLayoutChangeListener
+import android.view.ViewGroup
import android.view.ViewTreeObserver.OnPreDrawListener
+import android.view.WindowInsets
import android.widget.ImageView
+import android.window.SplashScreenView
import androidx.annotation.MainThread
import androidx.annotation.RequiresApi
import androidx.core.splashscreen.SplashScreen.KeepOnScreenCondition
@@ -106,7 +110,7 @@
* - Without icon background (`Theme.SplashScreen`)
* + Image size: 288x288 dp
* + Inner circle diameter: 192 dp
- *
+ *
* _Example:_ if the full size of the image is 300dp*300dp, the icon needs to fit within a
* circle with a diameter of 200dp. Everything outside the circle will be invisible (masked).
*
@@ -374,9 +378,52 @@
@RequiresApi(Build.VERSION_CODES.S)
private class Impl31(activity: Activity) : Impl(activity) {
var preDrawListener: OnPreDrawListener? = null
+ var mDecorFitWindowInsets = true
+
+ val hierarchyListener = object : ViewGroup.OnHierarchyChangeListener {
+ override fun onChildViewAdded(parent: View?, child: View?) {
+
+ if (child is SplashScreenView) {
+ /*
+ * On API 31, the SplashScreenView sets window.setDecorFitsSystemWindows(false)
+ * when an OnExitAnimationListener is used. This also affects the application
+ * content that will be pushed up under the status bar even though it didn't
+ * requested it. And once the SplashScreenView is removed, the whole layout
+ * jumps back below the status bar. Fortunately, this happens only after the
+ * view is attached, so we have time to record the value of
+ * window.setDecorFitsSystemWindows() before the splash screen modifies it and
+ * reapply the correct value to the window.
+ */
+ mDecorFitWindowInsets = computeDecorFitsWindow(child)
+ (activity.window.decorView as ViewGroup).setOnHierarchyChangeListener(null)
+ }
+ }
+
+ override fun onChildViewRemoved(parent: View?, child: View?) {
+ // no-op
+ }
+ }
+
+ fun computeDecorFitsWindow(child: SplashScreenView): Boolean {
+ val inWindowInsets = WindowInsets.Builder().build()
+ val outLocalInsets = Rect(
+ Int.MIN_VALUE, Int.MIN_VALUE, Int.MAX_VALUE,
+ Int.MAX_VALUE
+ )
+
+ // If setDecorFitWindowInsets is set to false, computeSystemWindowInsets
+ // will return the same instance of WindowInsets passed in its parameter and
+ // will set outLocalInsets to empty, so we check that both conditions are
+ // filled to extrapolate the value of setDecorFitWindowInsets
+ return !(inWindowInsets === child.rootView.computeSystemWindowInsets
+ (inWindowInsets, outLocalInsets) && outLocalInsets.isEmpty)
+ }
override fun install() {
setPostSplashScreenTheme(activity.theme, TypedValue())
+ (activity.window.decorView as ViewGroup).setOnHierarchyChangeListener(
+ hierarchyListener
+ )
}
override fun setKeepOnScreenCondition(keepOnScreenCondition: KeepOnScreenCondition) {
@@ -402,10 +449,19 @@
override fun setOnExitAnimationListener(
exitAnimationListener: OnExitAnimationListener
) {
- activity.splashScreen.setOnExitAnimationListener {
- val splashScreenViewProvider = SplashScreenViewProvider(it, activity)
+ activity.splashScreen.setOnExitAnimationListener { splashScreenView ->
+ applyAppSystemUiTheme()
+ val splashScreenViewProvider = SplashScreenViewProvider(splashScreenView, activity)
exitAnimationListener.onSplashScreenExit(splashScreenViewProvider)
}
}
+
+ private fun applyAppSystemUiTheme() {
+ val window = activity.window
+
+ // Fix setDecorFitsSystemWindows being overridden by the SplashScreenView
+ (activity.window.decorView as ViewGroup).setOnHierarchyChangeListener(null)
+ window.setDecorFitsSystemWindows(mDecorFitWindowInsets)
+ }
}
}