blob: 3f7e0f0fb52748e50239732641117a68b5d04753 [file] [log] [blame]
/*
* 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.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.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.InteractionJankMonitor
import java.util.LinkedList
import kotlin.math.min
private const val TAG = "GhostedViewLaunchAnimatorController"
/**
* A base implementation of [ActivityLaunchAnimator.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.
*
* Note: Avoid instantiating this directly and call [ActivityLaunchAnimator.Controller.fromView]
* whenever possible instead.
*/
open class GhostedViewLaunchAnimatorController(
/** The view that will be ghosted and from which the background will be extracted. */
private val ghostedView: View,
/** The [InteractionJankMonitor.CujType] associated to this animation. */
private val cujType: Int? = null,
private var interactionJankMonitor: InteractionJankMonitor? = null
) : ActivityLaunchAnimator.Controller {
constructor(view: View, type: Int) : this(view, type, null)
/** The container to which we will add the ghost view and expanding background. */
override var launchContainer = ghostedView.rootView as ViewGroup
private val launchContainerOverlay: ViewGroupOverlay
get() = launchContainer.overlay
private val launchContainerLocation = 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 [launchContainer] (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 = LaunchAnimator.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?
init {
/** 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.removeFirst()
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)
}
/**
* 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.
return gradient.cornerRadii?.get(CORNER_RADIUS_TOP_INDEX) ?: gradient.cornerRadius
}
/** 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.
return gradient.cornerRadii?.get(CORNER_RADIUS_BOTTOM_INDEX) ?: gradient.cornerRadius
}
override fun createAnimatorState(): LaunchAnimator.State {
val state = LaunchAnimator.State(
topCornerRadius = getCurrentTopCornerRadius(),
bottomCornerRadius = getCurrentBottomCornerRadius()
)
fillGhostedViewState(state)
return state
}
fun fillGhostedViewState(state: LaunchAnimator.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
state.top = ghostedViewLocation[1] + insets.top
state.bottom = ghostedViewLocation[1] + ghostedView.height - insets.bottom
state.left = ghostedViewLocation[0] + insets.left
state.right = ghostedViewLocation[0] + ghostedView.width - insets.right
}
override fun onLaunchAnimationStart(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(launchContainer.context)
launchContainerOverlay.add(backgroundView)
// 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
// 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, launchContainer)
val matrix = ghostView?.animationMatrix ?: Matrix.IDENTITY_MATRIX
matrix.getValues(initialGhostViewMatrixValues)
cujType?.let { interactionJankMonitor?.begin(ghostedView, it) }
}
override fun onLaunchAnimationProgress(
state: LaunchAnimator.State,
progress: Float,
linearProgress: Float
) {
val ghostView = this.ghostView ?: return
val backgroundView = this.backgroundView!!
if (!state.visible) {
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, launchContainer, ghostViewMatrix)
}
launchContainer.getLocationOnScreen(launchContainerLocation)
ghostViewMatrix.postScale(
scale, scale,
ghostedViewState.centerX - launchContainerLocation[0],
ghostedViewState.centerY - launchContainerLocation[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 - launchContainerLocation[1]
backgroundView.bottom = bottomWithInsets - launchContainerLocation[1]
backgroundView.left = leftWithInsets - launchContainerLocation[0]
backgroundView.right = rightWithInsets - launchContainerLocation[0]
val backgroundDrawable = backgroundDrawable!!
backgroundDrawable.wrapped?.let {
setBackgroundCornerRadius(it, state.topCornerRadius, state.bottomCornerRadius)
}
}
override fun onLaunchAnimationEnd(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)
launchContainerOverlay.remove(backgroundView)
// Make sure 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()
}
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 is 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 = drawable.getDrawable(i)
if (maybeGradient is GradientDrawable) {
return maybeGradient
}
}
}
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) {
(drawable.getDrawable(i) as? GradientDrawable)?.cornerRadii = radii
}
}
private fun restoreBackgroundRadii() {
if (cornerRadii[0] < 0 || wrapped == null) {
return
}
applyBackgroundRadii(wrapped, previousCornerRadii)
}
}
}