blob: 1fc3ebe7d49117a3b6adea039592cbb4e1666639 [file] [log] [blame]
/*
* Copyright 2021 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.core.splashscreen
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
/**
* Provides control over the splash screen once the application is started.
*
* On API 31+ (Android 12+) this class calls the platform methods.
*
* Prior API 31, the platform behavior is replicated with the exception of the Animated Vector
* Drawable support on the launch screen.
*
* # Usage of the `core-splashscreen` library:
*
* To replicate the splash screen behavior from Android 12 on older APIs the following steps need to
* be taken:
* 1. Create a new Theme (e.g `Theme.App.Starting`) and set its parent to `Theme.SplashScreen` or
* `Theme.SplashScreen.IconBackground`
*
* 2. In your manifest, set the `theme` attribute of the whole `<application>` or just the
* starting `<activity>` to `Theme.App.Starting`
*
* 3. In the `onCreate` method the starting activity, call [installSplashScreen] just before
* `super.onCreate()`. You also need to make sure that `postSplashScreenTheme` is set
* to the application's theme. Alternatively, this call can be replaced by [Activity#setTheme]
* if a [SplashScreen] instance isn't needed.
*
* ## Themes
*
* The library provides two themes: [R.style.Theme_SplashScreen] and
* [R.style.Theme_SplashScreen_IconBackground]. If you wish to display a background right under
* your icon, the later needs to be used. This ensure that the scale and masking of the icon are
* similar to the Android 12 Splash Screen.
*
* `windowSplashScreenAnimatedIcon`: The splash screen icon. On API 31+ it can be an animated
* vector drawable.
*
* `windowSplashScreenAnimationDuration`: Duration of the Animated Icon Animation. The value
* needs to be > 0 if the icon is animated.
*
* **Note:** This has no impact on the time during which the splash screen is displayed and is
* only used in [SplashScreenViewProvider.iconAnimationDurationMillis]. If you need to display the
* splash screen for a longer time, you can use [SplashScreen.setKeepOnScreenCondition]
*
* `windowSplashScreenIconBackgroundColor`: _To be used in with
* `Theme.SplashScreen.IconBackground`_. Sets a background color under the splash screen icon.
*
* `windowSplashScreenBackground`: Background color of the splash screen. Defaults to the theme's
* `?attr/colorBackground`.
*
* `postSplashScreenTheme`* Theme to apply to the Activity when [installSplashScreen] is called.
*
* **Known incompatibilities:**
* - On API < 31, `windowSplashScreenAnimatedIcon` cannot be animated. If you want to provide an
* animated icon for API 31+ and a still icon for API <31, you can do so by overriding the still
* icon with an animated vector drawable in `res/drawable-v31`.
*
* - On API < 31, if the value of `windowSplashScreenAnimatedIcon` is an
* [adaptive icon](http://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive)
* , it will be cropped and scaled. The workaround is to respectively assign
* `windowSplashScreenAnimatedIcon` and `windowSplashScreenIconBackgroundColor` to the values of
* the adaptive icon `foreground` and `background`.
*
* - On API 21-22, The icon isn't displayed until the application starts, only the background is
* visible.
*
* # Design
* The splash screen icon uses the same specifications as
* [Adaptive Icons](https://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive)
* . This means that the icon needs to fit within a circle whose diameter is 2/3 the size of the
* icon. The actual values don't really matter if you use a vector icon.
*
* ## Specs
* - With icon background (`Theme.SplashScreen.IconBackground`)
* + Image Size: 240x240 dp
* + Inner Circle diameter: 160 dp
* - 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).
*
*/
@SuppressLint("CustomSplashScreen")
class SplashScreen private constructor(activity: Activity) {
private val impl = when {
SDK_INT >= 31 -> Impl31(activity)
else -> Impl(activity)
}
public companion object {
private const val MASK_FACTOR = 2 / 3f
/**
* Creates a [SplashScreen] instance associated with this [Activity] and handles
* setting the theme to [R.attr.postSplashScreenTheme].
*
* This needs to be called before [Activity.setContentView] or other view operations on
* the root view (e.g setting flags).
*
* Alternatively, if a [SplashScreen] instance is not required, the theme can manually be
* set using [Activity.setTheme].
*/
@JvmStatic
public fun Activity.installSplashScreen(): SplashScreen {
val splashScreen = SplashScreen(this)
splashScreen.install()
return splashScreen
}
}
/**
* Sets the condition to keep the splash screen visible.
*
* The splash will stay visible until the condition isn't met anymore.
* The condition is evaluated before each request to draw the application, so it needs to be
* fast to avoid blocking the UI.
*
* @param condition The condition evaluated to decide whether to keep the splash screen on
* screen
*/
public fun setKeepOnScreenCondition(condition: KeepOnScreenCondition) {
impl.setKeepOnScreenCondition(condition)
}
/**
* Sets a listener that will be called when the splashscreen is ready to be removed.
*
* If a listener is set, the splashscreen won't be automatically removed and the application
* needs to manually call [SplashScreenViewProvider.remove].
*
* IF no listener is set, the splashscreen will be automatically removed once the app is
* ready to draw.
*
* The listener will be called on the ui thread.
*
* @param listener The [OnExitAnimationListener] that will be called when the splash screen
* is ready to be dismissed.
*
* @see setKeepOnScreenCondition
* @see OnExitAnimationListener
* @see SplashScreenViewProvider
*/
@SuppressWarnings("ExecutorRegistration") // Always runs on the MainThread
public fun setOnExitAnimationListener(listener: OnExitAnimationListener) {
impl.setOnExitAnimationListener(listener)
}
private fun install() {
impl.install()
}
/**
* Listener to be passed in [SplashScreen.setOnExitAnimationListener].
*
* The listener will be called once the splash screen is ready to be removed and provides a
* reference to a [SplashScreenViewProvider] that can be used to customize the exit
* animation of the splash screen.
*/
public fun interface OnExitAnimationListener {
/**
* Callback called when the splash screen is ready to be dismissed. The caller is
* responsible for animating and removing splash screen using the provided
* [splashScreenViewProvider].
*
* The caller **must** call [SplashScreenViewProvider.remove] once it's done with the
* splash screen.
*
* @param splashScreenViewProvider An object holding a reference to the displayed splash
* screen.
*/
@MainThread
public fun onSplashScreenExit(splashScreenViewProvider: SplashScreenViewProvider)
}
/**
* Condition evaluated to check if the splash screen should remain on screen
*
* The splash screen will stay visible until the condition isn't met anymore.
* The condition is evaluated before each request to draw the application, so it needs to be
* fast to avoid blocking the UI.
*/
public fun interface KeepOnScreenCondition {
/**
* Callback evaluated before every requests to draw the Activity. If it returns `true`, the
* splash screen will be kept visible to hide the Activity below.
*
* This callback is evaluated in the main thread.
*/
@MainThread
public fun shouldKeepOnScreen(): Boolean
}
private open class Impl(val activity: Activity) {
var finalThemeId: Int = 0
var backgroundResId: Int? = null
var backgroundColor: Int? = null
var icon: Drawable? = null
var hasBackground: Boolean = false
var splashScreenWaitPredicate = KeepOnScreenCondition { false }
private var animationListener: OnExitAnimationListener? = null
private var mSplashScreenViewProvider: SplashScreenViewProvider? = null
open fun install() {
val typedValue = TypedValue()
val currentTheme = activity.theme
if (currentTheme.resolveAttribute(
R.attr.windowSplashScreenBackground,
typedValue,
true
)
) {
backgroundResId = typedValue.resourceId
backgroundColor = typedValue.data
}
if (currentTheme.resolveAttribute(
R.attr.windowSplashScreenAnimatedIcon,
typedValue,
true
)
) {
icon = currentTheme.getDrawable(typedValue.resourceId)
}
if (currentTheme.resolveAttribute(R.attr.splashScreenIconSize, typedValue, true)) {
hasBackground =
typedValue.resourceId == R.dimen.splashscreen_icon_size_with_background
}
setPostSplashScreenTheme(currentTheme, typedValue)
}
protected fun setPostSplashScreenTheme(
currentTheme: Resources.Theme,
typedValue: TypedValue
) {
if (currentTheme.resolveAttribute(R.attr.postSplashScreenTheme, typedValue, true)) {
finalThemeId = typedValue.resourceId
if (finalThemeId != 0) {
activity.setTheme(finalThemeId)
}
}
}
open fun setKeepOnScreenCondition(keepOnScreenCondition: KeepOnScreenCondition) {
splashScreenWaitPredicate = keepOnScreenCondition
val contentView = activity.findViewById<View>(android.R.id.content)
val observer = contentView.viewTreeObserver
observer.addOnPreDrawListener(object : OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (splashScreenWaitPredicate.shouldKeepOnScreen()) {
return false
}
contentView.viewTreeObserver.removeOnPreDrawListener(this)
mSplashScreenViewProvider?.let(::dispatchOnExitAnimation)
return true
}
})
}
open fun setOnExitAnimationListener(exitAnimationListener: OnExitAnimationListener) {
animationListener = exitAnimationListener
val splashScreenViewProvider = SplashScreenViewProvider(activity)
val finalBackgroundResId = backgroundResId
val finalBackgroundColor = backgroundColor
val splashScreenView = splashScreenViewProvider.view
if (finalBackgroundResId != null && finalBackgroundResId != Resources.ID_NULL) {
splashScreenView.setBackgroundResource(finalBackgroundResId)
} else if (finalBackgroundColor != null) {
splashScreenView.setBackgroundColor(finalBackgroundColor)
} else {
splashScreenView.background = activity.window.decorView.background
}
icon?.let { displaySplashScreenIcon(splashScreenView, it) }
splashScreenView.addOnLayoutChangeListener(
object : OnLayoutChangeListener {
override fun onLayoutChange(
view: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
if (!view.isAttachedToWindow) {
return
}
view.removeOnLayoutChangeListener(this)
if (!splashScreenWaitPredicate.shouldKeepOnScreen()) {
dispatchOnExitAnimation(splashScreenViewProvider)
} else {
mSplashScreenViewProvider = splashScreenViewProvider
}
}
})
}
private fun displaySplashScreenIcon(splashScreenView: View, icon: Drawable) {
val iconView = splashScreenView.findViewById<ImageView>(R.id.splashscreen_icon_view)
iconView.apply {
val maskSize: Float
if (hasBackground) {
// If the splash screen has an icon background we need to mask both the
// background and foreground.
val iconBackgroundDrawable = context.getDrawable(R.drawable.icon_background)
val iconSize =
resources.getDimension(R.dimen.splashscreen_icon_size_with_background)
maskSize = iconSize * MASK_FACTOR
if (iconBackgroundDrawable != null) {
background = MaskedDrawable(iconBackgroundDrawable, maskSize)
}
} else {
val iconSize =
resources.getDimension(R.dimen.splashscreen_icon_size_no_background)
maskSize = iconSize * MASK_FACTOR
}
setImageDrawable(MaskedDrawable(icon, maskSize))
}
}
fun dispatchOnExitAnimation(splashScreenViewProvider: SplashScreenViewProvider) {
val finalListener = animationListener ?: return
animationListener = null
splashScreenViewProvider.view.postOnAnimation {
finalListener.onSplashScreenExit(splashScreenViewProvider)
}
}
}
@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) {
splashScreenWaitPredicate = keepOnScreenCondition
val contentView = activity.findViewById<View>(android.R.id.content)
val observer = contentView.viewTreeObserver
if (preDrawListener != null && observer.isAlive) {
observer.removeOnPreDrawListener(preDrawListener)
}
preDrawListener = object : OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (splashScreenWaitPredicate.shouldKeepOnScreen()) {
return false
}
contentView.viewTreeObserver.removeOnPreDrawListener(this)
return true
}
}
observer.addOnPreDrawListener(preDrawListener)
}
override fun setOnExitAnimationListener(
exitAnimationListener: OnExitAnimationListener
) {
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)
}
}
}