Files
Lawnchair/systemUIAnim/src/com/android/systemui/animation/DialogTransitionAnimator.kt
T
2024-11-02 10:55:28 +08:00

1101 lines
44 KiB
Kotlin

/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.animation
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.app.Dialog
import android.graphics.Color
import android.graphics.Rect
import android.os.Looper
import android.util.Log
import android.util.MathUtils
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewRootImpl
import android.view.WindowInsets
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
import com.android.app.animation.Interpolators
import com.android.internal.jank.Cuj.CujType
import com.android.internal.jank.InteractionJankMonitor
import com.android.systemui.util.maybeForceFullscreen
import com.android.systemui.util.registerAnimationOnBackInvoked
import java.util.concurrent.Executor
import kotlin.math.roundToInt
private const val TAG = "DialogTransitionAnimator"
/**
* A class that allows dialogs to be started in a seamless way from a view that is transforming
* nicely into the starting dialog.
*
* This animator also allows to easily animate a dialog into an activity.
*
* @see show
* @see showFromView
* @see showFromDialog
* @see createActivityTransitionController
*/
class DialogTransitionAnimator
@JvmOverloads
constructor(
private val mainExecutor: Executor,
private val callback: Callback,
private val interactionJankMonitor: InteractionJankMonitor,
private val featureFlags: AnimationFeatureFlags,
private val transitionAnimator: TransitionAnimator =
TransitionAnimator(
mainExecutor,
TIMINGS,
INTERPOLATORS,
),
private val isForTesting: Boolean = false,
) {
private companion object {
private val TIMINGS = ActivityTransitionAnimator.TIMINGS
// We use the same interpolator for X and Y axis to make sure the dialog does not move out
// of the screen bounds during the animation.
private val INTERPOLATORS =
ActivityTransitionAnimator.INTERPOLATORS.copy(
positionXInterpolator =
ActivityTransitionAnimator.INTERPOLATORS.positionInterpolator
)
}
/**
* A controller that takes care of applying the dialog launch and exit animations to the source
* that triggered the animation.
*/
interface Controller {
/** The [ViewRootImpl] of this controller. */
val viewRoot: ViewRootImpl?
/**
* The identity object of the source animated by this controller. This animator will ensure
* that 2 animations with the same source identity are not going to run at the same time, to
* avoid flickers when a dialog is shown from the same source more or less at the same time
* (for instance if the user clicks an expandable button twice).
*/
val sourceIdentity: Any
/** The CUJ associated to this controller. */
val cuj: DialogCuj?
/**
* Move the drawing of the source in the overlay of [viewGroup].
*
* Once this method is called, and until [stopDrawingInOverlay] is called, the source
* controlled by this Controller should be drawn in the overlay of [viewGroup] so that it is
* drawn above all other elements in the same [viewRoot].
*/
fun startDrawingInOverlayOf(viewGroup: ViewGroup)
/**
* Move the drawing of the source back in its original location.
*
* @see startDrawingInOverlayOf
*/
fun stopDrawingInOverlay()
/**
* Create the [TransitionAnimator.Controller] that will be called to animate the source
* controlled by this [Controller] during the dialog launch animation.
*
* At the end of this animation, the source should *not* be visible anymore (until the
* dialog is closed and is animated back into the source).
*/
fun createTransitionController(): TransitionAnimator.Controller
/**
* Create the [TransitionAnimator.Controller] that will be called to animate the source
* controlled by this [Controller] during the dialog exit animation.
*
* At the end of this animation, the source should be visible again.
*/
fun createExitController(): TransitionAnimator.Controller
/**
* Whether we should animate the dialog back into the source when it is dismissed. If this
* methods returns `false`, then the dialog will simply fade out and
* [onExitAnimationCancelled] will be called.
*
* Note that even when this returns `true`, the exit animation might still be cancelled (in
* which case [onExitAnimationCancelled] will also be called).
*/
fun shouldAnimateExit(): Boolean
/**
* Called if we decided to *not* animate the dialog into the source for some reason. This
* means that [createExitController] will *not* be called and this implementation should
* make sure that the source is back in its original state, before it was animated into the
* dialog. In particular, the source should be visible again.
*/
fun onExitAnimationCancelled()
/**
* Return the [InteractionJankMonitor.Configuration.Builder] to be used for animations
* controlled by this controller.
*/
// TODO(b/252723237): Make this non-nullable
fun jankConfigurationBuilder(): InteractionJankMonitor.Configuration.Builder?
companion object {
/**
* Create a [Controller] that can animate [source] to and from a dialog.
*
* Important: The view must be attached to a [ViewGroup] when calling this function and
* during the animation. For safety, this method will return null when it is not. The
* view must also implement [LaunchableView], otherwise this method will throw.
*
* Note: The background of [view] should be a (rounded) rectangle so that it can be
* properly animated.
*/
fun fromView(source: View, cuj: DialogCuj? = null): Controller? {
// Make sure the View we launch from implements LaunchableView to avoid visibility
// issues.
if (source !is LaunchableView) {
throw IllegalArgumentException(
"A DialogTransitionAnimator.Controller was created from a View that does " +
"not implement LaunchableView. This can lead to subtle bugs where " +
"the visibility of the View we are launching from is not what we " +
"expected."
)
}
if (source.parent !is ViewGroup) {
Log.e(
TAG,
"Skipping animation as view $source is not attached to a ViewGroup",
Exception(),
)
return null
}
return ViewDialogTransitionAnimatorController(source, cuj)
}
}
}
/**
* The set of dialogs that were animated using this animator and that are still opened (not
* dismissed, but can be hidden).
*/
// TODO(b/201264644): Remove this set.
private val openedDialogs = hashSetOf<AnimatedDialog>()
/**
* Show [dialog] by expanding it from [view]. If [view] is a view inside another dialog that was
* shown using this method, then we will animate from that dialog instead.
*
* If [animateBackgroundBoundsChange] is true, then the background of the dialog will be
* animated when the dialog bounds change.
*
* Note: The background of [view] should be a (rounded) rectangle so that it can be properly
* animated.
*
* Caveats: When calling this function and [dialog] is not a fullscreen dialog, then it will be
* made fullscreen and 2 views will be inserted between the dialog DecorView and its children.
*/
@JvmOverloads
fun showFromView(
dialog: Dialog,
view: View,
cuj: DialogCuj? = null,
animateBackgroundBoundsChange: Boolean = false
) {
val controller = Controller.fromView(view, cuj)
if (controller == null) {
dialog.show()
} else {
show(dialog, controller, animateBackgroundBoundsChange)
}
}
/**
* Show [dialog] by expanding it from a source controlled by [controller].
*
* If [animateBackgroundBoundsChange] is true, then the background of the dialog will be
* animated when the dialog bounds change.
*
* Note: The background of [view] should be a (rounded) rectangle so that it can be properly
* animated.
*
* Caveats: When calling this function and [dialog] is not a fullscreen dialog, then it will be
* made fullscreen and 2 views will be inserted between the dialog DecorView and its children.
*/
@JvmOverloads
fun show(
dialog: Dialog,
controller: Controller,
animateBackgroundBoundsChange: Boolean = false
) {
if (Looper.myLooper() != Looper.getMainLooper()) {
throw IllegalStateException(
"showFromView must be called from the main thread and dialog must be created in " +
"the main thread"
)
}
// If the view we are launching from belongs to another dialog, then this means the caller
// intent is to launch a dialog from another dialog.
val animatedParent =
openedDialogs.firstOrNull {
it.dialog.window?.decorView?.viewRootImpl == controller.viewRoot
}
val controller =
animatedParent?.dialogContentWithBackground?.let {
Controller.fromView(it, controller.cuj)
}
?: controller
// Make sure we don't run the launch animation from the same source twice at the same time.
if (openedDialogs.any { it.controller.sourceIdentity == controller.sourceIdentity }) {
Log.e(
TAG,
"Not running dialog launch animation from source as it is already expanded into a" +
" dialog"
)
dialog.show()
return
}
val animatedDialog =
AnimatedDialog(
transitionAnimator = transitionAnimator,
callback = callback,
interactionJankMonitor = interactionJankMonitor,
controller = controller,
onDialogDismissed = { openedDialogs.remove(it) },
dialog = dialog,
animateBackgroundBoundsChange = animateBackgroundBoundsChange,
parentAnimatedDialog = animatedParent,
forceDisableSynchronization = isForTesting,
featureFlags = featureFlags,
)
openedDialogs.add(animatedDialog)
animatedDialog.start()
}
/**
* Launch [dialog] from [another dialog][animateFrom] that was shown using [show]. This will
* allow for dismissing the whole stack.
*
* @see dismissStack
*/
fun showFromDialog(
dialog: Dialog,
animateFrom: Dialog,
cuj: DialogCuj? = null,
animateBackgroundBoundsChange: Boolean = false
) {
val view =
openedDialogs.firstOrNull { it.dialog == animateFrom }?.dialogContentWithBackground
if (view == null) {
Log.w(
TAG,
"Showing dialog $dialog normally as the dialog it is shown from was not shown " +
"using DialogTransitionAnimator"
)
dialog.show()
return
}
showFromView(
dialog,
view,
animateBackgroundBoundsChange = animateBackgroundBoundsChange,
cuj = cuj
)
}
/**
* Create an [ActivityTransitionAnimator.Controller] that can be used to launch an activity from
* the dialog that contains [View]. Note that the dialog must have been shown using this
* animator, otherwise this method will return null.
*
* The returned controller will take care of dismissing the dialog at the right time after the
* activity started, when the dialog to app animation is done (or when it is cancelled). If this
* method returns null, then the dialog won't be dismissed.
*
* @param view any view inside the dialog to animate.
*/
@JvmOverloads
fun createActivityTransitionController(
view: View,
cujType: Int? = null,
): ActivityTransitionAnimator.Controller? {
val animatedDialog =
openedDialogs.firstOrNull {
it.dialog.window?.decorView?.viewRootImpl == view.viewRootImpl
}
?: return null
return createActivityTransitionController(animatedDialog, cujType)
}
/**
* Create an [ActivityTransitionAnimator.Controller] that can be used to launch an activity from
* [dialog]. Note that the dialog must have been shown using this animator, otherwise this
* method will return null.
*
* The returned controller will take care of dismissing the dialog at the right time after the
* activity started, when the dialog to app animation is done (or when it is cancelled). If this
* method returns null, then the dialog won't be dismissed.
*
* @param dialog the dialog to animate.
*/
@JvmOverloads
fun createActivityTransitionController(
dialog: Dialog,
cujType: Int? = null,
): ActivityTransitionAnimator.Controller? {
val animatedDialog = openedDialogs.firstOrNull { it.dialog == dialog } ?: return null
return createActivityTransitionController(animatedDialog, cujType)
}
private fun createActivityTransitionController(
animatedDialog: AnimatedDialog,
cujType: Int? = null
): ActivityTransitionAnimator.Controller? {
// At this point, we know that the intent of the caller is to dismiss the dialog to show
// an app, so we disable the exit animation into the source because we will never want to
// run it anyways.
animatedDialog.exitAnimationDisabled = true
val dialog = animatedDialog.dialog
// Don't animate if the dialog is not showing or if we are locked and going to show the
// primary bouncer.
if (
!dialog.isShowing ||
(!callback.isUnlocked() && !callback.isShowingAlternateAuthOnUnlock())
) {
return null
}
val dialogContentWithBackground = animatedDialog.dialogContentWithBackground ?: return null
val controller =
ActivityTransitionAnimator.Controller.fromView(dialogContentWithBackground, cujType)
?: return null
// Wrap the controller into one that will instantly dismiss the dialog when the animation is
// done or dismiss it normally (fading it out) if the animation is cancelled.
return object : ActivityTransitionAnimator.Controller by controller {
override val isDialogLaunch = true
override fun onIntentStarted(willAnimate: Boolean) {
controller.onIntentStarted(willAnimate)
if (!willAnimate) {
dialog.dismiss()
}
}
override fun onTransitionAnimationCancelled(newKeyguardOccludedState: Boolean?) {
controller.onTransitionAnimationCancelled()
enableDialogDismiss()
dialog.dismiss()
}
override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) {
controller.onTransitionAnimationStart(isExpandingFullyAbove)
// Make sure the dialog is not dismissed during the animation.
disableDialogDismiss()
// If this dialog was shown from a cascade of other dialogs, make sure those ones
// are dismissed too.
animatedDialog.prepareForStackDismiss()
// Remove the dim.
dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
}
override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) {
controller.onTransitionAnimationEnd(isExpandingFullyAbove)
// Hide the dialog then dismiss it to instantly dismiss it without playing the
// animation.
dialog.hide()
enableDialogDismiss()
dialog.dismiss()
}
private fun disableDialogDismiss() {
dialog.setDismissOverride { /* Do nothing */}
}
private fun enableDialogDismiss() {
// We don't set the override to null given that [AnimatedDialog.OnDialogDismissed]
// will still properly dismiss the dialog but will also make sure to clean up
// everything (like making sure that the touched view that triggered the dialog is
// made VISIBLE again).
dialog.setDismissOverride(animatedDialog::onDialogDismissed)
}
}
}
/**
* Ensure that all dialogs currently shown won't animate into their source when dismissed.
*
* This is a temporary API meant to be called right before we both dismiss a dialog and start an
* activity, which currently does not look good if we animate the dialog into their source at
* the same time as the activity starts.
*
* TODO(b/193634619): Remove this function and animate dialog into opening activity instead.
*/
fun disableAllCurrentDialogsExitAnimations() {
openedDialogs.forEach { it.exitAnimationDisabled = true }
}
/**
* Dismiss [dialog]. If it was launched from another dialog using this animator, also dismiss
* the stack of dialogs and simply fade out [dialog].
*/
fun dismissStack(dialog: Dialog) {
openedDialogs.firstOrNull { it.dialog == dialog }?.prepareForStackDismiss()
dialog.dismiss()
}
interface Callback {
/** Whether the device is currently in dreaming (screensaver) mode. */
fun isDreaming(): Boolean
/**
* Whether the device is currently unlocked, i.e. if it is *not* on the keyguard or if the
* keyguard can be dismissed.
*/
fun isUnlocked(): Boolean
/**
* Whether we are going to show alternate authentication (like UDFPS) instead of the
* traditional bouncer when unlocking the device.
*/
fun isShowingAlternateAuthOnUnlock(): Boolean
}
}
/**
* The CUJ interaction associated with opening the dialog.
*
* The optional tag indicates the specific dialog being opened.
*/
data class DialogCuj(@CujType val cujType: Int, val tag: String? = null)
private class AnimatedDialog(
private val transitionAnimator: TransitionAnimator,
private val callback: DialogTransitionAnimator.Callback,
private val interactionJankMonitor: InteractionJankMonitor,
/**
* The controller of the source that triggered the dialog and that will animate into/from the
* dialog.
*/
val controller: DialogTransitionAnimator.Controller,
/**
* A callback that will be called with this [AnimatedDialog] after the dialog was dismissed and
* the exit animation is done.
*/
private val onDialogDismissed: (AnimatedDialog) -> Unit,
/** The dialog to show and animate. */
val dialog: Dialog,
/** Whether we should animate the dialog background when its bounds change. */
animateBackgroundBoundsChange: Boolean,
/** Launch animation corresponding to the parent [AnimatedDialog]. */
private val parentAnimatedDialog: AnimatedDialog? = null,
/**
* Whether synchronization should be disabled, which can be useful if we are running in a test.
*/
private val forceDisableSynchronization: Boolean,
private val featureFlags: AnimationFeatureFlags,
) {
/**
* The DecorView of this dialog window.
*
* Note that we access this DecorView lazily to avoid accessing it before the dialog is created,
* which can sometimes cause crashes (e.g. with the Cast dialog).
*/
private val decorView by lazy { dialog.window!!.decorView as ViewGroup }
/**
* The dialog content with its background. When animating a fullscreen dialog, this is just the
* first ViewGroup of the dialog that has a background. When animating a normal (not fullscreen)
* dialog, this is an additional view that serves as a fake window that will have the same size
* as the dialog window initially had and to which we will set the dialog window background.
*/
var dialogContentWithBackground: ViewGroup? = null
/** The background color of [dialog], taking into consideration its window background color. */
private var originalDialogBackgroundColor = Color.BLACK
/**
* Whether we are currently launching/showing the dialog by animating it from its source
* controlled by [controller].
*/
private var isLaunching = true
/** Whether we are currently dismissing/hiding the dialog by animating into its source. */
private var isDismissing = false
private var dismissRequested = false
var exitAnimationDisabled = false
private var isSourceDrawnInDialog = false
private var isOriginalDialogViewLaidOut = false
/** A layout listener to animate the dialog height change. */
private val backgroundLayoutListener =
if (animateBackgroundBoundsChange) {
AnimatedBoundsLayoutListener()
} else {
null
}
/*
* A layout listener in case the dialog (window) size changes (for instance because of a
* configuration change) to ensure that the dialog stays full width.
*/
private var decorViewLayoutListener: View.OnLayoutChangeListener? = null
private var hasInstrumentedJank = false
fun start() {
val cuj = controller.cuj
if (cuj != null) {
val config = controller.jankConfigurationBuilder()
if (config != null) {
if (cuj.tag != null) {
config.setTag(cuj.tag)
}
interactionJankMonitor.begin(config)
hasInstrumentedJank = true
}
}
// Create the dialog so that its onCreate() method is called, which usually sets the dialog
// content.
dialog.create()
val window = dialog.window!!
val isWindowFullScreen =
window.attributes.width == MATCH_PARENT && window.attributes.height == MATCH_PARENT
val dialogContentWithBackground =
if (isWindowFullScreen) {
// If the dialog window is already fullscreen, then we look for the first ViewGroup
// that has a background (and is not the DecorView, which always has a background)
// and animate towards that ViewGroup given that this is probably what represents
// the actual dialog view.
var viewGroupWithBackground: ViewGroup? = null
for (i in 0 until decorView.childCount) {
viewGroupWithBackground =
findFirstViewGroupWithBackground(decorView.getChildAt(i))
if (viewGroupWithBackground != null) {
break
}
}
// Animate that view with the background. Throw if we didn't find one, because
// otherwise it's not clear what we should animate.
if (viewGroupWithBackground == null) {
error("Unable to find ViewGroup with background")
}
if (viewGroupWithBackground !is LaunchableView) {
error("The animated ViewGroup with background must implement LaunchableView")
}
viewGroupWithBackground
} else {
val (dialogContentWithBackground, decorViewLayoutListener) =
dialog.maybeForceFullscreen()!!
this.decorViewLayoutListener = decorViewLayoutListener
dialogContentWithBackground
}
this.dialogContentWithBackground = dialogContentWithBackground
dialogContentWithBackground.setTag(R.id.tag_dialog_background, true)
val background = dialogContentWithBackground.background
originalDialogBackgroundColor =
GhostedViewTransitionAnimatorController.findGradientDrawable(background)
?.color
?.defaultColor
?: Color.BLACK
// Make the background view invisible until we start the animation. We use the transition
// visibility like GhostView does so that we don't mess up with the accessibility tree (see
// b/204944038#comment17). Given that this background implements LaunchableView, we call
// setShouldBlockVisibilityChanges() early so that the current visibility (VISIBLE) is
// restored at the end of the animation.
dialogContentWithBackground.setShouldBlockVisibilityChanges(true)
dialogContentWithBackground.setTransitionVisibility(View.INVISIBLE)
// Make sure the dialog is visible instantly and does not do any window animation.
val attributes = window.attributes
attributes.windowAnimations = R.style.Animation_LaunchAnimation
// Ensure that the animation is not clipped by the display cut-out when animating this
// dialog into an app.
attributes.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
// Ensure that the animation is not clipped by the navigation/task bars when animating this
// dialog into an app.
val wasFittingNavigationBars =
attributes.fitInsetsTypes and WindowInsets.Type.navigationBars() != 0
attributes.fitInsetsTypes =
attributes.fitInsetsTypes and WindowInsets.Type.navigationBars().inv()
window.attributes = window.attributes
// We apply the insets ourselves to make sure that the paddings are set on the correct
// View.
window.setDecorFitsSystemWindows(false)
val viewWithInsets = (dialogContentWithBackground.parent as ViewGroup)
viewWithInsets.setOnApplyWindowInsetsListener { view, windowInsets ->
val type =
if (wasFittingNavigationBars) {
WindowInsets.Type.displayCutout() or WindowInsets.Type.navigationBars()
} else {
WindowInsets.Type.displayCutout()
}
val insets = windowInsets.getInsets(type)
view.setPadding(insets.left, insets.top, insets.right, insets.bottom)
WindowInsets.CONSUMED
}
// Start the animation once the background view is properly laid out.
dialogContentWithBackground.addOnLayoutChangeListener(
object : View.OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
dialogContentWithBackground.removeOnLayoutChangeListener(this)
isOriginalDialogViewLaidOut = true
maybeStartLaunchAnimation()
}
}
)
// Disable the dim. We will enable it once we start the animation.
window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
// Override the dialog dismiss() so that we can animate the exit before actually dismissing
// the dialog.
dialog.setDismissOverride(this::onDialogDismissed)
if (featureFlags.isPredictiveBackQsDialogAnim) {
dialog.registerAnimationOnBackInvoked(targetView = dialogContentWithBackground)
}
// Show the dialog.
dialog.show()
moveSourceDrawingToDialog()
}
private fun moveSourceDrawingToDialog() {
if (decorView.viewRootImpl == null) {
// Make sure that we have access to the dialog view root to move the drawing to the
// dialog overlay.
decorView.post(::moveSourceDrawingToDialog)
return
}
// Move the drawing of the source in the overlay of this dialog, then animate. We trigger a
// one-off synchronization to make sure that this is done in sync between the two different
// windows.
controller.startDrawingInOverlayOf(decorView)
synchronizeNextDraw(
then = {
isSourceDrawnInDialog = true
maybeStartLaunchAnimation()
}
)
}
/**
* Synchronize the next draw of the source and dialog view roots so that they are performed at
* the same time, in the same transaction. This is necessary to make sure that the source is
* drawn in the overlay at the same time as it is removed from its original position (or
* inversely, removed from the overlay when the source is moved back to its original position).
*/
private fun synchronizeNextDraw(then: () -> Unit) {
val controllerRootView = controller.viewRoot?.view
if (forceDisableSynchronization || controllerRootView == null) {
// Don't synchronize when inside an automated test or if the controller root view is
// detached.
then()
return
}
ViewRootSync.synchronizeNextDraw(controllerRootView, decorView, then)
decorView.invalidate()
controllerRootView.invalidate()
}
private fun findFirstViewGroupWithBackground(view: View): ViewGroup? {
if (view !is ViewGroup) {
return null
}
if (view.background != null) {
return view
}
for (i in 0 until view.childCount) {
val match = findFirstViewGroupWithBackground(view.getChildAt(i))
if (match != null) {
return match
}
}
return null
}
private fun maybeStartLaunchAnimation() {
if (!isSourceDrawnInDialog || !isOriginalDialogViewLaidOut) {
return
}
// Show the background dim.
dialog.window?.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
startAnimation(
isLaunching = true,
onLaunchAnimationEnd = {
isLaunching = false
// dismiss was called during the animation, dismiss again now to actually dismiss.
if (dismissRequested) {
dialog.dismiss()
}
// If necessary, we animate the dialog background when its bounds change. We do it
// at the end of the launch animation, because the lauch animation already correctly
// handles bounds changes.
if (backgroundLayoutListener != null) {
dialogContentWithBackground!!.addOnLayoutChangeListener(
backgroundLayoutListener
)
}
if (hasInstrumentedJank) {
interactionJankMonitor.end(controller.cuj!!.cujType)
}
}
)
}
fun onDialogDismissed() {
if (Looper.myLooper() != Looper.getMainLooper()) {
dialog.context.mainExecutor.execute { onDialogDismissed() }
return
}
// TODO(b/193634619): Support interrupting the launch animation in the middle.
if (isLaunching) {
dismissRequested = true
return
}
if (isDismissing) {
return
}
isDismissing = true
hideDialogIntoView { animationRan: Boolean ->
if (animationRan) {
// Instantly dismiss the dialog if we ran the animation into view. If it was
// skipped, dismiss() will run the window animation (which fades out the dialog).
dialog.hide()
}
dialog.setDismissOverride(null)
dialog.dismiss()
}
}
/**
* Hide the dialog into the source and call [onAnimationFinished] when the animation is done
* (passing animationRan=true) or if it's skipped (passing animationRan=false) to actually
* dismiss the dialog.
*/
private fun hideDialogIntoView(onAnimationFinished: (Boolean) -> Unit) {
// Remove the layout change listener we have added to the DecorView earlier.
if (decorViewLayoutListener != null) {
decorView.removeOnLayoutChangeListener(decorViewLayoutListener)
}
if (!shouldAnimateDialogIntoSource()) {
Log.i(TAG, "Skipping animation of dialog into the source")
controller.onExitAnimationCancelled()
onAnimationFinished(false /* instantDismiss */)
onDialogDismissed(this@AnimatedDialog)
return
}
startAnimation(
isLaunching = false,
onLaunchAnimationStart = {
// Remove the dim background as soon as we start the animation.
dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
},
onLaunchAnimationEnd = {
val dialogContentWithBackground = this.dialogContentWithBackground!!
dialogContentWithBackground.visibility = View.INVISIBLE
if (backgroundLayoutListener != null) {
dialogContentWithBackground.removeOnLayoutChangeListener(
backgroundLayoutListener
)
}
controller.stopDrawingInOverlay()
synchronizeNextDraw {
onAnimationFinished(true /* instantDismiss */)
onDialogDismissed(this@AnimatedDialog)
}
}
)
}
private fun startAnimation(
isLaunching: Boolean,
onLaunchAnimationStart: () -> Unit = {},
onLaunchAnimationEnd: () -> Unit = {}
) {
// Create 2 controllers to animate both the dialog and the source.
val startController =
if (isLaunching) {
controller.createTransitionController()
} else {
GhostedViewTransitionAnimatorController(dialogContentWithBackground!!)
}
val endController =
if (isLaunching) {
GhostedViewTransitionAnimatorController(dialogContentWithBackground!!)
} else {
controller.createExitController()
}
startController.transitionContainer = decorView
endController.transitionContainer = decorView
val endState = endController.createAnimatorState()
val controller =
object : TransitionAnimator.Controller {
override var transitionContainer: ViewGroup
get() = startController.transitionContainer
set(value) {
startController.transitionContainer = value
endController.transitionContainer = value
}
// We tell TransitionController that this is always a launch, and handle the launch
// vs return logic internally.
// TODO(b/323863002): maybe move the launch vs return logic out of this class and
// delegate it to TransitionController?
override val isLaunching: Boolean = true
override fun createAnimatorState(): TransitionAnimator.State {
return startController.createAnimatorState()
}
override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) {
// During launch, onLaunchAnimationStart will be used to remove the temporary
// touch surface ghost so it is important to call this before calling
// onLaunchAnimationStart on the controller (which will create its own ghost).
onLaunchAnimationStart()
startController.onTransitionAnimationStart(isExpandingFullyAbove)
endController.onTransitionAnimationStart(isExpandingFullyAbove)
}
override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) {
// onLaunchAnimationEnd is called by an Animator at the end of the animation,
// on a Choreographer animation tick. The following calls will move the animated
// content from the dialog overlay back to its original position, and this
// change must be reflected in the next frame given that we then sync the next
// frame of both the content and dialog ViewRoots. However, in case that content
// is rendered by Compose, whose compositions are also scheduled on a
// Choreographer frame, any state change made *right now* won't be reflected in
// the next frame given that a Choreographer frame can't schedule another and
// have it happen in the same frame. So we post the forwarded calls to
// [Controller.onLaunchAnimationEnd], leaving this Choreographer frame, ensuring
// that the move of the content back to its original window will be reflected in
// the next frame right after [onLaunchAnimationEnd] is called.
//
// TODO(b/330672236): Move this to TransitionAnimator.
dialog.context.mainExecutor.execute {
startController.onTransitionAnimationEnd(isExpandingFullyAbove)
endController.onTransitionAnimationEnd(isExpandingFullyAbove)
onLaunchAnimationEnd()
}
}
override fun onTransitionAnimationProgress(
state: TransitionAnimator.State,
progress: Float,
linearProgress: Float
) {
startController.onTransitionAnimationProgress(state, progress, linearProgress)
// The end view is visible only iff the starting view is not visible.
state.visible = !state.visible
endController.onTransitionAnimationProgress(state, progress, linearProgress)
// If the dialog content is complex, its dimension might change during the
// launch animation. The animation end position might also change during the
// exit animation, for instance when locking the phone when the dialog is open.
// Therefore we update the end state to the new position/size. Usually the
// dialog dimension or position will change in the early frames, so changing the
// end state shouldn't really be noticeable.
if (endController is GhostedViewTransitionAnimatorController) {
endController.fillGhostedViewState(endState)
}
}
}
transitionAnimator.startAnimation(controller, endState, originalDialogBackgroundColor)
}
private fun shouldAnimateDialogIntoSource(): Boolean {
// Don't animate if the dialog was previously hidden using hide() or if we disabled the exit
// animation.
if (exitAnimationDisabled || !dialog.isShowing) {
return false
}
// If we are dreaming, the dialog was probably closed because of that so we don't animate
// into the source.
if (callback.isDreaming()) {
return false
}
return controller.shouldAnimateExit()
}
/** A layout listener to animate the change of bounds of the dialog background. */
class AnimatedBoundsLayoutListener : View.OnLayoutChangeListener {
companion object {
private const val ANIMATION_DURATION = 500L
}
private var lastBounds: Rect? = null
private var currentAnimator: ValueAnimator? = null
override fun onLayoutChange(
view: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
// Don't animate if bounds didn't actually change.
if (left == oldLeft && top == oldTop && right == oldRight && bottom == oldBottom) {
// Make sure that we that the last bounds set by the animator were not overridden.
lastBounds?.let { bounds ->
view.left = bounds.left
view.top = bounds.top
view.right = bounds.right
view.bottom = bounds.bottom
}
return
}
if (lastBounds == null) {
lastBounds = Rect(oldLeft, oldTop, oldRight, oldBottom)
}
val bounds = lastBounds!!
val startLeft = bounds.left
val startTop = bounds.top
val startRight = bounds.right
val startBottom = bounds.bottom
currentAnimator?.cancel()
currentAnimator = null
val animator =
ValueAnimator.ofFloat(0f, 1f).apply {
duration = ANIMATION_DURATION
interpolator = Interpolators.STANDARD
addListener(
object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
currentAnimator = null
}
}
)
addUpdateListener { animatedValue ->
val progress = animatedValue.animatedFraction
// Compute new bounds.
bounds.left = MathUtils.lerp(startLeft, left, progress).roundToInt()
bounds.top = MathUtils.lerp(startTop, top, progress).roundToInt()
bounds.right = MathUtils.lerp(startRight, right, progress).roundToInt()
bounds.bottom = MathUtils.lerp(startBottom, bottom, progress).roundToInt()
// Set the new bounds.
view.left = bounds.left
view.top = bounds.top
view.right = bounds.right
view.bottom = bounds.bottom
}
}
currentAnimator = animator
animator.start()
}
}
fun prepareForStackDismiss() {
if (parentAnimatedDialog == null) {
return
}
parentAnimatedDialog.exitAnimationDisabled = true
parentAnimatedDialog.dialog.hide()
parentAnimatedDialog.prepareForStackDismiss()
parentAnimatedDialog.dialog.dismiss()
}
}