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