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.animation.Animator
20 import android.animation.AnimatorListenerAdapter
21 import android.animation.ValueAnimator
22 import android.content.Context
23 import android.graphics.PorterDuff
24 import android.graphics.PorterDuffXfermode
25 import android.graphics.drawable.GradientDrawable
26 import android.util.Log
27 import android.util.MathUtils
28 import android.view.View
29 import android.view.ViewGroup
30 import android.view.animation.Interpolator
31 import com.android.app.animation.Interpolators.LINEAR
32 import kotlin.math.roundToInt
33 
34 private const val TAG = "LaunchAnimator"
35 
36 /** A base class to animate a window launch (activity or dialog) from a view . */
37 class LaunchAnimator(private val timings: Timings, private val interpolators: Interpolators) {
38     companion object {
39         internal const val DEBUG = false
40         private val SRC_MODE = PorterDuffXfermode(PorterDuff.Mode.SRC)
41 
42         /**
43          * Given the [linearProgress] of a launch animation, return the linear progress of the
44          * sub-animation starting [delay] ms after the launch animation and that lasts [duration].
45          */
46         @JvmStatic
47         fun getProgress(
48             timings: Timings,
49             linearProgress: Float,
50             delay: Long,
51             duration: Long
52         ): Float {
53             return MathUtils.constrain(
54                 (linearProgress * timings.totalDuration - delay) / duration,
55                 0.0f,
56                 1.0f
57             )
58         }
59     }
60 
61     private val launchContainerLocation = IntArray(2)
62     private val cornerRadii = FloatArray(8)
63 
64     /**
65      * A controller that takes care of applying the animation to an expanding view.
66      *
67      * Note that all callbacks (onXXX methods) are all called on the main thread.
68      */
69     interface Controller {
70         /**
71          * The container in which the view that started the animation will be animating together
72          * with the opening window.
73          *
74          * This will be used to:
75          * - Get the associated [Context].
76          * - Compute whether we are expanding fully above the launch container.
77          * - Get to overlay to which we initially put the window background layer, until the opening
78          *   window is made visible (see [openingWindowSyncView]).
79          *
80          * This container can be changed to force this [Controller] to animate the expanding view
81          * inside a different location, for instance to ensure correct layering during the
82          * animation.
83          */
84         var launchContainer: ViewGroup
85 
86         /**
87          * The [View] with which the opening app window should be synchronized with once it starts
88          * to be visible.
89          *
90          * We will also move the window background layer to this view's overlay once the opening
91          * window is visible.
92          *
93          * If null, this will default to [launchContainer].
94          */
95         val openingWindowSyncView: View?
96             get() = null
97 
98         /**
99          * Return the [State] of the view that will be animated. We will animate from this state to
100          * the final window state.
101          *
102          * Note: This state will be mutated and passed to [onLaunchAnimationProgress] during the
103          * animation.
104          */
105         fun createAnimatorState(): State
106 
107         /**
108          * The animation started. This is typically used to initialize any additional resource
109          * needed for the animation. [isExpandingFullyAbove] will be true if the window is expanding
110          * fully above the [launchContainer].
111          */
112         fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {}
113 
114         /** The animation made progress and the expandable view [state] should be updated. */
115         fun onLaunchAnimationProgress(state: State, progress: Float, linearProgress: Float) {}
116 
117         /**
118          * The animation ended. This will be called *if and only if* [onLaunchAnimationStart] was
119          * called previously. This is typically used to clean up the resources initialized when the
120          * animation was started.
121          */
122         fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {}
123     }
124 
125     /** The state of an expandable view during a [LaunchAnimator] animation. */
126     open class State(
127         /** The position of the view in screen space coordinates. */
128         var top: Int = 0,
129         var bottom: Int = 0,
130         var left: Int = 0,
131         var right: Int = 0,
132         var topCornerRadius: Float = 0f,
133         var bottomCornerRadius: Float = 0f
134     ) {
135         private val startTop = top
136 
137         val width: Int
138             get() = right - left
139 
140         val height: Int
141             get() = bottom - top
142 
143         open val topChange: Int
144             get() = top - startTop
145 
146         val centerX: Float
147             get() = left + width / 2f
148 
149         val centerY: Float
150             get() = top + height / 2f
151 
152         /** Whether the expanding view should be visible or hidden. */
153         var visible: Boolean = true
154     }
155 
156     interface Animation {
157         /** Cancel the animation. */
158         fun cancel()
159     }
160 
161     /** The timings (durations and delays) used by this animator. */
162     data class Timings(
163         /** The total duration of the animation. */
164         val totalDuration: Long,
165 
166         /** The time to wait before fading out the expanding content. */
167         val contentBeforeFadeOutDelay: Long,
168 
169         /** The duration of the expanding content fade out. */
170         val contentBeforeFadeOutDuration: Long,
171 
172         /**
173          * The time to wait before fading in the expanded content (usually an activity or dialog
174          * window).
175          */
176         val contentAfterFadeInDelay: Long,
177 
178         /** The duration of the expanded content fade in. */
179         val contentAfterFadeInDuration: Long
180     )
181 
182     /** The interpolators used by this animator. */
183     data class Interpolators(
184         /** The interpolator used for the Y position, width, height and corner radius. */
185         val positionInterpolator: Interpolator,
186 
187         /**
188          * The interpolator used for the X position. This can be different than
189          * [positionInterpolator] to create an arc-path during the animation.
190          */
191         val positionXInterpolator: Interpolator = positionInterpolator,
192 
193         /** The interpolator used when fading out the expanding content. */
194         val contentBeforeFadeOutInterpolator: Interpolator,
195 
196         /** The interpolator used when fading in the expanded content. */
197         val contentAfterFadeInInterpolator: Interpolator
198     )
199 
200     /**
201      * Start a launch animation controlled by [controller] towards [endState]. An intermediary layer
202      * with [windowBackgroundColor] will fade in then (optionally) fade out above the expanding
203      * view, and should be the same background color as the opening (or closing) window.
204      *
205      * If [fadeOutWindowBackgroundLayer] is true, then this intermediary layer will fade out during
206      * the second half of the animation, and will have SRC blending mode (ultimately punching a hole
207      * in the [launch container][Controller.launchContainer]) iff [drawHole] is true.
208      */
209     fun startAnimation(
210         controller: Controller,
211         endState: State,
212         windowBackgroundColor: Int,
213         fadeOutWindowBackgroundLayer: Boolean = true,
214         drawHole: Boolean = false,
215     ): Animation {
216         val state = controller.createAnimatorState()
217 
218         // Start state.
219         val startTop = state.top
220         val startBottom = state.bottom
221         val startLeft = state.left
222         val startRight = state.right
223         val startCenterX = (startLeft + startRight) / 2f
224         val startWidth = startRight - startLeft
225         val startTopCornerRadius = state.topCornerRadius
226         val startBottomCornerRadius = state.bottomCornerRadius
227 
228         // End state.
229         var endTop = endState.top
230         var endBottom = endState.bottom
231         var endLeft = endState.left
232         var endRight = endState.right
233         var endCenterX = (endLeft + endRight) / 2f
234         var endWidth = endRight - endLeft
235         val endTopCornerRadius = endState.topCornerRadius
236         val endBottomCornerRadius = endState.bottomCornerRadius
237 
238         fun maybeUpdateEndState() {
239             if (
240                 endTop != endState.top ||
241                     endBottom != endState.bottom ||
242                     endLeft != endState.left ||
243                     endRight != endState.right
244             ) {
245                 endTop = endState.top
246                 endBottom = endState.bottom
247                 endLeft = endState.left
248                 endRight = endState.right
249                 endCenterX = (endLeft + endRight) / 2f
250                 endWidth = endRight - endLeft
251             }
252         }
253 
254         val launchContainer = controller.launchContainer
255         val isExpandingFullyAbove = isExpandingFullyAbove(launchContainer, endState)
256 
257         // We add an extra layer with the same color as the dialog/app splash screen background
258         // color, which is usually the same color of the app background. We first fade in this layer
259         // to hide the expanding view, then we fade it out with SRC mode to draw a hole in the
260         // launch container and reveal the opening window.
261         val windowBackgroundLayer =
262             GradientDrawable().apply {
263                 setColor(windowBackgroundColor)
264                 alpha = 0
265             }
266 
267         // Update state.
268         val animator = ValueAnimator.ofFloat(0f, 1f)
269         animator.duration = timings.totalDuration
270         animator.interpolator = LINEAR
271 
272         // Whether we should move the [windowBackgroundLayer] into the overlay of
273         // [Controller.openingWindowSyncView] once the opening app window starts to be visible.
274         val openingWindowSyncView = controller.openingWindowSyncView
275         val openingWindowSyncViewOverlay = openingWindowSyncView?.overlay
276         val moveBackgroundLayerWhenAppIsVisible =
277             openingWindowSyncView != null &&
278                 openingWindowSyncView.viewRootImpl != controller.launchContainer.viewRootImpl
279 
280         val launchContainerOverlay = launchContainer.overlay
281         var cancelled = false
282         var movedBackgroundLayer = false
283 
284         animator.addListener(
285             object : AnimatorListenerAdapter() {
286                 override fun onAnimationStart(animation: Animator, isReverse: Boolean) {
287                     if (DEBUG) {
288                         Log.d(TAG, "Animation started")
289                     }
290                     controller.onLaunchAnimationStart(isExpandingFullyAbove)
291 
292                     // Add the drawable to the launch container overlay. Overlays always draw
293                     // drawables after views, so we know that it will be drawn above any view added
294                     // by the controller.
295                     launchContainerOverlay.add(windowBackgroundLayer)
296                 }
297 
298                 override fun onAnimationEnd(animation: Animator) {
299                     if (DEBUG) {
300                         Log.d(TAG, "Animation ended")
301                     }
302                     controller.onLaunchAnimationEnd(isExpandingFullyAbove)
303                     launchContainerOverlay.remove(windowBackgroundLayer)
304 
305                     if (moveBackgroundLayerWhenAppIsVisible) {
306                         openingWindowSyncViewOverlay?.remove(windowBackgroundLayer)
307                     }
308                 }
309             }
310         )
311 
312         animator.addUpdateListener { animation ->
313             if (cancelled) {
314                 // TODO(b/184121838): Cancel the animator directly instead of just skipping the
315                 // update.
316                 return@addUpdateListener
317             }
318 
319             maybeUpdateEndState()
320 
321             // TODO(b/184121838): Use reverse interpolators to get the same path/arc as the non
322             // reversed animation.
323             val linearProgress = animation.animatedFraction
324             val progress = interpolators.positionInterpolator.getInterpolation(linearProgress)
325             val xProgress = interpolators.positionXInterpolator.getInterpolation(linearProgress)
326 
327             val xCenter = MathUtils.lerp(startCenterX, endCenterX, xProgress)
328             val halfWidth = MathUtils.lerp(startWidth, endWidth, progress) / 2f
329 
330             state.top = MathUtils.lerp(startTop, endTop, progress).roundToInt()
331             state.bottom = MathUtils.lerp(startBottom, endBottom, progress).roundToInt()
332             state.left = (xCenter - halfWidth).roundToInt()
333             state.right = (xCenter + halfWidth).roundToInt()
334 
335             state.topCornerRadius =
336                 MathUtils.lerp(startTopCornerRadius, endTopCornerRadius, progress)
337             state.bottomCornerRadius =
338                 MathUtils.lerp(startBottomCornerRadius, endBottomCornerRadius, progress)
339 
340             // The expanding view can/should be hidden once it is completely covered by the opening
341             // window.
342             state.visible =
343                 getProgress(
344                     timings,
345                     linearProgress,
346                     timings.contentBeforeFadeOutDelay,
347                     timings.contentBeforeFadeOutDuration
348                 ) < 1
349 
350             if (moveBackgroundLayerWhenAppIsVisible && !state.visible && !movedBackgroundLayer) {
351                 // The expanding view is not visible, so the opening app is visible. If this is the
352                 // first frame when it happens, trigger a one-off sync and move the background layer
353                 // in its new container.
354                 movedBackgroundLayer = true
355 
356                 launchContainerOverlay.remove(windowBackgroundLayer)
357                 openingWindowSyncViewOverlay!!.add(windowBackgroundLayer)
358 
359                 ViewRootSync.synchronizeNextDraw(launchContainer, openingWindowSyncView, then = {})
360             }
361 
362             val container =
363                 if (movedBackgroundLayer) {
364                     openingWindowSyncView!!
365                 } else {
366                     controller.launchContainer
367                 }
368 
369             applyStateToWindowBackgroundLayer(
370                 windowBackgroundLayer,
371                 state,
372                 linearProgress,
373                 container,
374                 fadeOutWindowBackgroundLayer,
375                 drawHole
376             )
377             controller.onLaunchAnimationProgress(state, progress, linearProgress)
378         }
379 
380         animator.start()
381         return object : Animation {
382             override fun cancel() {
383                 cancelled = true
384                 animator.cancel()
385             }
386         }
387     }
388 
389     /** Return whether we are expanding fully above the [launchContainer]. */
390     internal fun isExpandingFullyAbove(launchContainer: View, endState: State): Boolean {
391         launchContainer.getLocationOnScreen(launchContainerLocation)
392         return endState.top <= launchContainerLocation[1] &&
393             endState.bottom >= launchContainerLocation[1] + launchContainer.height &&
394             endState.left <= launchContainerLocation[0] &&
395             endState.right >= launchContainerLocation[0] + launchContainer.width
396     }
397 
398     private fun applyStateToWindowBackgroundLayer(
399         drawable: GradientDrawable,
400         state: State,
401         linearProgress: Float,
402         launchContainer: View,
403         fadeOutWindowBackgroundLayer: Boolean,
404         drawHole: Boolean
405     ) {
406         // Update position.
407         launchContainer.getLocationOnScreen(launchContainerLocation)
408         drawable.setBounds(
409             state.left - launchContainerLocation[0],
410             state.top - launchContainerLocation[1],
411             state.right - launchContainerLocation[0],
412             state.bottom - launchContainerLocation[1]
413         )
414 
415         // Update radius.
416         cornerRadii[0] = state.topCornerRadius
417         cornerRadii[1] = state.topCornerRadius
418         cornerRadii[2] = state.topCornerRadius
419         cornerRadii[3] = state.topCornerRadius
420         cornerRadii[4] = state.bottomCornerRadius
421         cornerRadii[5] = state.bottomCornerRadius
422         cornerRadii[6] = state.bottomCornerRadius
423         cornerRadii[7] = state.bottomCornerRadius
424         drawable.cornerRadii = cornerRadii
425 
426         // We first fade in the background layer to hide the expanding view, then fade it out
427         // with SRC mode to draw a hole punch in the status bar and reveal the opening window.
428         val fadeInProgress =
429             getProgress(
430                 timings,
431                 linearProgress,
432                 timings.contentBeforeFadeOutDelay,
433                 timings.contentBeforeFadeOutDuration
434             )
435         if (fadeInProgress < 1) {
436             val alpha =
437                 interpolators.contentBeforeFadeOutInterpolator.getInterpolation(fadeInProgress)
438             drawable.alpha = (alpha * 0xFF).roundToInt()
439         } else if (fadeOutWindowBackgroundLayer) {
440             val fadeOutProgress =
441                 getProgress(
442                     timings,
443                     linearProgress,
444                     timings.contentAfterFadeInDelay,
445                     timings.contentAfterFadeInDuration
446                 )
447             val alpha =
448                 1 - interpolators.contentAfterFadeInInterpolator.getInterpolation(fadeOutProgress)
449             drawable.alpha = (alpha * 0xFF).roundToInt()
450 
451             if (drawHole) {
452                 drawable.setXfermode(SRC_MODE)
453             }
454         } else {
455             drawable.alpha = 0xFF
456         }
457     }
458 }
459