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.graphics.Canvas
20 import android.graphics.ColorFilter
21 import android.graphics.Insets
22 import android.graphics.Matrix
23 import android.graphics.PixelFormat
24 import android.graphics.Rect
25 import android.graphics.drawable.Drawable
26 import android.graphics.drawable.GradientDrawable
27 import android.graphics.drawable.InsetDrawable
28 import android.graphics.drawable.LayerDrawable
29 import android.graphics.drawable.StateListDrawable
30 import android.util.Log
31 import android.view.GhostView
32 import android.view.View
33 import android.view.ViewGroup
34 import android.view.ViewGroupOverlay
35 import android.widget.FrameLayout
36 import com.android.internal.jank.InteractionJankMonitor
37 import java.util.LinkedList
38 import kotlin.math.min
39 import kotlin.math.roundToInt
40 
41 private const val TAG = "GhostedViewLaunchAnimatorController"
42 
43 /**
44  * A base implementation of [ActivityLaunchAnimator.Controller] which creates a [ghost][GhostView]
45  * of [ghostedView] as well as an expandable background view, which are drawn and animated instead
46  * of the ghosted view.
47  *
48  * Important: [ghostedView] must be attached to a [ViewGroup] when calling this function and during
49  * the animation. It must also implement [LaunchableView], otherwise an exception will be thrown
50  * during this controller instantiation.
51  *
52  * Note: Avoid instantiating this directly and call [ActivityLaunchAnimator.Controller.fromView]
53  * whenever possible instead.
54  */
55 open class GhostedViewLaunchAnimatorController
56 @JvmOverloads
57 constructor(
58     /** The view that will be ghosted and from which the background will be extracted. */
59     private val ghostedView: View,
60 
61     /** The [InteractionJankMonitor.CujType] associated to this animation. */
62     private val cujType: Int? = null,
63     private var interactionJankMonitor: InteractionJankMonitor =
64         InteractionJankMonitor.getInstance(),
65 ) : ActivityLaunchAnimator.Controller {
66 
67     /** The container to which we will add the ghost view and expanding background. */
68     override var launchContainer = ghostedView.rootView as ViewGroup
69     private val launchContainerOverlay: ViewGroupOverlay
70         get() = launchContainer.overlay
71     private val launchContainerLocation = IntArray(2)
72 
73     /** The ghost view that is drawn and animated instead of the ghosted view. */
74     private var ghostView: GhostView? = null
75     private val initialGhostViewMatrixValues = FloatArray(9) { 0f }
76     private val ghostViewMatrix = Matrix()
77 
78     /**
79      * The expanding background view that will be added to [launchContainer] (below [ghostView]) and
80      * animate.
81      */
82     private var backgroundView: FrameLayout? = null
83 
84     /**
85      * The drawable wrapping the [ghostedView] background and used as background for
86      * [backgroundView].
87      */
88     private var backgroundDrawable: WrappedDrawable? = null
89     private val backgroundInsets by lazy { background?.opticalInsets ?: Insets.NONE }
90     private var startBackgroundAlpha: Int = 0xFF
91 
92     private val ghostedViewLocation = IntArray(2)
93     private val ghostedViewState = LaunchAnimator.State()
94 
95     /**
96      * The background of the [ghostedView]. This background will be used to draw the background of
97      * the background view that is expanding up to the final animation position.
98      *
99      * Note that during the animation, the alpha value value of this background will be set to 0,
100      * then set back to its initial value at the end of the animation.
101      */
102     private val background: Drawable?
103 
104     init {
105         // Make sure the View we launch from implements LaunchableView to avoid visibility issues.
106         if (ghostedView !is LaunchableView) {
107             throw IllegalArgumentException(
108                 "A GhostedViewLaunchAnimatorController was created from a View that does not " +
109                     "implement LaunchableView. This can lead to subtle bugs where the visibility " +
110                     "of the View we are launching from is not what we expected."
111             )
112         }
113 
114         /** Find the first view with a background in [view] and its children. */
115         fun findBackground(view: View): Drawable? {
116             if (view.background != null) {
117                 return view.background
118             }
119 
120             // Perform a BFS to find the largest View with background.
121             val views = LinkedList<View>().apply { add(view) }
122 
123             while (views.isNotEmpty()) {
124                 val v = views.removeFirst()
125                 if (v.background != null) {
126                     return v.background
127                 }
128 
129                 if (v is ViewGroup) {
130                     for (i in 0 until v.childCount) {
131                         views.add(v.getChildAt(i))
132                     }
133                 }
134             }
135 
136             return null
137         }
138 
139         background = findBackground(ghostedView)
140     }
141 
142     /**
143      * Set the corner radius of [background]. The background is the one that was returned by
144      * [getBackground].
145      */
146     protected open fun setBackgroundCornerRadius(
147         background: Drawable,
148         topCornerRadius: Float,
149         bottomCornerRadius: Float
150     ) {
151         // By default, we rely on WrappedDrawable to set/restore the background radii before/after
152         // each draw.
153         backgroundDrawable?.setBackgroundRadius(topCornerRadius, bottomCornerRadius)
154     }
155 
156     /** Return the current top corner radius of the background. */
157     protected open fun getCurrentTopCornerRadius(): Float {
158         val drawable = background ?: return 0f
159         val gradient = findGradientDrawable(drawable) ?: return 0f
160 
161         // TODO(b/184121838): Support more than symmetric top & bottom radius.
162         val radius = gradient.cornerRadii?.get(CORNER_RADIUS_TOP_INDEX) ?: gradient.cornerRadius
163         return radius * ghostedView.scaleX
164     }
165 
166     /** Return the current bottom corner radius of the background. */
167     protected open fun getCurrentBottomCornerRadius(): Float {
168         val drawable = background ?: return 0f
169         val gradient = findGradientDrawable(drawable) ?: return 0f
170 
171         // TODO(b/184121838): Support more than symmetric top & bottom radius.
172         val radius = gradient.cornerRadii?.get(CORNER_RADIUS_BOTTOM_INDEX) ?: gradient.cornerRadius
173         return radius * ghostedView.scaleX
174     }
175 
176     override fun createAnimatorState(): LaunchAnimator.State {
177         val state =
178             LaunchAnimator.State(
179                 topCornerRadius = getCurrentTopCornerRadius(),
180                 bottomCornerRadius = getCurrentBottomCornerRadius()
181             )
182         fillGhostedViewState(state)
183         return state
184     }
185 
186     fun fillGhostedViewState(state: LaunchAnimator.State) {
187         // For the animation we are interested in the area that has a non transparent background,
188         // so we have to take the optical insets into account.
189         ghostedView.getLocationOnScreen(ghostedViewLocation)
190         val insets = backgroundInsets
191         state.top = ghostedViewLocation[1] + insets.top
192         state.bottom =
193             ghostedViewLocation[1] + (ghostedView.height * ghostedView.scaleY).roundToInt() -
194                 insets.bottom
195         state.left = ghostedViewLocation[0] + insets.left
196         state.right =
197             ghostedViewLocation[0] + (ghostedView.width * ghostedView.scaleX).roundToInt() -
198                 insets.right
199     }
200 
201     override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {
202         if (ghostedView.parent !is ViewGroup) {
203             // This should usually not happen, but let's make sure we don't crash if the view was
204             // detached right before we started the animation.
205             Log.w(TAG, "Skipping animation as ghostedView is not attached to a ViewGroup")
206             return
207         }
208 
209         backgroundView = FrameLayout(launchContainer.context).also {
210             launchContainerOverlay.add(it)
211         }
212 
213         // We wrap the ghosted view background and use it to draw the expandable background. Its
214         // alpha will be set to 0 as soon as we start drawing the expanding background.
215         startBackgroundAlpha = background?.alpha ?: 0xFF
216         backgroundDrawable = WrappedDrawable(background)
217         backgroundView?.background = backgroundDrawable
218 
219         // Delay the calls to `ghostedView.setVisibility()` during the animation. This must be
220         // called before `GhostView.addGhost()` is called because the latter will change the
221         // *transition* visibility, which won't be blocked and will affect the normal View
222         // visibility that is saved by `setShouldBlockVisibilityChanges()` for a later restoration.
223         (ghostedView as? LaunchableView)?.setShouldBlockVisibilityChanges(true)
224 
225         // Create a ghost of the view that will be moving and fading out. This allows to fade out
226         // the content before fading out the background.
227         ghostView = GhostView.addGhost(ghostedView, launchContainer)
228 
229         val matrix = ghostView?.animationMatrix ?: Matrix.IDENTITY_MATRIX
230         matrix.getValues(initialGhostViewMatrixValues)
231 
232         cujType?.let { interactionJankMonitor.begin(ghostedView, it) }
233     }
234 
235     override fun onLaunchAnimationProgress(
236         state: LaunchAnimator.State,
237         progress: Float,
238         linearProgress: Float
239     ) {
240         val ghostView = this.ghostView ?: return
241         val backgroundView = this.backgroundView!!
242 
243         if (!state.visible || !ghostedView.isAttachedToWindow) {
244             if (ghostView.visibility == View.VISIBLE) {
245                 // Making the ghost view invisible will make the ghosted view visible, so order is
246                 // important here.
247                 ghostView.visibility = View.INVISIBLE
248 
249                 // Make the ghosted view invisible again. We use the transition visibility like
250                 // GhostView does so that we don't mess up with the accessibility tree (see
251                 // b/204944038#comment17).
252                 ghostedView.setTransitionVisibility(View.INVISIBLE)
253                 backgroundView.visibility = View.INVISIBLE
254             }
255             return
256         }
257 
258         // The ghost and backgrounds views were made invisible earlier. That can for instance happen
259         // when animating a dialog into a view.
260         if (ghostView.visibility == View.INVISIBLE) {
261             ghostView.visibility = View.VISIBLE
262             backgroundView.visibility = View.VISIBLE
263         }
264 
265         fillGhostedViewState(ghostedViewState)
266         val leftChange = state.left - ghostedViewState.left
267         val rightChange = state.right - ghostedViewState.right
268         val topChange = state.top - ghostedViewState.top
269         val bottomChange = state.bottom - ghostedViewState.bottom
270 
271         val widthRatio = state.width.toFloat() / ghostedViewState.width
272         val heightRatio = state.height.toFloat() / ghostedViewState.height
273         val scale = min(widthRatio, heightRatio)
274 
275         if (ghostedView.parent is ViewGroup) {
276             // Recalculate the matrix in case the ghosted view moved. We ensure that the ghosted
277             // view is still attached to a ViewGroup, otherwise calculateMatrix will throw.
278             GhostView.calculateMatrix(ghostedView, launchContainer, ghostViewMatrix)
279         }
280 
281         launchContainer.getLocationOnScreen(launchContainerLocation)
282         ghostViewMatrix.postScale(
283             scale,
284             scale,
285             ghostedViewState.centerX - launchContainerLocation[0],
286             ghostedViewState.centerY - launchContainerLocation[1]
287         )
288         ghostViewMatrix.postTranslate(
289             (leftChange + rightChange) / 2f,
290             (topChange + bottomChange) / 2f
291         )
292         ghostView.animationMatrix = ghostViewMatrix
293 
294         // We need to take into account the background insets for the background position.
295         val insets = backgroundInsets
296         val topWithInsets = state.top - insets.top
297         val leftWithInsets = state.left - insets.left
298         val rightWithInsets = state.right + insets.right
299         val bottomWithInsets = state.bottom + insets.bottom
300 
301         backgroundView.top = topWithInsets - launchContainerLocation[1]
302         backgroundView.bottom = bottomWithInsets - launchContainerLocation[1]
303         backgroundView.left = leftWithInsets - launchContainerLocation[0]
304         backgroundView.right = rightWithInsets - launchContainerLocation[0]
305 
306         val backgroundDrawable = backgroundDrawable!!
307         backgroundDrawable.wrapped?.let {
308             setBackgroundCornerRadius(it, state.topCornerRadius, state.bottomCornerRadius)
309         }
310     }
311 
312     override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
313         if (ghostView == null) {
314             // We didn't actually run the animation.
315             return
316         }
317 
318         cujType?.let { interactionJankMonitor.end(it) }
319 
320         backgroundDrawable?.wrapped?.alpha = startBackgroundAlpha
321 
322         GhostView.removeGhost(ghostedView)
323         backgroundView?.let { launchContainerOverlay.remove(it) }
324 
325         if (ghostedView is LaunchableView) {
326             // Restore the ghosted view visibility.
327             ghostedView.setShouldBlockVisibilityChanges(false)
328         } else {
329             // Make the ghosted view visible. We ensure that the view is considered VISIBLE by
330             // accessibility by first making it INVISIBLE then VISIBLE (see b/204944038#comment17
331             // for more info).
332             ghostedView.visibility = View.INVISIBLE
333             ghostedView.visibility = View.VISIBLE
334             ghostedView.invalidate()
335         }
336     }
337 
338     companion object {
339         private const val CORNER_RADIUS_TOP_INDEX = 0
340         private const val CORNER_RADIUS_BOTTOM_INDEX = 4
341 
342         /**
343          * Return the first [GradientDrawable] found in [drawable], or null if none is found. If
344          * [drawable] is a [LayerDrawable], this will return the first layer that is a
345          * [GradientDrawable].
346          */
347         fun findGradientDrawable(drawable: Drawable): GradientDrawable? {
348             if (drawable is GradientDrawable) {
349                 return drawable
350             }
351 
352             if (drawable is InsetDrawable) {
353                 return drawable.drawable?.let { findGradientDrawable(it) }
354             }
355 
356             if (drawable is LayerDrawable) {
357                 for (i in 0 until drawable.numberOfLayers) {
358                     val maybeGradient = drawable.getDrawable(i)
359                     if (maybeGradient is GradientDrawable) {
360                         return maybeGradient
361                     }
362                 }
363             }
364 
365             if (drawable is StateListDrawable) {
366                 return findGradientDrawable(drawable.current)
367             }
368 
369             return null
370         }
371     }
372 
373     private class WrappedDrawable(val wrapped: Drawable?) : Drawable() {
374         private var currentAlpha = 0xFF
375         private var previousBounds = Rect()
376 
377         private var cornerRadii = FloatArray(8) { -1f }
378         private var previousCornerRadii = FloatArray(8)
379 
380         override fun draw(canvas: Canvas) {
381             val wrapped = this.wrapped ?: return
382 
383             wrapped.copyBounds(previousBounds)
384 
385             wrapped.alpha = currentAlpha
386             wrapped.bounds = bounds
387             applyBackgroundRadii()
388 
389             wrapped.draw(canvas)
390 
391             // The background view (and therefore this drawable) is drawn before the ghost view, so
392             // the ghosted view background alpha should always be 0 when it is drawn above the
393             // background.
394             wrapped.alpha = 0
395             wrapped.bounds = previousBounds
396             restoreBackgroundRadii()
397         }
398 
399         override fun setAlpha(alpha: Int) {
400             if (alpha != currentAlpha) {
401                 currentAlpha = alpha
402                 invalidateSelf()
403             }
404         }
405 
406         override fun getAlpha() = currentAlpha
407 
408         override fun getOpacity(): Int {
409             val wrapped = this.wrapped ?: return PixelFormat.TRANSPARENT
410 
411             val previousAlpha = wrapped.alpha
412             wrapped.alpha = currentAlpha
413             val opacity = wrapped.opacity
414             wrapped.alpha = previousAlpha
415             return opacity
416         }
417 
418         override fun setColorFilter(filter: ColorFilter?) {
419             wrapped?.colorFilter = filter
420         }
421 
422         fun setBackgroundRadius(topCornerRadius: Float, bottomCornerRadius: Float) {
423             updateRadii(cornerRadii, topCornerRadius, bottomCornerRadius)
424             invalidateSelf()
425         }
426 
427         private fun updateRadii(
428             radii: FloatArray,
429             topCornerRadius: Float,
430             bottomCornerRadius: Float
431         ) {
432             radii[0] = topCornerRadius
433             radii[1] = topCornerRadius
434             radii[2] = topCornerRadius
435             radii[3] = topCornerRadius
436 
437             radii[4] = bottomCornerRadius
438             radii[5] = bottomCornerRadius
439             radii[6] = bottomCornerRadius
440             radii[7] = bottomCornerRadius
441         }
442 
443         private fun applyBackgroundRadii() {
444             if (cornerRadii[0] < 0 || wrapped == null) {
445                 return
446             }
447 
448             savePreviousBackgroundRadii(wrapped)
449             applyBackgroundRadii(wrapped, cornerRadii)
450         }
451 
452         private fun savePreviousBackgroundRadii(background: Drawable) {
453             // TODO(b/184121838): This method assumes that all GradientDrawable in background will
454             // have the same radius. Should we save/restore the radii for each layer instead?
455             val gradient = findGradientDrawable(background) ?: return
456 
457             // TODO(b/184121838): GradientDrawable#getCornerRadii clones its radii array. Should we
458             // try to avoid that?
459             val radii = gradient.cornerRadii
460             if (radii != null) {
461                 radii.copyInto(previousCornerRadii)
462             } else {
463                 // Copy the cornerRadius into previousCornerRadii.
464                 val radius = gradient.cornerRadius
465                 updateRadii(previousCornerRadii, radius, radius)
466             }
467         }
468 
469         private fun applyBackgroundRadii(drawable: Drawable, radii: FloatArray) {
470             if (drawable is GradientDrawable) {
471                 drawable.cornerRadii = radii
472                 return
473             }
474 
475             if (drawable is InsetDrawable) {
476                 drawable.drawable?.let { applyBackgroundRadii(it, radii) }
477                 return
478             }
479 
480             if (drawable !is LayerDrawable) {
481                 return
482             }
483 
484             for (i in 0 until drawable.numberOfLayers) {
485                 (drawable.getDrawable(i) as? GradientDrawable)?.cornerRadii = radii
486             }
487         }
488 
489         private fun restoreBackgroundRadii() {
490             if (cornerRadii[0] < 0 || wrapped == null) {
491                 return
492             }
493 
494             applyBackgroundRadii(wrapped, previousCornerRadii)
495         }
496     }
497 }
498