Files
lawnchair/systemUI/anim/src/com/android/systemui/animation/GhostedViewTransitionAnimatorController.kt
2026-01-10 20:47:46 +07:00

613 lines
24 KiB
Kotlin

/*
* Copyright (C) 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 com.android.systemui.animation
import android.content.ComponentName
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Insets
import android.graphics.Matrix
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.InsetDrawable
import android.graphics.drawable.LayerDrawable
import android.graphics.drawable.StateListDrawable
import android.util.Log
import android.view.GhostView
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroupOverlay
import android.widget.FrameLayout
import com.android.internal.jank.Cuj.CujType
import com.android.internal.jank.InteractionJankMonitor
import com.android.systemui.Flags
import java.util.LinkedList
import kotlin.math.min
import kotlin.math.roundToInt
private const val TAG = "GhostedViewTransitionAnimatorController"
/**
* A base implementation of [ActivityTransitionAnimator.Controller] which creates a
* [ghost][GhostView] of [ghostedView] as well as an expandable background view, which are drawn and
* animated instead of the ghosted view.
*
* Important: [ghostedView] must be attached to a [ViewGroup] when calling this function and during
* the animation. It must also implement [LaunchableView], otherwise an exception will be thrown
* during this controller instantiation.
*
* Note: Avoid instantiating this directly and call [ActivityTransitionAnimator.Controller.fromView]
* whenever possible instead.
*/
open class GhostedViewTransitionAnimatorController
@JvmOverloads
constructor(
/** The view that will be ghosted and from which the background will be extracted. */
transitioningView: View,
/** The [CujType] associated to this launch animation. */
private val launchCujType: Int? = null,
override val transitionCookie: ActivityTransitionAnimator.TransitionCookie? = null,
override val component: ComponentName? = null,
/** The [CujType] associated to this return animation. */
private val returnCujType: Int? = null,
/**
* Whether this controller should be invalidated after its first use, and whenever [ghostedView]
* is detached.
*/
private val isEphemeral: Boolean = false,
private var interactionJankMonitor: InteractionJankMonitor =
InteractionJankMonitor.getInstance(),
/** [ViewTransitionRegistry] to store the mapping of transitioning view and its token */
private val transitionRegistry: IViewTransitionRegistry? =
if (Flags.decoupleViewControllerInAnimlib()) {
ViewTransitionRegistry.instance
} else {
null
},
) : ActivityTransitionAnimator.Controller {
override val isLaunching: Boolean = true
/** The container to which we will add the ghost view and expanding background. */
override var transitionContainer: ViewGroup
get() = ghostedView.rootView as ViewGroup
set(_) {
// empty, should never be set to avoid memory leak
}
private val transitionContainerOverlay: ViewGroupOverlay
get() = transitionContainer.overlay
private val transitionContainerLocation = IntArray(2)
/** The ghost view that is drawn and animated instead of the ghosted view. */
private var ghostView: GhostView? = null
private val initialGhostViewMatrixValues = FloatArray(9) { 0f }
private val ghostViewMatrix = Matrix()
/**
* The expanding background view that will be added to [transitionContainer] (below [ghostView])
* and animate.
*/
private var backgroundView: FrameLayout? = null
/**
* The drawable wrapping the [ghostedView] background and used as background for
* [backgroundView].
*/
private var backgroundDrawable: WrappedDrawable? = null
private val backgroundInsets by lazy { background?.opticalInsets ?: Insets.NONE }
private var startBackgroundAlpha: Int = 0xFF
private val ghostedViewLocation = IntArray(2)
private val ghostedViewState = TransitionAnimator.State()
/**
* The background of the [ghostedView]. This background will be used to draw the background of
* the background view that is expanding up to the final animation position.
*
* Note that during the animation, the alpha value value of this background will be set to 0,
* then set back to its initial value at the end of the animation.
*/
private val background: Drawable?
/** CUJ identifier accounting for whether this controller is for a launch or a return. */
private val cujType: Int?
get() =
if (isLaunching) {
launchCujType
} else {
returnCujType
}
/**
* Used to automatically clean up the internal state once [ghostedView] is detached from the
* hierarchy.
*/
private val detachListener =
object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {}
override fun onViewDetachedFromWindow(v: View) {
onDispose()
}
}
/** [ViewTransitionToken] to be used for storing transitioning view in [transitionRegistry] */
private val transitionToken =
if (Flags.decoupleViewControllerInAnimlib()) {
transitionRegistry?.register(transitioningView)
} else {
null
}
/** The view that will be ghosted and from which the background will be extracted */
private val ghostedView: View
get() =
if (Flags.decoupleViewControllerInAnimlib()) {
transitionToken?.let { token -> transitionRegistry?.getView(token) }
} else {
_ghostedView
}!!
private val _ghostedView =
if (Flags.decoupleViewControllerInAnimlib()) {
null
} else {
transitioningView
}
init {
// Make sure the View we launch from implements LaunchableView to avoid visibility issues.
if (transitioningView !is LaunchableView) {
throw IllegalArgumentException(
"A GhostedViewLaunchAnimatorController was created from a View that does not " +
"implement LaunchableView. This can lead to subtle bugs where the visibility " +
"of the View we are launching from is not what we expected."
)
}
/** Find the first view with a background in [view] and its children. */
fun findBackground(view: View): Drawable? {
if (view.background != null) {
return view.background
}
// Perform a BFS to find the largest View with background.
val views = LinkedList<View>().apply { add(view) }
while (views.isNotEmpty()) {
val v = views.removeAt(0)
if (v.background != null) {
return v.background
}
if (v is ViewGroup) {
for (i in 0 until v.childCount) {
views.add(v.getChildAt(i))
}
}
}
return null
}
background = findBackground(ghostedView)
if (TransitionAnimator.returnAnimationsEnabled() && isEphemeral) {
ghostedView.addOnAttachStateChangeListener(detachListener)
}
}
override fun onDispose() {
if (TransitionAnimator.returnAnimationsEnabled()) {
ghostedView.removeOnAttachStateChangeListener(detachListener)
}
transitionToken?.let { token -> transitionRegistry?.unregister(token) }
}
/**
* Set the corner radius of [background]. The background is the one that was returned by
* [getBackground].
*/
protected open fun setBackgroundCornerRadius(
background: Drawable,
topCornerRadius: Float,
bottomCornerRadius: Float,
) {
// By default, we rely on WrappedDrawable to set/restore the background radii before/after
// each draw.
backgroundDrawable?.setBackgroundRadius(topCornerRadius, bottomCornerRadius)
}
/** Return the current top corner radius of the background. */
protected open fun getCurrentTopCornerRadius(): Float {
val drawable = background ?: return 0f
val gradient = findGradientDrawable(drawable) ?: return 0f
// TODO(b/184121838): Support more than symmetric top & bottom radius.
val radius = gradient.cornerRadii?.get(CORNER_RADIUS_TOP_INDEX) ?: gradient.cornerRadius
return radius * ghostedView.scaleX
}
/** Return the current bottom corner radius of the background. */
protected open fun getCurrentBottomCornerRadius(): Float {
val drawable = background ?: return 0f
val gradient = findGradientDrawable(drawable) ?: return 0f
// TODO(b/184121838): Support more than symmetric top & bottom radius.
val radius = gradient.cornerRadii?.get(CORNER_RADIUS_BOTTOM_INDEX) ?: gradient.cornerRadius
return radius * ghostedView.scaleX
}
override fun createAnimatorState(): TransitionAnimator.State {
val state =
TransitionAnimator.State(
topCornerRadius = getCurrentTopCornerRadius(),
bottomCornerRadius = getCurrentBottomCornerRadius(),
)
fillGhostedViewState(state)
return state
}
fun fillGhostedViewState(state: TransitionAnimator.State) {
// For the animation we are interested in the area that has a non transparent background,
// so we have to take the optical insets into account.
ghostedView.getLocationOnScreen(ghostedViewLocation)
val insets = backgroundInsets
val boundCorrections: Rect =
if (ghostedView is LaunchableView) {
(ghostedView as LaunchableView).getPaddingForLaunchAnimation()
} else {
Rect()
}
state.top = ghostedViewLocation[1] + insets.top + boundCorrections.top
state.bottom =
ghostedViewLocation[1] + (ghostedView.height * ghostedView.scaleY).roundToInt() -
insets.bottom + boundCorrections.bottom
state.left = ghostedViewLocation[0] + insets.left + boundCorrections.left
state.right =
ghostedViewLocation[0] + (ghostedView.width * ghostedView.scaleX).roundToInt() -
insets.right + boundCorrections.right
}
override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) {
if (ghostedView.parent !is ViewGroup) {
// This should usually not happen, but let's make sure we don't crash if the view was
// detached right before we started the animation.
Log.w(TAG, "Skipping animation as ghostedView is not attached to a ViewGroup")
return
}
backgroundView =
FrameLayout(transitionContainer.context).also { transitionContainerOverlay.add(it) }
// We wrap the ghosted view background and use it to draw the expandable background. Its
// alpha will be set to 0 as soon as we start drawing the expanding background.
startBackgroundAlpha = background?.alpha ?: 0xFF
backgroundDrawable = WrappedDrawable(background)
backgroundView?.background = backgroundDrawable
// Delay the calls to `ghostedView.setVisibility()` during the animation. This must be
// called before `GhostView.addGhost()` is called because the latter will change the
// *transition* visibility, which won't be blocked and will affect the normal View
// visibility that is saved by `setShouldBlockVisibilityChanges()` for a later restoration.
(ghostedView as? LaunchableView)?.setShouldBlockVisibilityChanges(true)
try {
// Create a ghost of the view that will be moving and fading out. This allows to fade
// out the content before fading out the background.
ghostView = GhostView.addGhost(ghostedView, transitionContainer)
} catch (e: Exception) {
// It is not 100% clear what conditions cause this exception to happen in practice, and
// we could never reproduce it, but it does show up extremely rarely. We already handle
// the scenario where ghostView is null, so we just avoid crashing and log the error.
// See b/315858472 for an investigation of the issue.
Log.e(TAG, "Failed to create ghostView", e)
}
// [GhostView.addGhost], the result of which is our [ghostView], creates a [GhostView], and
// adds it first to a [FrameLayout] container. It then adds _that_ container to an
// [OverlayViewGroup]. We need to turn off clipping for that container view. Currently,
// however, the only way to get a reference to that overlay is by going through our
// [ghostView]. The [OverlayViewGroup] will always be its grandparent view.
// TODO(b/306652954) reference the overlay view group directly if we can
(ghostView?.parent?.parent as? ViewGroup)?.let {
it.clipChildren = false
it.clipToPadding = false
}
val matrix = ghostView?.animationMatrix ?: Matrix.IDENTITY_MATRIX
matrix.getValues(initialGhostViewMatrixValues)
cujType?.let { interactionJankMonitor.begin(ghostedView, it) }
}
override fun onTransitionAnimationProgress(
state: TransitionAnimator.State,
progress: Float,
linearProgress: Float,
) {
val ghostView = this.ghostView ?: return
val backgroundView = this.backgroundView!!
if (!state.visible || !ghostedView.isAttachedToWindow) {
if (ghostView.visibility == View.VISIBLE) {
// Making the ghost view invisible will make the ghosted view visible, so order is
// important here.
ghostView.visibility = View.INVISIBLE
// Make the ghosted view invisible again. We use the transition visibility like
// GhostView does so that we don't mess up with the accessibility tree (see
// b/204944038#comment17).
ghostedView.setTransitionVisibility(View.INVISIBLE)
backgroundView.visibility = View.INVISIBLE
}
return
}
// The ghost and backgrounds views were made invisible earlier. That can for instance happen
// when animating a dialog into a view.
if (ghostView.visibility == View.INVISIBLE) {
ghostView.visibility = View.VISIBLE
backgroundView.visibility = View.VISIBLE
}
fillGhostedViewState(ghostedViewState)
val leftChange = state.left - ghostedViewState.left
val rightChange = state.right - ghostedViewState.right
val topChange = state.top - ghostedViewState.top
val bottomChange = state.bottom - ghostedViewState.bottom
val widthRatio = state.width.toFloat() / ghostedViewState.width
val heightRatio = state.height.toFloat() / ghostedViewState.height
val scale = min(widthRatio, heightRatio)
if (ghostedView.parent is ViewGroup) {
// Recalculate the matrix in case the ghosted view moved. We ensure that the ghosted
// view is still attached to a ViewGroup, otherwise calculateMatrix will throw.
GhostView.calculateMatrix(ghostedView, transitionContainer, ghostViewMatrix)
}
transitionContainer.getLocationOnScreen(transitionContainerLocation)
ghostViewMatrix.postScale(
scale,
scale,
ghostedViewState.centerX - transitionContainerLocation[0],
ghostedViewState.centerY - transitionContainerLocation[1],
)
ghostViewMatrix.postTranslate(
(leftChange + rightChange) / 2f,
(topChange + bottomChange) / 2f,
)
ghostView.animationMatrix = ghostViewMatrix
// We need to take into account the background insets for the background position.
val insets = backgroundInsets
val topWithInsets = state.top - insets.top
val leftWithInsets = state.left - insets.left
val rightWithInsets = state.right + insets.right
val bottomWithInsets = state.bottom + insets.bottom
backgroundView.top = topWithInsets - transitionContainerLocation[1]
backgroundView.bottom = bottomWithInsets - transitionContainerLocation[1]
backgroundView.left = leftWithInsets - transitionContainerLocation[0]
backgroundView.right = rightWithInsets - transitionContainerLocation[0]
val backgroundDrawable = backgroundDrawable!!
backgroundDrawable.wrapped?.let {
setBackgroundCornerRadius(it, state.topCornerRadius, state.bottomCornerRadius)
}
}
override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) {
if (ghostView == null) {
// We didn't actually run the animation.
return
}
cujType?.let { interactionJankMonitor.end(it) }
backgroundDrawable?.wrapped?.alpha = startBackgroundAlpha
GhostView.removeGhost(ghostedView)
backgroundView?.let { transitionContainerOverlay.remove(it) }
if (ghostedView is LaunchableView) {
// Restore the ghosted view visibility.
(ghostedView as LaunchableView).setShouldBlockVisibilityChanges(false)
(ghostedView as LaunchableView).onActivityLaunchAnimationEnd()
} else {
// Make the ghosted view visible. We ensure that the view is considered VISIBLE by
// accessibility by first making it INVISIBLE then VISIBLE (see b/204944038#comment17
// for more info).
ghostedView.visibility = View.INVISIBLE
ghostedView.visibility = View.VISIBLE
ghostedView.invalidate()
}
if (isEphemeral || Flags.decoupleViewControllerInAnimlib()) {
onDispose()
}
}
companion object {
private const val CORNER_RADIUS_TOP_INDEX = 0
private const val CORNER_RADIUS_BOTTOM_INDEX = 4
/**
* Return the first [GradientDrawable] found in [drawable], or null if none is found. If
* [drawable] is a [LayerDrawable], this will return the first layer that has a
* [GradientDrawable].
*/
fun findGradientDrawable(drawable: Drawable): GradientDrawable? {
if (drawable is GradientDrawable) {
return drawable
}
if (drawable is InsetDrawable) {
return drawable.drawable?.let { findGradientDrawable(it) }
}
if (drawable is LayerDrawable) {
for (i in 0 until drawable.numberOfLayers) {
val maybeGradient = findGradientDrawable(drawable.getDrawable(i))
if (maybeGradient != null) {
return maybeGradient
}
}
}
if (drawable is StateListDrawable) {
return findGradientDrawable(drawable.current)
}
return null
}
}
private class WrappedDrawable(val wrapped: Drawable?) : Drawable() {
private var currentAlpha = 0xFF
private var previousBounds = Rect()
private var cornerRadii = FloatArray(8) { -1f }
private var previousCornerRadii = FloatArray(8)
override fun draw(canvas: Canvas) {
val wrapped = this.wrapped ?: return
wrapped.copyBounds(previousBounds)
wrapped.alpha = currentAlpha
wrapped.bounds = bounds
applyBackgroundRadii()
wrapped.draw(canvas)
// The background view (and therefore this drawable) is drawn before the ghost view, so
// the ghosted view background alpha should always be 0 when it is drawn above the
// background.
wrapped.alpha = 0
wrapped.bounds = previousBounds
restoreBackgroundRadii()
}
override fun setAlpha(alpha: Int) {
if (alpha != currentAlpha) {
currentAlpha = alpha
invalidateSelf()
}
}
override fun getAlpha() = currentAlpha
override fun getOpacity(): Int {
val wrapped = this.wrapped ?: return PixelFormat.TRANSPARENT
val previousAlpha = wrapped.alpha
wrapped.alpha = currentAlpha
val opacity = wrapped.opacity
wrapped.alpha = previousAlpha
return opacity
}
override fun setColorFilter(filter: ColorFilter?) {
wrapped?.colorFilter = filter
}
fun setBackgroundRadius(topCornerRadius: Float, bottomCornerRadius: Float) {
updateRadii(cornerRadii, topCornerRadius, bottomCornerRadius)
invalidateSelf()
}
private fun updateRadii(
radii: FloatArray,
topCornerRadius: Float,
bottomCornerRadius: Float,
) {
radii[0] = topCornerRadius
radii[1] = topCornerRadius
radii[2] = topCornerRadius
radii[3] = topCornerRadius
radii[4] = bottomCornerRadius
radii[5] = bottomCornerRadius
radii[6] = bottomCornerRadius
radii[7] = bottomCornerRadius
}
private fun applyBackgroundRadii() {
if (cornerRadii[0] < 0 || wrapped == null) {
return
}
savePreviousBackgroundRadii(wrapped)
applyBackgroundRadii(wrapped, cornerRadii)
}
private fun savePreviousBackgroundRadii(background: Drawable) {
// TODO(b/184121838): This method assumes that all GradientDrawable in background will
// have the same radius. Should we save/restore the radii for each layer instead?
val gradient = findGradientDrawable(background) ?: return
// TODO(b/184121838): GradientDrawable#getCornerRadii clones its radii array. Should we
// try to avoid that?
val radii = gradient.cornerRadii
if (radii != null) {
radii.copyInto(previousCornerRadii)
} else {
// Copy the cornerRadius into previousCornerRadii.
val radius = gradient.cornerRadius
updateRadii(previousCornerRadii, radius, radius)
}
}
private fun applyBackgroundRadii(drawable: Drawable, radii: FloatArray) {
if (drawable is GradientDrawable) {
drawable.cornerRadii = radii
return
}
if (drawable is InsetDrawable) {
drawable.drawable?.let { applyBackgroundRadii(it, radii) }
return
}
if (drawable !is LayerDrawable) {
return
}
for (i in 0 until drawable.numberOfLayers) {
applyBackgroundRadii(drawable.getDrawable(i), radii)
}
}
private fun restoreBackgroundRadii() {
if (cornerRadii[0] < 0 || wrapped == null) {
return
}
applyBackgroundRadii(wrapped, previousCornerRadii)
}
}
}