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