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.app.Dialog 23 import android.graphics.Color 24 import android.graphics.Rect 25 import android.os.Looper 26 import android.util.Log 27 import android.util.MathUtils 28 import android.view.View 29 import android.view.ViewGroup 30 import android.view.ViewGroup.LayoutParams.MATCH_PARENT 31 import android.view.ViewRootImpl 32 import android.view.WindowInsets 33 import android.view.WindowManager 34 import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS 35 import com.android.app.animation.Interpolators 36 import com.android.internal.jank.InteractionJankMonitor 37 import com.android.internal.jank.InteractionJankMonitor.CujType 38 import com.android.systemui.util.maybeForceFullscreen 39 import com.android.systemui.util.registerAnimationOnBackInvoked 40 import kotlin.math.roundToInt 41 42 private const val TAG = "DialogLaunchAnimator" 43 44 /** 45 * A class that allows dialogs to be started in a seamless way from a view that is transforming 46 * nicely into the starting dialog. 47 * 48 * This animator also allows to easily animate a dialog into an activity. 49 * 50 * @see show 51 * @see showFromView 52 * @see showFromDialog 53 * @see createActivityLaunchController 54 */ 55 class DialogLaunchAnimator 56 @JvmOverloads 57 constructor( 58 private val callback: Callback, 59 private val interactionJankMonitor: InteractionJankMonitor, 60 private val featureFlags: AnimationFeatureFlags, 61 private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS), 62 private val isForTesting: Boolean = false, 63 ) { 64 private companion object { 65 private val TIMINGS = ActivityLaunchAnimator.TIMINGS 66 67 // We use the same interpolator for X and Y axis to make sure the dialog does not move out 68 // of the screen bounds during the animation. 69 private val INTERPOLATORS = 70 ActivityLaunchAnimator.INTERPOLATORS.copy( 71 positionXInterpolator = ActivityLaunchAnimator.INTERPOLATORS.positionInterpolator 72 ) 73 } 74 75 /** 76 * A controller that takes care of applying the dialog launch and exit animations to the source 77 * that triggered the animation. 78 */ 79 interface Controller { 80 /** The [ViewRootImpl] of this controller. */ 81 val viewRoot: ViewRootImpl? 82 83 /** 84 * The identity object of the source animated by this controller. This animator will ensure 85 * that 2 animations with the same source identity are not going to run at the same time, to 86 * avoid flickers when a dialog is shown from the same source more or less at the same time 87 * (for instance if the user clicks an expandable button twice). 88 */ 89 val sourceIdentity: Any 90 91 /** The CUJ associated to this controller. */ 92 val cuj: DialogCuj? 93 94 /** 95 * Move the drawing of the source in the overlay of [viewGroup]. 96 * 97 * Once this method is called, and until [stopDrawingInOverlay] is called, the source 98 * controlled by this Controller should be drawn in the overlay of [viewGroup] so that it is 99 * drawn above all other elements in the same [viewRoot]. 100 */ 101 fun startDrawingInOverlayOf(viewGroup: ViewGroup) 102 103 /** 104 * Move the drawing of the source back in its original location. 105 * 106 * @see startDrawingInOverlayOf 107 */ 108 fun stopDrawingInOverlay() 109 110 /** 111 * Create the [LaunchAnimator.Controller] that will be called to animate the source 112 * controlled by this [Controller] during the dialog launch animation. 113 * 114 * At the end of this animation, the source should *not* be visible anymore (until the 115 * dialog is closed and is animated back into the source). 116 */ 117 fun createLaunchController(): LaunchAnimator.Controller 118 119 /** 120 * Create the [LaunchAnimator.Controller] that will be called to animate the source 121 * controlled by this [Controller] during the dialog exit animation. 122 * 123 * At the end of this animation, the source should be visible again. 124 */ 125 fun createExitController(): LaunchAnimator.Controller 126 127 /** 128 * Whether we should animate the dialog back into the source when it is dismissed. If this 129 * methods returns `false`, then the dialog will simply fade out and 130 * [onExitAnimationCancelled] will be called. 131 * 132 * Note that even when this returns `true`, the exit animation might still be cancelled (in 133 * which case [onExitAnimationCancelled] will also be called). 134 */ 135 fun shouldAnimateExit(): Boolean 136 137 /** 138 * Called if we decided to *not* animate the dialog into the source for some reason. This 139 * means that [createExitController] will *not* be called and this implementation should 140 * make sure that the source is back in its original state, before it was animated into the 141 * dialog. In particular, the source should be visible again. 142 */ 143 fun onExitAnimationCancelled() 144 145 /** 146 * Return the [InteractionJankMonitor.Configuration.Builder] to be used for animations 147 * controlled by this controller. 148 */ 149 // TODO(b/252723237): Make this non-nullable 150 fun jankConfigurationBuilder(): InteractionJankMonitor.Configuration.Builder? 151 152 companion object { 153 /** 154 * Create a [Controller] that can animate [source] to and from a dialog. 155 * 156 * Important: The view must be attached to a [ViewGroup] when calling this function and 157 * during the animation. For safety, this method will return null when it is not. The 158 * view must also implement [LaunchableView], otherwise this method will throw. 159 * 160 * Note: The background of [view] should be a (rounded) rectangle so that it can be 161 * properly animated. 162 */ 163 fun fromView(source: View, cuj: DialogCuj? = null): Controller? { 164 // Make sure the View we launch from implements LaunchableView to avoid visibility 165 // issues. 166 if (source !is LaunchableView) { 167 throw IllegalArgumentException( 168 "A DialogLaunchAnimator.Controller was created from a View that does not " + 169 "implement LaunchableView. This can lead to subtle bugs where the " + 170 "visibility of the View we are launching from is not what we expected." 171 ) 172 } 173 174 if (source.parent !is ViewGroup) { 175 Log.e( 176 TAG, 177 "Skipping animation as view $source is not attached to a ViewGroup", 178 Exception(), 179 ) 180 return null 181 } 182 183 return ViewDialogLaunchAnimatorController(source, cuj) 184 } 185 } 186 } 187 188 /** 189 * The set of dialogs that were animated using this animator and that are still opened (not 190 * dismissed, but can be hidden). 191 */ 192 // TODO(b/201264644): Remove this set. 193 private val openedDialogs = hashSetOf<AnimatedDialog>() 194 195 /** 196 * Show [dialog] by expanding it from [view]. If [view] is a view inside another dialog that was 197 * shown using this method, then we will animate from that dialog instead. 198 * 199 * If [animateBackgroundBoundsChange] is true, then the background of the dialog will be 200 * animated when the dialog bounds change. 201 * 202 * Note: The background of [view] should be a (rounded) rectangle so that it can be properly 203 * animated. 204 * 205 * Caveats: When calling this function and [dialog] is not a fullscreen dialog, then it will be 206 * made fullscreen and 2 views will be inserted between the dialog DecorView and its children. 207 */ 208 @JvmOverloads 209 fun showFromView( 210 dialog: Dialog, 211 view: View, 212 cuj: DialogCuj? = null, 213 animateBackgroundBoundsChange: Boolean = false 214 ) { 215 val controller = Controller.fromView(view, cuj) 216 if (controller == null) { 217 dialog.show() 218 } else { 219 show(dialog, controller, animateBackgroundBoundsChange) 220 } 221 } 222 223 /** 224 * Show [dialog] by expanding it from a source controlled by [controller]. 225 * 226 * If [animateBackgroundBoundsChange] is true, then the background of the dialog will be 227 * animated when the dialog bounds change. 228 * 229 * Note: The background of [view] should be a (rounded) rectangle so that it can be properly 230 * animated. 231 * 232 * Caveats: When calling this function and [dialog] is not a fullscreen dialog, then it will be 233 * made fullscreen and 2 views will be inserted between the dialog DecorView and its children. 234 */ 235 @JvmOverloads 236 fun show( 237 dialog: Dialog, 238 controller: Controller, 239 animateBackgroundBoundsChange: Boolean = false 240 ) { 241 if (Looper.myLooper() != Looper.getMainLooper()) { 242 throw IllegalStateException( 243 "showFromView must be called from the main thread and dialog must be created in " + 244 "the main thread" 245 ) 246 } 247 248 // If the view we are launching from belongs to another dialog, then this means the caller 249 // intent is to launch a dialog from another dialog. 250 val animatedParent = 251 openedDialogs.firstOrNull { 252 it.dialog.window?.decorView?.viewRootImpl == controller.viewRoot 253 } 254 val controller = 255 animatedParent?.dialogContentWithBackground?.let { 256 Controller.fromView(it, controller.cuj) 257 } 258 ?: controller 259 260 // Make sure we don't run the launch animation from the same source twice at the same time. 261 if (openedDialogs.any { it.controller.sourceIdentity == controller.sourceIdentity }) { 262 Log.e( 263 TAG, 264 "Not running dialog launch animation from source as it is already expanded into a" + 265 " dialog" 266 ) 267 dialog.show() 268 return 269 } 270 271 val animatedDialog = 272 AnimatedDialog( 273 launchAnimator = launchAnimator, 274 callback = callback, 275 interactionJankMonitor = interactionJankMonitor, 276 controller = controller, 277 onDialogDismissed = { openedDialogs.remove(it) }, 278 dialog = dialog, 279 animateBackgroundBoundsChange = animateBackgroundBoundsChange, 280 parentAnimatedDialog = animatedParent, 281 forceDisableSynchronization = isForTesting, 282 featureFlags = featureFlags, 283 ) 284 285 openedDialogs.add(animatedDialog) 286 animatedDialog.start() 287 } 288 289 /** 290 * Launch [dialog] from [another dialog][animateFrom] that was shown using [show]. This will 291 * allow for dismissing the whole stack. 292 * 293 * @see dismissStack 294 */ 295 fun showFromDialog( 296 dialog: Dialog, 297 animateFrom: Dialog, 298 cuj: DialogCuj? = null, 299 animateBackgroundBoundsChange: Boolean = false 300 ) { 301 val view = 302 openedDialogs.firstOrNull { it.dialog == animateFrom }?.dialogContentWithBackground 303 if (view == null) { 304 Log.w( 305 TAG, 306 "Showing dialog $dialog normally as the dialog it is shown from was not shown " + 307 "using DialogLaunchAnimator" 308 ) 309 dialog.show() 310 return 311 } 312 313 showFromView( 314 dialog, 315 view, 316 animateBackgroundBoundsChange = animateBackgroundBoundsChange, 317 cuj = cuj 318 ) 319 } 320 321 /** 322 * Create an [ActivityLaunchAnimator.Controller] that can be used to launch an activity from the 323 * dialog that contains [View]. Note that the dialog must have been shown using this animator, 324 * otherwise this method will return null. 325 * 326 * The returned controller will take care of dismissing the dialog at the right time after the 327 * activity started, when the dialog to app animation is done (or when it is cancelled). If this 328 * method returns null, then the dialog won't be dismissed. 329 * 330 * @param view any view inside the dialog to animate. 331 */ 332 @JvmOverloads 333 fun createActivityLaunchController( 334 view: View, 335 cujType: Int? = null, 336 ): ActivityLaunchAnimator.Controller? { 337 val animatedDialog = 338 openedDialogs.firstOrNull { 339 it.dialog.window?.decorView?.viewRootImpl == view.viewRootImpl 340 } 341 ?: return null 342 return createActivityLaunchController(animatedDialog, cujType) 343 } 344 345 /** 346 * Create an [ActivityLaunchAnimator.Controller] that can be used to launch an activity from 347 * [dialog]. Note that the dialog must have been shown using this animator, otherwise this 348 * method will return null. 349 * 350 * The returned controller will take care of dismissing the dialog at the right time after the 351 * activity started, when the dialog to app animation is done (or when it is cancelled). If this 352 * method returns null, then the dialog won't be dismissed. 353 * 354 * @param dialog the dialog to animate. 355 */ 356 @JvmOverloads 357 fun createActivityLaunchController( 358 dialog: Dialog, 359 cujType: Int? = null, 360 ): ActivityLaunchAnimator.Controller? { 361 val animatedDialog = openedDialogs.firstOrNull { it.dialog == dialog } ?: return null 362 return createActivityLaunchController(animatedDialog, cujType) 363 } 364 365 private fun createActivityLaunchController( 366 animatedDialog: AnimatedDialog, 367 cujType: Int? = null 368 ): ActivityLaunchAnimator.Controller? { 369 // At this point, we know that the intent of the caller is to dismiss the dialog to show 370 // an app, so we disable the exit animation into the source because we will never want to 371 // run it anyways. 372 animatedDialog.exitAnimationDisabled = true 373 374 val dialog = animatedDialog.dialog 375 376 // Don't animate if the dialog is not showing or if we are locked and going to show the 377 // primary bouncer. 378 if ( 379 !dialog.isShowing || 380 (!callback.isUnlocked() && !callback.isShowingAlternateAuthOnUnlock()) 381 ) { 382 return null 383 } 384 385 val dialogContentWithBackground = animatedDialog.dialogContentWithBackground ?: return null 386 val controller = 387 ActivityLaunchAnimator.Controller.fromView(dialogContentWithBackground, cujType) 388 ?: return null 389 390 // Wrap the controller into one that will instantly dismiss the dialog when the animation is 391 // done or dismiss it normally (fading it out) if the animation is cancelled. 392 return object : ActivityLaunchAnimator.Controller by controller { 393 override val isDialogLaunch = true 394 395 override fun onIntentStarted(willAnimate: Boolean) { 396 controller.onIntentStarted(willAnimate) 397 398 if (!willAnimate) { 399 dialog.dismiss() 400 } 401 } 402 403 override fun onLaunchAnimationCancelled(newKeyguardOccludedState: Boolean?) { 404 controller.onLaunchAnimationCancelled() 405 enableDialogDismiss() 406 dialog.dismiss() 407 } 408 409 override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { 410 controller.onLaunchAnimationStart(isExpandingFullyAbove) 411 412 // Make sure the dialog is not dismissed during the animation. 413 disableDialogDismiss() 414 415 // If this dialog was shown from a cascade of other dialogs, make sure those ones 416 // are dismissed too. 417 animatedDialog.prepareForStackDismiss() 418 419 // Remove the dim. 420 dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) 421 } 422 423 override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { 424 controller.onLaunchAnimationEnd(isExpandingFullyAbove) 425 426 // Hide the dialog then dismiss it to instantly dismiss it without playing the 427 // animation. 428 dialog.hide() 429 enableDialogDismiss() 430 dialog.dismiss() 431 } 432 433 private fun disableDialogDismiss() { 434 dialog.setDismissOverride { /* Do nothing */} 435 } 436 437 private fun enableDialogDismiss() { 438 // We don't set the override to null given that [AnimatedDialog.OnDialogDismissed] 439 // will still properly dismiss the dialog but will also make sure to clean up 440 // everything (like making sure that the touched view that triggered the dialog is 441 // made VISIBLE again). 442 dialog.setDismissOverride(animatedDialog::onDialogDismissed) 443 } 444 } 445 } 446 447 /** 448 * Ensure that all dialogs currently shown won't animate into their source when dismissed. 449 * 450 * This is a temporary API meant to be called right before we both dismiss a dialog and start an 451 * activity, which currently does not look good if we animate the dialog into their source at 452 * the same time as the activity starts. 453 * 454 * TODO(b/193634619): Remove this function and animate dialog into opening activity instead. 455 */ 456 fun disableAllCurrentDialogsExitAnimations() { 457 openedDialogs.forEach { it.exitAnimationDisabled = true } 458 } 459 460 /** 461 * Dismiss [dialog]. If it was launched from another dialog using this animator, also dismiss 462 * the stack of dialogs and simply fade out [dialog]. 463 */ 464 fun dismissStack(dialog: Dialog) { 465 openedDialogs.firstOrNull { it.dialog == dialog }?.prepareForStackDismiss() 466 dialog.dismiss() 467 } 468 469 interface Callback { 470 /** Whether the device is currently in dreaming (screensaver) mode. */ 471 fun isDreaming(): Boolean 472 473 /** 474 * Whether the device is currently unlocked, i.e. if it is *not* on the keyguard or if the 475 * keyguard can be dismissed. 476 */ 477 fun isUnlocked(): Boolean 478 479 /** 480 * Whether we are going to show alternate authentication (like UDFPS) instead of the 481 * traditional bouncer when unlocking the device. 482 */ 483 fun isShowingAlternateAuthOnUnlock(): Boolean 484 } 485 } 486 487 /** 488 * The CUJ interaction associated with opening the dialog. 489 * 490 * The optional tag indicates the specific dialog being opened. 491 */ 492 data class DialogCuj(@CujType val cujType: Int, val tag: String? = null) 493 494 private class AnimatedDialog( 495 private val launchAnimator: LaunchAnimator, 496 private val callback: DialogLaunchAnimator.Callback, 497 private val interactionJankMonitor: InteractionJankMonitor, 498 499 /** 500 * The controller of the source that triggered the dialog and that will animate into/from the 501 * dialog. 502 */ 503 val controller: DialogLaunchAnimator.Controller, 504 505 /** 506 * A callback that will be called with this [AnimatedDialog] after the dialog was dismissed and 507 * the exit animation is done. 508 */ 509 private val onDialogDismissed: (AnimatedDialog) -> Unit, 510 511 /** The dialog to show and animate. */ 512 val dialog: Dialog, 513 514 /** Whether we should animate the dialog background when its bounds change. */ 515 animateBackgroundBoundsChange: Boolean, 516 517 /** Launch animation corresponding to the parent [AnimatedDialog]. */ 518 private val parentAnimatedDialog: AnimatedDialog? = null, 519 520 /** 521 * Whether synchronization should be disabled, which can be useful if we are running in a test. 522 */ 523 private val forceDisableSynchronization: Boolean, 524 private val featureFlags: AnimationFeatureFlags, 525 ) { 526 /** 527 * The DecorView of this dialog window. 528 * 529 * Note that we access this DecorView lazily to avoid accessing it before the dialog is created, 530 * which can sometimes cause crashes (e.g. with the Cast dialog). 531 */ 532 private val decorView by lazy { dialog.window!!.decorView as ViewGroup } 533 534 /** 535 * The dialog content with its background. When animating a fullscreen dialog, this is just the 536 * first ViewGroup of the dialog that has a background. When animating a normal (not fullscreen) 537 * dialog, this is an additional view that serves as a fake window that will have the same size 538 * as the dialog window initially had and to which we will set the dialog window background. 539 */ 540 var dialogContentWithBackground: ViewGroup? = null 541 542 /** The background color of [dialog], taking into consideration its window background color. */ 543 private var originalDialogBackgroundColor = Color.BLACK 544 545 /** 546 * Whether we are currently launching/showing the dialog by animating it from its source 547 * controlled by [controller]. 548 */ 549 private var isLaunching = true 550 551 /** Whether we are currently dismissing/hiding the dialog by animating into its source. */ 552 private var isDismissing = false 553 554 private var dismissRequested = false 555 var exitAnimationDisabled = false 556 557 private var isSourceDrawnInDialog = false 558 private var isOriginalDialogViewLaidOut = false 559 560 /** A layout listener to animate the dialog height change. */ 561 private val backgroundLayoutListener = 562 if (animateBackgroundBoundsChange) { 563 AnimatedBoundsLayoutListener() 564 } else { 565 null 566 } 567 568 /* 569 * A layout listener in case the dialog (window) size changes (for instance because of a 570 * configuration change) to ensure that the dialog stays full width. 571 */ 572 private var decorViewLayoutListener: View.OnLayoutChangeListener? = null 573 574 private var hasInstrumentedJank = false 575 576 fun start() { 577 val cuj = controller.cuj 578 if (cuj != null) { 579 val config = controller.jankConfigurationBuilder() 580 if (config != null) { 581 if (cuj.tag != null) { 582 config.setTag(cuj.tag) 583 } 584 585 interactionJankMonitor.begin(config) 586 hasInstrumentedJank = true 587 } 588 } 589 590 // Create the dialog so that its onCreate() method is called, which usually sets the dialog 591 // content. 592 dialog.create() 593 594 val window = dialog.window!! 595 val isWindowFullScreen = 596 window.attributes.width == MATCH_PARENT && window.attributes.height == MATCH_PARENT 597 val dialogContentWithBackground = 598 if (isWindowFullScreen) { 599 // If the dialog window is already fullscreen, then we look for the first ViewGroup 600 // that has a background (and is not the DecorView, which always has a background) 601 // and animate towards that ViewGroup given that this is probably what represents 602 // the actual dialog view. 603 var viewGroupWithBackground: ViewGroup? = null 604 for (i in 0 until decorView.childCount) { 605 viewGroupWithBackground = 606 findFirstViewGroupWithBackground(decorView.getChildAt(i)) 607 if (viewGroupWithBackground != null) { 608 break 609 } 610 } 611 612 // Animate that view with the background. Throw if we didn't find one, because 613 // otherwise it's not clear what we should animate. 614 if (viewGroupWithBackground == null) { 615 error("Unable to find ViewGroup with background") 616 } 617 618 if (viewGroupWithBackground !is LaunchableView) { 619 error("The animated ViewGroup with background must implement LaunchableView") 620 } 621 622 viewGroupWithBackground 623 } else { 624 val (dialogContentWithBackground, decorViewLayoutListener) = 625 dialog.maybeForceFullscreen()!! 626 this.decorViewLayoutListener = decorViewLayoutListener 627 dialogContentWithBackground 628 } 629 630 this.dialogContentWithBackground = dialogContentWithBackground 631 dialogContentWithBackground.setTag(R.id.tag_dialog_background, true) 632 633 val background = dialogContentWithBackground.background 634 originalDialogBackgroundColor = 635 GhostedViewLaunchAnimatorController.findGradientDrawable(background) 636 ?.color 637 ?.defaultColor 638 ?: Color.BLACK 639 640 // Make the background view invisible until we start the animation. We use the transition 641 // visibility like GhostView does so that we don't mess up with the accessibility tree (see 642 // b/204944038#comment17). Given that this background implements LaunchableView, we call 643 // setShouldBlockVisibilityChanges() early so that the current visibility (VISIBLE) is 644 // restored at the end of the animation. 645 dialogContentWithBackground.setShouldBlockVisibilityChanges(true) 646 dialogContentWithBackground.setTransitionVisibility(View.INVISIBLE) 647 648 // Make sure the dialog is visible instantly and does not do any window animation. 649 val attributes = window.attributes 650 attributes.windowAnimations = R.style.Animation_LaunchAnimation 651 652 // Ensure that the animation is not clipped by the display cut-out when animating this 653 // dialog into an app. 654 attributes.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS 655 656 // Ensure that the animation is not clipped by the navigation/task bars when animating this 657 // dialog into an app. 658 val wasFittingNavigationBars = 659 attributes.fitInsetsTypes and WindowInsets.Type.navigationBars() != 0 660 attributes.fitInsetsTypes = 661 attributes.fitInsetsTypes and WindowInsets.Type.navigationBars().inv() 662 663 window.attributes = window.attributes 664 665 // We apply the insets ourselves to make sure that the paddings are set on the correct 666 // View. 667 window.setDecorFitsSystemWindows(false) 668 val viewWithInsets = (dialogContentWithBackground.parent as ViewGroup) 669 viewWithInsets.setOnApplyWindowInsetsListener { view, windowInsets -> 670 val type = 671 if (wasFittingNavigationBars) { 672 WindowInsets.Type.displayCutout() or WindowInsets.Type.navigationBars() 673 } else { 674 WindowInsets.Type.displayCutout() 675 } 676 677 val insets = windowInsets.getInsets(type) 678 view.setPadding(insets.left, insets.top, insets.right, insets.bottom) 679 WindowInsets.CONSUMED 680 } 681 682 // Start the animation once the background view is properly laid out. 683 dialogContentWithBackground.addOnLayoutChangeListener( 684 object : View.OnLayoutChangeListener { 685 override fun onLayoutChange( 686 v: View, 687 left: Int, 688 top: Int, 689 right: Int, 690 bottom: Int, 691 oldLeft: Int, 692 oldTop: Int, 693 oldRight: Int, 694 oldBottom: Int 695 ) { 696 dialogContentWithBackground.removeOnLayoutChangeListener(this) 697 698 isOriginalDialogViewLaidOut = true 699 maybeStartLaunchAnimation() 700 } 701 } 702 ) 703 704 // Disable the dim. We will enable it once we start the animation. 705 window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) 706 707 // Override the dialog dismiss() so that we can animate the exit before actually dismissing 708 // the dialog. 709 dialog.setDismissOverride(this::onDialogDismissed) 710 711 if (featureFlags.isPredictiveBackQsDialogAnim) { 712 // TODO(b/265923095) Improve animations for QS dialogs on configuration change 713 dialog.registerAnimationOnBackInvoked(targetView = dialogContentWithBackground) 714 } 715 716 // Show the dialog. 717 dialog.show() 718 moveSourceDrawingToDialog() 719 } 720 721 private fun moveSourceDrawingToDialog() { 722 if (decorView.viewRootImpl == null) { 723 // Make sure that we have access to the dialog view root to move the drawing to the 724 // dialog overlay. 725 decorView.post(::moveSourceDrawingToDialog) 726 return 727 } 728 729 // Move the drawing of the source in the overlay of this dialog, then animate. We trigger a 730 // one-off synchronization to make sure that this is done in sync between the two different 731 // windows. 732 controller.startDrawingInOverlayOf(decorView) 733 synchronizeNextDraw( 734 then = { 735 isSourceDrawnInDialog = true 736 maybeStartLaunchAnimation() 737 } 738 ) 739 } 740 741 /** 742 * Synchronize the next draw of the source and dialog view roots so that they are performed at 743 * the same time, in the same transaction. This is necessary to make sure that the source is 744 * drawn in the overlay at the same time as it is removed from its original position (or 745 * inversely, removed from the overlay when the source is moved back to its original position). 746 */ 747 private fun synchronizeNextDraw(then: () -> Unit) { 748 val controllerRootView = controller.viewRoot?.view 749 if (forceDisableSynchronization || controllerRootView == null) { 750 // Don't synchronize when inside an automated test or if the controller root view is 751 // detached. 752 then() 753 return 754 } 755 756 ViewRootSync.synchronizeNextDraw(controllerRootView, decorView, then) 757 decorView.invalidate() 758 controllerRootView.invalidate() 759 } 760 761 private fun findFirstViewGroupWithBackground(view: View): ViewGroup? { 762 if (view !is ViewGroup) { 763 return null 764 } 765 766 if (view.background != null) { 767 return view 768 } 769 770 for (i in 0 until view.childCount) { 771 val match = findFirstViewGroupWithBackground(view.getChildAt(i)) 772 if (match != null) { 773 return match 774 } 775 } 776 777 return null 778 } 779 780 private fun maybeStartLaunchAnimation() { 781 if (!isSourceDrawnInDialog || !isOriginalDialogViewLaidOut) { 782 return 783 } 784 785 // Show the background dim. 786 dialog.window?.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) 787 788 startAnimation( 789 isLaunching = true, 790 onLaunchAnimationEnd = { 791 isLaunching = false 792 793 // dismiss was called during the animation, dismiss again now to actually dismiss. 794 if (dismissRequested) { 795 dialog.dismiss() 796 } 797 798 // If necessary, we animate the dialog background when its bounds change. We do it 799 // at the end of the launch animation, because the lauch animation already correctly 800 // handles bounds changes. 801 if (backgroundLayoutListener != null) { 802 dialogContentWithBackground!!.addOnLayoutChangeListener( 803 backgroundLayoutListener 804 ) 805 } 806 807 if (hasInstrumentedJank) { 808 interactionJankMonitor.end(controller.cuj!!.cujType) 809 } 810 } 811 ) 812 } 813 814 fun onDialogDismissed() { 815 if (Looper.myLooper() != Looper.getMainLooper()) { 816 dialog.context.mainExecutor.execute { onDialogDismissed() } 817 return 818 } 819 820 // TODO(b/193634619): Support interrupting the launch animation in the middle. 821 if (isLaunching) { 822 dismissRequested = true 823 return 824 } 825 826 if (isDismissing) { 827 return 828 } 829 830 isDismissing = true 831 hideDialogIntoView { animationRan: Boolean -> 832 if (animationRan) { 833 // Instantly dismiss the dialog if we ran the animation into view. If it was 834 // skipped, dismiss() will run the window animation (which fades out the dialog). 835 dialog.hide() 836 } 837 838 dialog.setDismissOverride(null) 839 dialog.dismiss() 840 } 841 } 842 843 /** 844 * Hide the dialog into the source and call [onAnimationFinished] when the animation is done 845 * (passing animationRan=true) or if it's skipped (passing animationRan=false) to actually 846 * dismiss the dialog. 847 */ 848 private fun hideDialogIntoView(onAnimationFinished: (Boolean) -> Unit) { 849 // Remove the layout change listener we have added to the DecorView earlier. 850 if (decorViewLayoutListener != null) { 851 decorView.removeOnLayoutChangeListener(decorViewLayoutListener) 852 } 853 854 if (!shouldAnimateDialogIntoSource()) { 855 Log.i(TAG, "Skipping animation of dialog into the source") 856 controller.onExitAnimationCancelled() 857 onAnimationFinished(false /* instantDismiss */) 858 onDialogDismissed(this@AnimatedDialog) 859 return 860 } 861 862 startAnimation( 863 isLaunching = false, 864 onLaunchAnimationStart = { 865 // Remove the dim background as soon as we start the animation. 866 dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) 867 }, 868 onLaunchAnimationEnd = { 869 val dialogContentWithBackground = this.dialogContentWithBackground!! 870 dialogContentWithBackground.visibility = View.INVISIBLE 871 872 if (backgroundLayoutListener != null) { 873 dialogContentWithBackground.removeOnLayoutChangeListener( 874 backgroundLayoutListener 875 ) 876 } 877 878 controller.stopDrawingInOverlay() 879 synchronizeNextDraw { 880 onAnimationFinished(true /* instantDismiss */) 881 onDialogDismissed(this@AnimatedDialog) 882 } 883 } 884 ) 885 } 886 887 private fun startAnimation( 888 isLaunching: Boolean, 889 onLaunchAnimationStart: () -> Unit = {}, 890 onLaunchAnimationEnd: () -> Unit = {} 891 ) { 892 // Create 2 controllers to animate both the dialog and the source. 893 val startController = 894 if (isLaunching) { 895 controller.createLaunchController() 896 } else { 897 GhostedViewLaunchAnimatorController(dialogContentWithBackground!!) 898 } 899 val endController = 900 if (isLaunching) { 901 GhostedViewLaunchAnimatorController(dialogContentWithBackground!!) 902 } else { 903 controller.createExitController() 904 } 905 startController.launchContainer = decorView 906 endController.launchContainer = decorView 907 908 val endState = endController.createAnimatorState() 909 val controller = 910 object : LaunchAnimator.Controller { 911 override var launchContainer: ViewGroup 912 get() = startController.launchContainer 913 set(value) { 914 startController.launchContainer = value 915 endController.launchContainer = value 916 } 917 918 override fun createAnimatorState(): LaunchAnimator.State { 919 return startController.createAnimatorState() 920 } 921 922 override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { 923 // During launch, onLaunchAnimationStart will be used to remove the temporary 924 // touch surface ghost so it is important to call this before calling 925 // onLaunchAnimationStart on the controller (which will create its own ghost). 926 onLaunchAnimationStart() 927 928 startController.onLaunchAnimationStart(isExpandingFullyAbove) 929 endController.onLaunchAnimationStart(isExpandingFullyAbove) 930 } 931 932 override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { 933 // onLaunchAnimationEnd is called by an Animator at the end of the animation, 934 // on a Choreographer animation tick. The following calls will move the animated 935 // content from the dialog overlay back to its original position, and this 936 // change must be reflected in the next frame given that we then sync the next 937 // frame of both the content and dialog ViewRoots. However, in case that content 938 // is rendered by Compose, whose compositions are also scheduled on a 939 // Choreographer frame, any state change made *right now* won't be reflected in 940 // the next frame given that a Choreographer frame can't schedule another and 941 // have it happen in the same frame. So we post the forwarded calls to 942 // [Controller.onLaunchAnimationEnd], leaving this Choreographer frame, ensuring 943 // that the move of the content back to its original window will be reflected in 944 // the next frame right after [onLaunchAnimationEnd] is called. 945 dialog.context.mainExecutor.execute { 946 startController.onLaunchAnimationEnd(isExpandingFullyAbove) 947 endController.onLaunchAnimationEnd(isExpandingFullyAbove) 948 949 onLaunchAnimationEnd() 950 } 951 } 952 953 override fun onLaunchAnimationProgress( 954 state: LaunchAnimator.State, 955 progress: Float, 956 linearProgress: Float 957 ) { 958 startController.onLaunchAnimationProgress(state, progress, linearProgress) 959 960 // The end view is visible only iff the starting view is not visible. 961 state.visible = !state.visible 962 endController.onLaunchAnimationProgress(state, progress, linearProgress) 963 964 // If the dialog content is complex, its dimension might change during the 965 // launch animation. The animation end position might also change during the 966 // exit animation, for instance when locking the phone when the dialog is open. 967 // Therefore we update the end state to the new position/size. Usually the 968 // dialog dimension or position will change in the early frames, so changing the 969 // end state shouldn't really be noticeable. 970 if (endController is GhostedViewLaunchAnimatorController) { 971 endController.fillGhostedViewState(endState) 972 } 973 } 974 } 975 976 launchAnimator.startAnimation(controller, endState, originalDialogBackgroundColor) 977 } 978 979 private fun shouldAnimateDialogIntoSource(): Boolean { 980 // Don't animate if the dialog was previously hidden using hide() or if we disabled the exit 981 // animation. 982 if (exitAnimationDisabled || !dialog.isShowing) { 983 return false 984 } 985 986 // If we are dreaming, the dialog was probably closed because of that so we don't animate 987 // into the source. 988 if (callback.isDreaming()) { 989 return false 990 } 991 992 return controller.shouldAnimateExit() 993 } 994 995 /** A layout listener to animate the change of bounds of the dialog background. */ 996 class AnimatedBoundsLayoutListener : View.OnLayoutChangeListener { 997 companion object { 998 private const val ANIMATION_DURATION = 500L 999 } 1000 1001 private var lastBounds: Rect? = null 1002 private var currentAnimator: ValueAnimator? = null 1003 1004 override fun onLayoutChange( 1005 view: View, 1006 left: Int, 1007 top: Int, 1008 right: Int, 1009 bottom: Int, 1010 oldLeft: Int, 1011 oldTop: Int, 1012 oldRight: Int, 1013 oldBottom: Int 1014 ) { 1015 // Don't animate if bounds didn't actually change. 1016 if (left == oldLeft && top == oldTop && right == oldRight && bottom == oldBottom) { 1017 // Make sure that we that the last bounds set by the animator were not overridden. 1018 lastBounds?.let { bounds -> 1019 view.left = bounds.left 1020 view.top = bounds.top 1021 view.right = bounds.right 1022 view.bottom = bounds.bottom 1023 } 1024 return 1025 } 1026 1027 if (lastBounds == null) { 1028 lastBounds = Rect(oldLeft, oldTop, oldRight, oldBottom) 1029 } 1030 1031 val bounds = lastBounds!! 1032 val startLeft = bounds.left 1033 val startTop = bounds.top 1034 val startRight = bounds.right 1035 val startBottom = bounds.bottom 1036 1037 currentAnimator?.cancel() 1038 currentAnimator = null 1039 1040 val animator = 1041 ValueAnimator.ofFloat(0f, 1f).apply { 1042 duration = ANIMATION_DURATION 1043 interpolator = Interpolators.STANDARD 1044 1045 addListener( 1046 object : AnimatorListenerAdapter() { 1047 override fun onAnimationEnd(animation: Animator) { 1048 currentAnimator = null 1049 } 1050 } 1051 ) 1052 1053 addUpdateListener { animatedValue -> 1054 val progress = animatedValue.animatedFraction 1055 1056 // Compute new bounds. 1057 bounds.left = MathUtils.lerp(startLeft, left, progress).roundToInt() 1058 bounds.top = MathUtils.lerp(startTop, top, progress).roundToInt() 1059 bounds.right = MathUtils.lerp(startRight, right, progress).roundToInt() 1060 bounds.bottom = MathUtils.lerp(startBottom, bottom, progress).roundToInt() 1061 1062 // Set the new bounds. 1063 view.left = bounds.left 1064 view.top = bounds.top 1065 view.right = bounds.right 1066 view.bottom = bounds.bottom 1067 } 1068 } 1069 1070 currentAnimator = animator 1071 animator.start() 1072 } 1073 } 1074 1075 fun prepareForStackDismiss() { 1076 if (parentAnimatedDialog == null) { 1077 return 1078 } 1079 parentAnimatedDialog.exitAnimationDisabled = true 1080 parentAnimatedDialog.dialog.hide() 1081 parentAnimatedDialog.prepareForStackDismiss() 1082 parentAnimatedDialog.dialog.dismiss() 1083 } 1084 } 1085