1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.animation
18 
19 import android.content.ComponentName
20 import android.graphics.Canvas
21 import android.graphics.ColorFilter
22 import android.graphics.Insets
23 import android.graphics.Matrix
24 import android.graphics.PixelFormat
25 import android.graphics.Rect
26 import android.graphics.drawable.Drawable
27 import android.graphics.drawable.GradientDrawable
28 import android.graphics.drawable.InsetDrawable
29 import android.graphics.drawable.LayerDrawable
30 import android.graphics.drawable.StateListDrawable
31 import android.util.Log
32 import android.view.GhostView
33 import android.view.View
34 import android.view.ViewGroup
35 import android.view.ViewGroupOverlay
36 import android.widget.FrameLayout
37 import com.android.internal.jank.Cuj.CujType
38 import com.android.internal.jank.InteractionJankMonitor
39 import java.util.LinkedList
40 import kotlin.math.min
41 import kotlin.math.roundToInt
42 
43 private const val TAG = "GhostedViewTransitionAnimatorController"
44 
45 /**
46  * A base implementation of [ActivityTransitionAnimator.Controller] which creates a
47  * [ghost][GhostView] of [ghostedView] as well as an expandable background view, which are drawn and
48  * animated instead of the ghosted view.
49  *
50  * Important: [ghostedView] must be attached to a [ViewGroup] when calling this function and during
51  * the animation. It must also implement [LaunchableView], otherwise an exception will be thrown
52  * during this controller instantiation.
53  *
54  * Note: Avoid instantiating this directly and call [ActivityTransitionAnimator.Controller.fromView]
55  * whenever possible instead.
56  */
57 open class GhostedViewTransitionAnimatorController
58 @JvmOverloads
59 constructor(
60     /** The view that will be ghosted and from which the background will be extracted. */
61     private val ghostedView: View,
62 
63     /** The [CujType] associated to this launch animation. */
64     private val launchCujType: Int? = null,
65     override val transitionCookie: ActivityTransitionAnimator.TransitionCookie? = null,
66     override val component: ComponentName? = null,
67 
68     /** The [CujType] associated to this return animation. */
69     private val returnCujType: Int? = null,
70 
71     /**
72      * Whether this controller should be invalidated after its first use, and whenever [ghostedView]
73      * is detached.
74      */
75     private val isEphemeral: Boolean = false,
76     private var interactionJankMonitor: InteractionJankMonitor =
77         InteractionJankMonitor.getInstance(),
78 ) : ActivityTransitionAnimator.Controller {
79     override val isLaunching: Boolean = true
80 
81     /** The container to which we will add the ghost view and expanding background. */
82     override var transitionContainer = ghostedView.rootView as ViewGroup
83     private val transitionContainerOverlay: ViewGroupOverlay
84         get() = transitionContainer.overlay
85 
86     private val transitionContainerLocation = IntArray(2)
87 
88     /** The ghost view that is drawn and animated instead of the ghosted view. */
89     private var ghostView: GhostView? = null
<lambda>null90     private val initialGhostViewMatrixValues = FloatArray(9) { 0f }
91     private val ghostViewMatrix = Matrix()
92 
93     /**
94      * The expanding background view that will be added to [transitionContainer] (below [ghostView])
95      * and animate.
96      */
97     private var backgroundView: FrameLayout? = null
98 
99     /**
100      * The drawable wrapping the [ghostedView] background and used as background for
101      * [backgroundView].
102      */
103     private var backgroundDrawable: WrappedDrawable? = null
<lambda>null104     private val backgroundInsets by lazy { background?.opticalInsets ?: Insets.NONE }
105     private var startBackgroundAlpha: Int = 0xFF
106 
107     private val ghostedViewLocation = IntArray(2)
108     private val ghostedViewState = TransitionAnimator.State()
109 
110     /**
111      * The background of the [ghostedView]. This background will be used to draw the background of
112      * the background view that is expanding up to the final animation position.
113      *
114      * Note that during the animation, the alpha value value of this background will be set to 0,
115      * then set back to its initial value at the end of the animation.
116      */
117     private val background: Drawable?
118 
119     /** CUJ identifier accounting for whether this controller is for a launch or a return. */
120     private val cujType: Int?
121         get() =
122             if (isLaunching) {
123                 launchCujType
124             } else {
125                 returnCujType
126             }
127 
128     /**
129      * Used to automatically clean up the internal state once [ghostedView] is detached from the
130      * hierarchy.
131      */
132     private val detachListener =
133         object : View.OnAttachStateChangeListener {
onViewAttachedToWindownull134             override fun onViewAttachedToWindow(v: View) {}
135 
onViewDetachedFromWindownull136             override fun onViewDetachedFromWindow(v: View) {
137                 onDispose()
138             }
139         }
140 
141     init {
142         // Make sure the View we launch from implements LaunchableView to avoid visibility issues.
143         if (ghostedView !is LaunchableView) {
144             throw IllegalArgumentException(
145                 "A GhostedViewLaunchAnimatorController was created from a View that does not " +
146                     "implement LaunchableView. This can lead to subtle bugs where the visibility " +
147                     "of the View we are launching from is not what we expected."
148             )
149         }
150 
151         /** Find the first view with a background in [view] and its children. */
findBackgroundnull152         fun findBackground(view: View): Drawable? {
153             if (view.background != null) {
154                 return view.background
155             }
156 
157             // Perform a BFS to find the largest View with background.
158             val views = LinkedList<View>().apply { add(view) }
159 
160             while (views.isNotEmpty()) {
161                 val v = views.removeAt(0)
162                 if (v.background != null) {
163                     return v.background
164                 }
165 
166                 if (v is ViewGroup) {
167                     for (i in 0 until v.childCount) {
168                         views.add(v.getChildAt(i))
169                     }
170                 }
171             }
172 
173             return null
174         }
175 
176         background = findBackground(ghostedView)
177 
178         if (TransitionAnimator.returnAnimationsEnabled() && isEphemeral) {
179             ghostedView.addOnAttachStateChangeListener(detachListener)
180         }
181     }
182 
onDisposenull183     override fun onDispose() {
184         if (TransitionAnimator.returnAnimationsEnabled()) {
185             ghostedView.removeOnAttachStateChangeListener(detachListener)
186         }
187     }
188 
189     /**
190      * Set the corner radius of [background]. The background is the one that was returned by
191      * [getBackground].
192      */
setBackgroundCornerRadiusnull193     protected open fun setBackgroundCornerRadius(
194         background: Drawable,
195         topCornerRadius: Float,
196         bottomCornerRadius: Float,
197     ) {
198         // By default, we rely on WrappedDrawable to set/restore the background radii before/after
199         // each draw.
200         backgroundDrawable?.setBackgroundRadius(topCornerRadius, bottomCornerRadius)
201     }
202 
203     /** Return the current top corner radius of the background. */
getCurrentTopCornerRadiusnull204     protected open fun getCurrentTopCornerRadius(): Float {
205         val drawable = background ?: return 0f
206         val gradient = findGradientDrawable(drawable) ?: return 0f
207 
208         // TODO(b/184121838): Support more than symmetric top & bottom radius.
209         val radius = gradient.cornerRadii?.get(CORNER_RADIUS_TOP_INDEX) ?: gradient.cornerRadius
210         return radius * ghostedView.scaleX
211     }
212 
213     /** Return the current bottom corner radius of the background. */
getCurrentBottomCornerRadiusnull214     protected open fun getCurrentBottomCornerRadius(): Float {
215         val drawable = background ?: return 0f
216         val gradient = findGradientDrawable(drawable) ?: return 0f
217 
218         // TODO(b/184121838): Support more than symmetric top & bottom radius.
219         val radius = gradient.cornerRadii?.get(CORNER_RADIUS_BOTTOM_INDEX) ?: gradient.cornerRadius
220         return radius * ghostedView.scaleX
221     }
222 
createAnimatorStatenull223     override fun createAnimatorState(): TransitionAnimator.State {
224         val state =
225             TransitionAnimator.State(
226                 topCornerRadius = getCurrentTopCornerRadius(),
227                 bottomCornerRadius = getCurrentBottomCornerRadius(),
228             )
229         fillGhostedViewState(state)
230         return state
231     }
232 
fillGhostedViewStatenull233     fun fillGhostedViewState(state: TransitionAnimator.State) {
234         // For the animation we are interested in the area that has a non transparent background,
235         // so we have to take the optical insets into account.
236         ghostedView.getLocationOnScreen(ghostedViewLocation)
237         val insets = backgroundInsets
238         val boundCorrections: Rect =
239             if (ghostedView is LaunchableView) {
240                 ghostedView.getPaddingForLaunchAnimation()
241             } else {
242                 Rect()
243             }
244         state.top = ghostedViewLocation[1] + insets.top + boundCorrections.top
245         state.bottom =
246             ghostedViewLocation[1] + (ghostedView.height * ghostedView.scaleY).roundToInt() -
247                 insets.bottom + boundCorrections.bottom
248         state.left = ghostedViewLocation[0] + insets.left + boundCorrections.left
249         state.right =
250             ghostedViewLocation[0] + (ghostedView.width * ghostedView.scaleX).roundToInt() -
251                 insets.right + boundCorrections.right
252     }
253 
onTransitionAnimationStartnull254     override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) {
255         if (ghostedView.parent !is ViewGroup) {
256             // This should usually not happen, but let's make sure we don't crash if the view was
257             // detached right before we started the animation.
258             Log.w(TAG, "Skipping animation as ghostedView is not attached to a ViewGroup")
259             return
260         }
261 
262         backgroundView =
263             FrameLayout(transitionContainer.context).also { transitionContainerOverlay.add(it) }
264 
265         // We wrap the ghosted view background and use it to draw the expandable background. Its
266         // alpha will be set to 0 as soon as we start drawing the expanding background.
267         startBackgroundAlpha = background?.alpha ?: 0xFF
268         backgroundDrawable = WrappedDrawable(background)
269         backgroundView?.background = backgroundDrawable
270 
271         // Delay the calls to `ghostedView.setVisibility()` during the animation. This must be
272         // called before `GhostView.addGhost()` is called because the latter will change the
273         // *transition* visibility, which won't be blocked and will affect the normal View
274         // visibility that is saved by `setShouldBlockVisibilityChanges()` for a later restoration.
275         (ghostedView as? LaunchableView)?.setShouldBlockVisibilityChanges(true)
276 
277         // Create a ghost of the view that will be moving and fading out. This allows to fade out
278         // the content before fading out the background.
279         ghostView = GhostView.addGhost(ghostedView, transitionContainer)
280 
281         // [GhostView.addGhost], the result of which is our [ghostView], creates a [GhostView], and
282         // adds it first to a [FrameLayout] container. It then adds _that_ container to an
283         // [OverlayViewGroup]. We need to turn off clipping for that container view. Currently,
284         // however, the only way to get a reference to that overlay is by going through our
285         // [ghostView]. The [OverlayViewGroup] will always be its grandparent view.
286         // TODO(b/306652954) reference the overlay view group directly if we can
287         (ghostView?.parent?.parent as? ViewGroup)?.let {
288             it.clipChildren = false
289             it.clipToPadding = false
290         }
291 
292         val matrix = ghostView?.animationMatrix ?: Matrix.IDENTITY_MATRIX
293         matrix.getValues(initialGhostViewMatrixValues)
294 
295         cujType?.let { interactionJankMonitor.begin(ghostedView, it) }
296     }
297 
onTransitionAnimationProgressnull298     override fun onTransitionAnimationProgress(
299         state: TransitionAnimator.State,
300         progress: Float,
301         linearProgress: Float,
302     ) {
303         val ghostView = this.ghostView ?: return
304         val backgroundView = this.backgroundView!!
305 
306         if (!state.visible || !ghostedView.isAttachedToWindow) {
307             if (ghostView.visibility == View.VISIBLE) {
308                 // Making the ghost view invisible will make the ghosted view visible, so order is
309                 // important here.
310                 ghostView.visibility = View.INVISIBLE
311 
312                 // Make the ghosted view invisible again. We use the transition visibility like
313                 // GhostView does so that we don't mess up with the accessibility tree (see
314                 // b/204944038#comment17).
315                 ghostedView.setTransitionVisibility(View.INVISIBLE)
316                 backgroundView.visibility = View.INVISIBLE
317             }
318             return
319         }
320 
321         // The ghost and backgrounds views were made invisible earlier. That can for instance happen
322         // when animating a dialog into a view.
323         if (ghostView.visibility == View.INVISIBLE) {
324             ghostView.visibility = View.VISIBLE
325             backgroundView.visibility = View.VISIBLE
326         }
327 
328         fillGhostedViewState(ghostedViewState)
329         val leftChange = state.left - ghostedViewState.left
330         val rightChange = state.right - ghostedViewState.right
331         val topChange = state.top - ghostedViewState.top
332         val bottomChange = state.bottom - ghostedViewState.bottom
333 
334         val widthRatio = state.width.toFloat() / ghostedViewState.width
335         val heightRatio = state.height.toFloat() / ghostedViewState.height
336         val scale = min(widthRatio, heightRatio)
337 
338         if (ghostedView.parent is ViewGroup) {
339             // Recalculate the matrix in case the ghosted view moved. We ensure that the ghosted
340             // view is still attached to a ViewGroup, otherwise calculateMatrix will throw.
341             GhostView.calculateMatrix(ghostedView, transitionContainer, ghostViewMatrix)
342         }
343 
344         transitionContainer.getLocationOnScreen(transitionContainerLocation)
345         ghostViewMatrix.postScale(
346             scale,
347             scale,
348             ghostedViewState.centerX - transitionContainerLocation[0],
349             ghostedViewState.centerY - transitionContainerLocation[1],
350         )
351         ghostViewMatrix.postTranslate(
352             (leftChange + rightChange) / 2f,
353             (topChange + bottomChange) / 2f,
354         )
355         ghostView.animationMatrix = ghostViewMatrix
356 
357         // We need to take into account the background insets for the background position.
358         val insets = backgroundInsets
359         val topWithInsets = state.top - insets.top
360         val leftWithInsets = state.left - insets.left
361         val rightWithInsets = state.right + insets.right
362         val bottomWithInsets = state.bottom + insets.bottom
363 
364         backgroundView.top = topWithInsets - transitionContainerLocation[1]
365         backgroundView.bottom = bottomWithInsets - transitionContainerLocation[1]
366         backgroundView.left = leftWithInsets - transitionContainerLocation[0]
367         backgroundView.right = rightWithInsets - transitionContainerLocation[0]
368 
369         val backgroundDrawable = backgroundDrawable!!
370         backgroundDrawable.wrapped?.let {
371             setBackgroundCornerRadius(it, state.topCornerRadius, state.bottomCornerRadius)
372         }
373     }
374 
onTransitionAnimationEndnull375     override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) {
376         if (ghostView == null) {
377             // We didn't actually run the animation.
378             return
379         }
380 
381         cujType?.let { interactionJankMonitor.end(it) }
382 
383         backgroundDrawable?.wrapped?.alpha = startBackgroundAlpha
384 
385         GhostView.removeGhost(ghostedView)
386         backgroundView?.let { transitionContainerOverlay.remove(it) }
387 
388         if (ghostedView is LaunchableView) {
389             // Restore the ghosted view visibility.
390             ghostedView.setShouldBlockVisibilityChanges(false)
391             ghostedView.onActivityLaunchAnimationEnd()
392         } else {
393             // Make the ghosted view visible. We ensure that the view is considered VISIBLE by
394             // accessibility by first making it INVISIBLE then VISIBLE (see b/204944038#comment17
395             // for more info).
396             ghostedView.visibility = View.INVISIBLE
397             ghostedView.visibility = View.VISIBLE
398             ghostedView.invalidate()
399         }
400 
401         if (isEphemeral) {
402             onDispose()
403         }
404     }
405 
406     companion object {
407         private const val CORNER_RADIUS_TOP_INDEX = 0
408         private const val CORNER_RADIUS_BOTTOM_INDEX = 4
409 
410         /**
411          * Return the first [GradientDrawable] found in [drawable], or null if none is found. If
412          * [drawable] is a [LayerDrawable], this will return the first layer that has a
413          * [GradientDrawable].
414          */
findGradientDrawablenull415         fun findGradientDrawable(drawable: Drawable): GradientDrawable? {
416             if (drawable is GradientDrawable) {
417                 return drawable
418             }
419 
420             if (drawable is InsetDrawable) {
421                 return drawable.drawable?.let { findGradientDrawable(it) }
422             }
423 
424             if (drawable is LayerDrawable) {
425                 for (i in 0 until drawable.numberOfLayers) {
426                     val maybeGradient = findGradientDrawable(drawable.getDrawable(i))
427                     if (maybeGradient != null) {
428                         return maybeGradient
429                     }
430                 }
431             }
432 
433             if (drawable is StateListDrawable) {
434                 return findGradientDrawable(drawable.current)
435             }
436 
437             return null
438         }
439     }
440 
441     private class WrappedDrawable(val wrapped: Drawable?) : Drawable() {
442         private var currentAlpha = 0xFF
443         private var previousBounds = Rect()
444 
<lambda>null445         private var cornerRadii = FloatArray(8) { -1f }
446         private var previousCornerRadii = FloatArray(8)
447 
drawnull448         override fun draw(canvas: Canvas) {
449             val wrapped = this.wrapped ?: return
450 
451             wrapped.copyBounds(previousBounds)
452 
453             wrapped.alpha = currentAlpha
454             wrapped.bounds = bounds
455             applyBackgroundRadii()
456 
457             wrapped.draw(canvas)
458 
459             // The background view (and therefore this drawable) is drawn before the ghost view, so
460             // the ghosted view background alpha should always be 0 when it is drawn above the
461             // background.
462             wrapped.alpha = 0
463             wrapped.bounds = previousBounds
464             restoreBackgroundRadii()
465         }
466 
setAlphanull467         override fun setAlpha(alpha: Int) {
468             if (alpha != currentAlpha) {
469                 currentAlpha = alpha
470                 invalidateSelf()
471             }
472         }
473 
getAlphanull474         override fun getAlpha() = currentAlpha
475 
476         override fun getOpacity(): Int {
477             val wrapped = this.wrapped ?: return PixelFormat.TRANSPARENT
478 
479             val previousAlpha = wrapped.alpha
480             wrapped.alpha = currentAlpha
481             val opacity = wrapped.opacity
482             wrapped.alpha = previousAlpha
483             return opacity
484         }
485 
setColorFilternull486         override fun setColorFilter(filter: ColorFilter?) {
487             wrapped?.colorFilter = filter
488         }
489 
setBackgroundRadiusnull490         fun setBackgroundRadius(topCornerRadius: Float, bottomCornerRadius: Float) {
491             updateRadii(cornerRadii, topCornerRadius, bottomCornerRadius)
492             invalidateSelf()
493         }
494 
updateRadiinull495         private fun updateRadii(
496             radii: FloatArray,
497             topCornerRadius: Float,
498             bottomCornerRadius: Float,
499         ) {
500             radii[0] = topCornerRadius
501             radii[1] = topCornerRadius
502             radii[2] = topCornerRadius
503             radii[3] = topCornerRadius
504 
505             radii[4] = bottomCornerRadius
506             radii[5] = bottomCornerRadius
507             radii[6] = bottomCornerRadius
508             radii[7] = bottomCornerRadius
509         }
510 
applyBackgroundRadiinull511         private fun applyBackgroundRadii() {
512             if (cornerRadii[0] < 0 || wrapped == null) {
513                 return
514             }
515 
516             savePreviousBackgroundRadii(wrapped)
517             applyBackgroundRadii(wrapped, cornerRadii)
518         }
519 
savePreviousBackgroundRadiinull520         private fun savePreviousBackgroundRadii(background: Drawable) {
521             // TODO(b/184121838): This method assumes that all GradientDrawable in background will
522             // have the same radius. Should we save/restore the radii for each layer instead?
523             val gradient = findGradientDrawable(background) ?: return
524 
525             // TODO(b/184121838): GradientDrawable#getCornerRadii clones its radii array. Should we
526             // try to avoid that?
527             val radii = gradient.cornerRadii
528             if (radii != null) {
529                 radii.copyInto(previousCornerRadii)
530             } else {
531                 // Copy the cornerRadius into previousCornerRadii.
532                 val radius = gradient.cornerRadius
533                 updateRadii(previousCornerRadii, radius, radius)
534             }
535         }
536 
applyBackgroundRadiinull537         private fun applyBackgroundRadii(drawable: Drawable, radii: FloatArray) {
538             if (drawable is GradientDrawable) {
539                 drawable.cornerRadii = radii
540                 return
541             }
542 
543             if (drawable is InsetDrawable) {
544                 drawable.drawable?.let { applyBackgroundRadii(it, radii) }
545                 return
546             }
547 
548             if (drawable !is LayerDrawable) {
549                 return
550             }
551 
552             for (i in 0 until drawable.numberOfLayers) {
553                 applyBackgroundRadii(drawable.getDrawable(i), radii)
554             }
555         }
556 
restoreBackgroundRadiinull557         private fun restoreBackgroundRadii() {
558             if (cornerRadii[0] < 0 || wrapped == null) {
559                 return
560             }
561 
562             applyBackgroundRadii(wrapped, previousCornerRadii)
563         }
564     }
565 }
566