diff options
5 files changed, 1008 insertions, 145 deletions
diff --git a/packages/SystemUI/animation/res/values/ids.xml b/packages/SystemUI/animation/res/values/ids.xml index f7150ab548dd..2d82307aca76 100644 --- a/packages/SystemUI/animation/res/values/ids.xml +++ b/packages/SystemUI/animation/res/values/ids.xml @@ -16,7 +16,6 @@ --> <resources> <!-- DialogLaunchAnimator --> - <item type="id" name="tag_launch_animation_running"/> <item type="id" name="tag_dialog_background"/> <!-- ViewBoundsAnimator --> diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt index 9656b8a99d41..23cee4d0972d 100644 --- a/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt @@ -29,12 +29,12 @@ import android.view.GhostView 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 android.widget.FrameLayout import com.android.internal.jank.InteractionJankMonitor -import com.android.internal.jank.InteractionJankMonitor.Configuration import com.android.internal.jank.InteractionJankMonitor.CujType import kotlin.math.roundToInt @@ -46,6 +46,7 @@ private const val TAG = "DialogLaunchAnimator" * * This animator also allows to easily animate a dialog into an activity. * + * @see show * @see showFromView * @see showFromDialog * @see createActivityLaunchController @@ -67,8 +68,81 @@ constructor( ActivityLaunchAnimator.INTERPOLATORS.copy( positionXInterpolator = ActivityLaunchAnimator.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 + + /** + * 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 [LaunchAnimator.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 createLaunchController(): LaunchAnimator.Controller + + /** + * Create the [LaunchAnimator.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(): LaunchAnimator.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 - private val TAG_LAUNCH_ANIMATION_RUNNING = R.id.tag_launch_animation_running + /** + * 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(cuj: Int): InteractionJankMonitor.Configuration.Builder? } /** @@ -96,7 +170,28 @@ constructor( dialog: Dialog, view: View, cuj: DialogCuj? = null, - animateBackgroundBoundsChange: Boolean = false, + animateBackgroundBoundsChange: Boolean = false + ) { + show(dialog, createController(view), cuj, 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. + */ + fun show( + dialog: Dialog, + controller: Controller, + cuj: DialogCuj? = null, + animateBackgroundBoundsChange: Boolean = false ) { if (Looper.myLooper() != Looper.getMainLooper()) { throw IllegalStateException( @@ -109,9 +204,10 @@ constructor( // intent is to launch a dialog from another dialog. val animatedParent = openedDialogs.firstOrNull { - it.dialog.window.decorView.viewRootImpl == view.viewRootImpl + it.dialog.window.decorView.viewRootImpl == controller.viewRoot } - val animateFrom = animatedParent?.dialogContentWithBackground ?: view + val animateFrom = + animatedParent?.dialogContentWithBackground?.let { createController(it) } ?: controller if (animatedParent == null && animateFrom !is LaunchableView) { // Make sure the View we launch from implements LaunchableView to avoid visibility @@ -126,15 +222,17 @@ constructor( ) } - // Make sure we don't run the launch animation from the same view twice at the same time. - if (animateFrom.getTag(TAG_LAUNCH_ANIMATION_RUNNING) != null) { - Log.e(TAG, "Not running dialog launch animation as there is already one running") + // 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 } - animateFrom.setTag(TAG_LAUNCH_ANIMATION_RUNNING, true) - val animatedDialog = AnimatedDialog( launchAnimator, @@ -146,16 +244,99 @@ constructor( animateBackgroundBoundsChange, animatedParent, isForTesting, - cuj + cuj, ) openedDialogs.add(animatedDialog) animatedDialog.start() } + /** Create a [Controller] that can animate [source] to & from a dialog. */ + private fun createController(source: View): Controller { + return object : Controller { + override val viewRoot: ViewRootImpl + get() = source.viewRootImpl + + override val sourceIdentity: Any = source + + override fun startDrawingInOverlayOf(viewGroup: ViewGroup) { + // Create a temporary ghost of the source (which will make it invisible) and add it + // to the host dialog. + GhostView.addGhost(source, viewGroup) + + // The ghost of the source was just created, so the source is currently invisible. + // We need to make sure that it stays invisible as long as the dialog is shown or + // animating. + (source as? LaunchableView)?.setShouldBlockVisibilityChanges(true) + } + + override fun stopDrawingInOverlay() { + // Note: here we should remove the ghost from the overlay, but in practice this is + // already done by the launch controllers created below. + + // Make sure we allow the source to change its visibility again. + (source as? LaunchableView)?.setShouldBlockVisibilityChanges(false) + source.visibility = View.VISIBLE + } + + override fun createLaunchController(): LaunchAnimator.Controller { + val delegate = GhostedViewLaunchAnimatorController(source) + return object : LaunchAnimator.Controller by delegate { + override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { + // Remove the temporary ghost added by [startDrawingInOverlayOf]. Another + // ghost (that ghosts only the source content, and not its background) will + // be added right after this by the delegate and will be animated. + GhostView.removeGhost(source) + delegate.onLaunchAnimationStart(isExpandingFullyAbove) + } + + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationEnd(isExpandingFullyAbove) + + // We hide the source when the dialog is showing. We will make this view + // visible again when dismissing the dialog. This does nothing if the source + // implements [LaunchableView], as it's already INVISIBLE in that case. + source.visibility = View.INVISIBLE + } + } + } + + override fun createExitController(): LaunchAnimator.Controller { + return GhostedViewLaunchAnimatorController(source) + } + + override fun shouldAnimateExit(): Boolean { + // The source should be invisible by now, if it's not then something else changed + // its visibility and we probably don't want to run the animation. + if (source.visibility != View.INVISIBLE) { + return false + } + + return source.isAttachedToWindow && ((source.parent as? View)?.isShown ?: true) + } + + override fun onExitAnimationCancelled() { + // Make sure we allow the source to change its visibility again. + (source as? LaunchableView)?.setShouldBlockVisibilityChanges(false) + + // If the view is invisible it's probably because of us, so we make it visible + // again. + if (source.visibility == View.INVISIBLE) { + source.visibility = View.VISIBLE + } + } + + override fun jankConfigurationBuilder( + cuj: Int + ): InteractionJankMonitor.Configuration.Builder? { + return InteractionJankMonitor.Configuration.Builder.withView(cuj, source) + } + } + } + /** - * Launch [dialog] from [another dialog][animateFrom] that was shown using [showFromView]. This - * will allow for dismissing the whole stack. + * Launch [dialog] from [another dialog][animateFrom] that was shown using [show]. This will + * allow for dismissing the whole stack. * * @see dismissStack */ @@ -181,32 +362,55 @@ constructor( /** * Create an [ActivityLaunchAnimator.Controller] that can be used to launch an activity from the - * dialog that contains [View]. Note that the dialog must have been show using [showFromView] - * and be currently showing, otherwise this will return null. + * 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. * - * Note: The background of [view] should be a (rounded) rectangle so that it can be properly - * animated. - * * @param view any view inside the dialog to animate. */ @JvmOverloads fun createActivityLaunchController( view: View, - cujType: Int? = null + cujType: Int? = null, ): ActivityLaunchAnimator.Controller? { val animatedDialog = openedDialogs.firstOrNull { it.dialog.window.decorView.viewRootImpl == view.viewRootImpl } ?: return null + return createActivityLaunchController(animatedDialog, cujType) + } + /** + * Create an [ActivityLaunchAnimator.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 createActivityLaunchController( + dialog: Dialog, + cujType: Int? = null, + ): ActivityLaunchAnimator.Controller? { + val animatedDialog = openedDialogs.firstOrNull { it.dialog == dialog } ?: return null + return createActivityLaunchController(animatedDialog, cujType) + } + + private fun createActivityLaunchController( + animatedDialog: AnimatedDialog, + cujType: Int? = null + ): ActivityLaunchAnimator.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 touch surface because we will never - // want to run it anyways. + // 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 @@ -252,7 +456,7 @@ constructor( // If this dialog was shown from a cascade of other dialogs, make sure those ones // are dismissed too. - animatedDialog.touchSurface = animatedDialog.prepareForStackDismiss() + animatedDialog.prepareForStackDismiss() // Remove the dim. dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) @@ -283,12 +487,11 @@ constructor( } /** - * Ensure that all dialogs currently shown won't animate into their touch surface when - * dismissed. + * 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 the touch surface - * at the same time as the activity starts. + * 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. */ @@ -297,13 +500,11 @@ constructor( } /** - * Dismiss [dialog]. If it was launched from another dialog using [showFromView], also dismiss - * the stack of dialogs, animating back to the original touchSurface. + * 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 } - ?.let { it.touchSurface = it.prepareForStackDismiss() } + openedDialogs.firstOrNull { it.dialog == dialog }?.prepareForStackDismiss() dialog.dismiss() } @@ -337,8 +538,11 @@ private class AnimatedDialog( private val callback: DialogLaunchAnimator.Callback, private val interactionJankMonitor: InteractionJankMonitor, - /** The view that triggered the dialog after being tapped. */ - var touchSurface: View, + /** + * The controller of the source that triggered the dialog and that will animate into/from the + * dialog. + */ + val controller: DialogLaunchAnimator.Controller, /** * A callback that will be called with this [AnimatedDialog] after the dialog was dismissed and @@ -383,17 +587,18 @@ private class AnimatedDialog( private var originalDialogBackgroundColor = Color.BLACK /** - * Whether we are currently launching/showing the dialog by animating it from [touchSurface]. + * 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 [touchSurface]. */ + /** 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 isTouchSurfaceGhostDrawn = false + private var isSourceDrawnInDialog = false private var isOriginalDialogViewLaidOut = false /** A layout listener to animate the dialog height change. */ @@ -410,13 +615,19 @@ private class AnimatedDialog( */ private var decorViewLayoutListener: View.OnLayoutChangeListener? = null + private var hasInstrumentedJank = false + fun start() { if (cuj != null) { - val config = Configuration.Builder.withView(cuj.cujType, touchSurface) - if (cuj.tag != null) { - config.setTag(cuj.tag) + val config = controller.jankConfigurationBuilder(cuj.cujType) + if (config != null) { + if (cuj.tag != null) { + config.setTag(cuj.tag) + } + + interactionJankMonitor.begin(config) + hasInstrumentedJank = true } - interactionJankMonitor.begin(config) } // Create the dialog so that its onCreate() method is called, which usually sets the dialog @@ -618,47 +829,45 @@ private class AnimatedDialog( // Show the dialog. dialog.show() - addTouchSurfaceGhost() + moveSourceDrawingToDialog() } - private fun addTouchSurfaceGhost() { + private fun moveSourceDrawingToDialog() { if (decorView.viewRootImpl == null) { - // Make sure that we have access to the dialog view root to synchronize the creation of - // the ghost. - decorView.post(::addTouchSurfaceGhost) + // Make sure that we have access to the dialog view root to move the drawing to the + // dialog overlay. + decorView.post(::moveSourceDrawingToDialog) return } - // Create a ghost of the touch surface (which will make the touch surface invisible) and add - // it to the host dialog. We trigger a one off synchronization to make sure that this is - // done in sync between the two different windows. + // 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. synchronizeNextDraw( then = { - isTouchSurfaceGhostDrawn = true + isSourceDrawnInDialog = true maybeStartLaunchAnimation() } ) - GhostView.addGhost(touchSurface, decorView) - - // The ghost of the touch surface was just created, so the touch surface is currently - // invisible. We need to make sure that it stays invisible as long as the dialog is shown or - // animating. - (touchSurface as? LaunchableView)?.setShouldBlockVisibilityChanges(true) + controller.startDrawingInOverlayOf(decorView) } /** - * Synchronize the next draw of the touch surface 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 - * ghost of the touch surface is drawn at the same time as the touch surface is made invisible - * (or inversely, removed from the UI when the touch surface is made visible). + * 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) { if (forceDisableSynchronization) { + // Don't synchronize when inside an automated test. then() return } - ViewRootSync.synchronizeNextDraw(touchSurface, decorView, then) + ViewRootSync.synchronizeNextDraw(decorView, controller.viewRoot.view, then) + decorView.invalidate() + controller.viewRoot.view.invalidate() } private fun findFirstViewGroupWithBackground(view: View): ViewGroup? { @@ -681,7 +890,7 @@ private class AnimatedDialog( } private fun maybeStartLaunchAnimation() { - if (!isTouchSurfaceGhostDrawn || !isOriginalDialogViewLaidOut) { + if (!isSourceDrawnInDialog || !isOriginalDialogViewLaidOut) { return } @@ -690,19 +899,7 @@ private class AnimatedDialog( startAnimation( isLaunching = true, - onLaunchAnimationStart = { - // Remove the temporary ghost. Another ghost (that ghosts only the touch surface - // content, and not its background) will be added right after this and will be - // animated. - GhostView.removeGhost(touchSurface) - }, onLaunchAnimationEnd = { - touchSurface.setTag(R.id.tag_launch_animation_running, null) - - // We hide the touch surface when the dialog is showing. We will make this view - // visible again when dismissing the dialog. - touchSurface.visibility = View.INVISIBLE - isLaunching = false // dismiss was called during the animation, dismiss again now to actually dismiss. @@ -718,7 +915,10 @@ private class AnimatedDialog( backgroundLayoutListener ) } - cuj?.run { interactionJankMonitor.end(cujType) } + + if (hasInstrumentedJank) { + interactionJankMonitor.end(cuj!!.cujType) + } } ) } @@ -753,8 +953,8 @@ private class AnimatedDialog( } /** - * Hide the dialog into the touch surface and call [onAnimationFinished] when the animation is - * done (passing animationRan=true) or if it's skipped (passing animationRan=false) to actually + * 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) { @@ -763,17 +963,9 @@ private class AnimatedDialog( decorView.removeOnLayoutChangeListener(decorViewLayoutListener) } - if (!shouldAnimateDialogIntoView()) { - Log.i(TAG, "Skipping animation of dialog into the touch surface") - - // Make sure we allow the touch surface to change its visibility again. - (touchSurface as? LaunchableView)?.setShouldBlockVisibilityChanges(false) - - // If the view is invisible it's probably because of us, so we make it visible again. - if (touchSurface.visibility == View.INVISIBLE) { - touchSurface.visibility = View.VISIBLE - } - + if (!shouldAnimateDialogIntoSource()) { + Log.i(TAG, "Skipping animation of dialog into the source") + controller.onExitAnimationCancelled() onAnimationFinished(false /* instantDismiss */) onDialogDismissed(this@AnimatedDialog) return @@ -786,10 +978,6 @@ private class AnimatedDialog( dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) }, onLaunchAnimationEnd = { - // Make sure we allow the touch surface to change its visibility again. - (touchSurface as? LaunchableView)?.setShouldBlockVisibilityChanges(false) - - touchSurface.visibility = View.VISIBLE val dialogContentWithBackground = this.dialogContentWithBackground!! dialogContentWithBackground.visibility = View.INVISIBLE @@ -799,14 +987,11 @@ private class AnimatedDialog( ) } - // Make sure that the removal of the ghost and making the touch surface visible is - // done at the same time. - synchronizeNextDraw( - then = { - onAnimationFinished(true /* instantDismiss */) - onDialogDismissed(this@AnimatedDialog) - } - ) + controller.stopDrawingInOverlay() + synchronizeNextDraw { + onAnimationFinished(true /* instantDismiss */) + onDialogDismissed(this@AnimatedDialog) + } } ) } @@ -816,27 +1001,34 @@ private class AnimatedDialog( onLaunchAnimationStart: () -> Unit = {}, onLaunchAnimationEnd: () -> Unit = {} ) { - // Create 2 ghost controllers to animate both the dialog and the touch surface in the - // dialog. - val startView = if (isLaunching) touchSurface else dialogContentWithBackground!! - val endView = if (isLaunching) dialogContentWithBackground!! else touchSurface - val startViewController = GhostedViewLaunchAnimatorController(startView) - val endViewController = GhostedViewLaunchAnimatorController(endView) - startViewController.launchContainer = decorView - endViewController.launchContainer = decorView - - val endState = endViewController.createAnimatorState() + // Create 2 controllers to animate both the dialog and the source. + val startController = + if (isLaunching) { + controller.createLaunchController() + } else { + GhostedViewLaunchAnimatorController(dialogContentWithBackground!!) + } + val endController = + if (isLaunching) { + GhostedViewLaunchAnimatorController(dialogContentWithBackground!!) + } else { + controller.createExitController() + } + startController.launchContainer = decorView + endController.launchContainer = decorView + + val endState = endController.createAnimatorState() val controller = object : LaunchAnimator.Controller { override var launchContainer: ViewGroup - get() = startViewController.launchContainer + get() = startController.launchContainer set(value) { - startViewController.launchContainer = value - endViewController.launchContainer = value + startController.launchContainer = value + endController.launchContainer = value } override fun createAnimatorState(): LaunchAnimator.State { - return startViewController.createAnimatorState() + return startController.createAnimatorState() } override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { @@ -845,15 +1037,29 @@ private class AnimatedDialog( // onLaunchAnimationStart on the controller (which will create its own ghost). onLaunchAnimationStart() - startViewController.onLaunchAnimationStart(isExpandingFullyAbove) - endViewController.onLaunchAnimationStart(isExpandingFullyAbove) + startController.onLaunchAnimationStart(isExpandingFullyAbove) + endController.onLaunchAnimationStart(isExpandingFullyAbove) } override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { - startViewController.onLaunchAnimationEnd(isExpandingFullyAbove) - endViewController.onLaunchAnimationEnd(isExpandingFullyAbove) - - onLaunchAnimationEnd() + // 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. + dialog.context.mainExecutor.execute { + startController.onLaunchAnimationEnd(isExpandingFullyAbove) + endController.onLaunchAnimationEnd(isExpandingFullyAbove) + + onLaunchAnimationEnd() + } } override fun onLaunchAnimationProgress( @@ -861,11 +1067,11 @@ private class AnimatedDialog( progress: Float, linearProgress: Float ) { - startViewController.onLaunchAnimationProgress(state, progress, linearProgress) + startController.onLaunchAnimationProgress(state, progress, linearProgress) // The end view is visible only iff the starting view is not visible. state.visible = !state.visible - endViewController.onLaunchAnimationProgress(state, progress, linearProgress) + endController.onLaunchAnimationProgress(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 @@ -873,14 +1079,16 @@ private class AnimatedDialog( // 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. - endViewController.fillGhostedViewState(endState) + if (endController is GhostedViewLaunchAnimatorController) { + endController.fillGhostedViewState(endState) + } } } launchAnimator.startAnimation(controller, endState, originalDialogBackgroundColor) } - private fun shouldAnimateDialogIntoView(): Boolean { + 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) { @@ -888,24 +1096,12 @@ private class AnimatedDialog( } // If we are dreaming, the dialog was probably closed because of that so we don't animate - // into the touchSurface. + // into the source. if (callback.isDreaming()) { return false } - // The touch surface should be invisible by now, if it's not then something else changed its - // visibility and we probably don't want to run the animation. - if (touchSurface.visibility != View.INVISIBLE) { - return false - } - - // If the touch surface is not attached or one of its ancestors is not visible, then we - // don't run the animation either. - if (!touchSurface.isAttachedToWindow) { - return false - } - - return (touchSurface.parent as? View)?.isShown ?: true + return controller.shouldAnimateExit() } /** A layout listener to animate the change of bounds of the dialog background. */ @@ -988,17 +1184,13 @@ private class AnimatedDialog( } } - fun prepareForStackDismiss(): View { + fun prepareForStackDismiss() { if (parentAnimatedDialog == null) { - return touchSurface + return } parentAnimatedDialog.exitAnimationDisabled = true parentAnimatedDialog.dialog.hide() - val view = parentAnimatedDialog.prepareForStackDismiss() + parentAnimatedDialog.prepareForStackDismiss() parentAnimatedDialog.dialog.dismiss() - // Make the touch surface invisible, so we end up animating to it when we actually - // dismiss the stack - view.visibility = View.INVISIBLE - return view } } diff --git a/packages/SystemUI/compose/core/Android.bp b/packages/SystemUI/compose/core/Android.bp index 4cfe39225a9b..fbdb526d6b31 100644 --- a/packages/SystemUI/compose/core/Android.bp +++ b/packages/SystemUI/compose/core/Android.bp @@ -30,8 +30,11 @@ android_library { ], static_libs: [ + "SystemUIAnimationLib", + "androidx.compose.runtime_runtime", "androidx.compose.material3_material3", + "androidx.savedstate_savedstate", ], kotlincflags: ["-Xjvm-default=all"], diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt new file mode 100644 index 000000000000..8f9a4dae67bd --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt @@ -0,0 +1,363 @@ +/* + * Copyright (C) 2022 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.compose.animation + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroupOverlay +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Density +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.lifecycle.ViewTreeViewModelStoreOwner +import androidx.savedstate.ViewTreeSavedStateRegistryOwner +import com.android.systemui.animation.LaunchAnimator +import kotlin.math.min + +/** + * Create an expandable shape that can launch into an Activity or a Dialog. + * + * Example: + * ``` + * Expandable( + * color = MaterialTheme.colorScheme.primary, + * shape = RoundedCornerShape(16.dp), + * ) { controller -> + * Row( + * Modifier + * // For activities: + * .clickable { activityStarter.startActivity(intent, controller.forActivity()) } + * + * // For dialogs: + * .clickable { dialogLaunchAnimator.show(dialog, controller.forDialog()) } + * ) { ... } + * } + * ``` + * + * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen + * @sample com.android.systemui.compose.gallery.DialogLaunchScreen + */ +@Composable +fun Expandable( + color: Color, + shape: Shape, + modifier: Modifier = Modifier, + contentColor: Color = contentColorFor(color), + content: @Composable (ExpandableController) -> Unit, +) { + Expandable( + rememberExpandableController(color, shape, contentColor), + modifier, + content, + ) +} + +/** + * Create an expandable shape that can launch into an Activity or a Dialog. + * + * This overload can be used in cases where you need to create the [ExpandableController] before + * composing this [Expandable], for instance if something outside of this Expandable can trigger a + * launch animation + * + * Example: + * ``` + * // The controller that you can use to trigger the animations from anywhere. + * val controller = + * rememberExpandableController( + * color = MaterialTheme.colorScheme.primary, + * shape = RoundedCornerShape(16.dp), + * ) + * + * Expandable(controller) { + * ... + * } + * ``` + * + * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen + * @sample com.android.systemui.compose.gallery.DialogLaunchScreen + */ +@Composable +fun Expandable( + controller: ExpandableController, + modifier: Modifier = Modifier, + content: @Composable (ExpandableController) -> Unit, +) { + val controller = controller as ExpandableControllerImpl + val color = controller.color + val contentColor = controller.contentColor + val shape = controller.shape + + // TODO(b/230830644): Use movableContentOf to preserve the content state instead once the + // Compose libraries have been updated and include aosp/2163631. + val wrappedContent = + @Composable { controller: ExpandableController -> + CompositionLocalProvider( + LocalContentColor provides contentColor, + ) { + content(controller) + } + } + + val thisExpandableSize by remember { + derivedStateOf { controller.boundsInComposeViewRoot.value.size } + } + + // Make sure we don't read animatorState directly here to avoid recomposition every time the + // state changes (i.e. every frame of the animation). + val isAnimating by remember { + derivedStateOf { + controller.animatorState.value != null && controller.overlay.value != null + } + } + + when { + isAnimating -> { + // Don't compose the movable content during the animation, as it should be composed only + // once at all times. We make this spacer exactly the same size as this Expandable when + // it is visible. + Spacer( + modifier + .clip(shape) + .requiredSize(with(controller.density) { thisExpandableSize.toDpSize() }) + ) + + // The content and its animated background in the overlay. We draw it only when we are + // animating. + AnimatedContentInOverlay( + color, + thisExpandableSize, + controller.animatorState, + controller.overlay.value + ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."), + controller, + wrappedContent, + controller.composeViewRoot, + { controller.currentComposeViewInOverlay.value = it }, + controller.density, + ) + } + controller.isDialogShowing.value -> { + Box( + modifier + .drawWithContent { /* Don't draw anything when the dialog is shown. */} + .onGloballyPositioned { + controller.boundsInComposeViewRoot.value = it.boundsInRoot() + } + ) { wrappedContent(controller) } + } + else -> { + Box( + modifier.clip(shape).background(color, shape).onGloballyPositioned { + controller.boundsInComposeViewRoot.value = it.boundsInRoot() + } + ) { wrappedContent(controller) } + } + } +} + +/** Draw [content] in [overlay] while respecting its screen position given by [animatorState]. */ +@Composable +private fun AnimatedContentInOverlay( + color: Color, + sizeInOriginalLayout: Size, + animatorState: State<LaunchAnimator.State?>, + overlay: ViewGroupOverlay, + controller: ExpandableController, + content: @Composable (ExpandableController) -> Unit, + composeViewRoot: View, + onOverlayComposeViewChanged: (View?) -> Unit, + density: Density, +) { + val compositionContext = rememberCompositionContext() + val context = LocalContext.current + + // Create the ComposeView and force its content composition so that the movableContent is + // composed exactly once when we start animating. + val composeViewInOverlay = + remember(context, density) { + val startWidth = sizeInOriginalLayout.width + val startHeight = sizeInOriginalLayout.height + val contentModifier = + Modifier + // Draw the content with the same size as it was at the start of the animation + // so that its content is laid out exactly the same way. + .requiredSize(with(density) { sizeInOriginalLayout.toDpSize() }) + .drawWithContent { + val animatorState = animatorState.value ?: return@drawWithContent + + // Scale the content with the background while keeping its aspect ratio. + val widthRatio = + if (startWidth != 0f) { + animatorState.width.toFloat() / startWidth + } else { + 1f + } + val heightRatio = + if (startHeight != 0f) { + animatorState.height.toFloat() / startHeight + } else { + 1f + } + val scale = min(widthRatio, heightRatio) + scale(scale) { this@drawWithContent.drawContent() } + } + + val composeView = + ComposeView(context).apply { + setContent { + Box( + Modifier.fillMaxSize().drawWithContent { + val animatorState = animatorState.value ?: return@drawWithContent + if (!animatorState.visible) { + return@drawWithContent + } + + val topRadius = animatorState.topCornerRadius + val bottomRadius = animatorState.bottomCornerRadius + if (topRadius == bottomRadius) { + // Shortcut to avoid Outline calculation and allocation. + val cornerRadius = CornerRadius(topRadius) + drawRoundRect(color, cornerRadius = cornerRadius) + } else { + val shape = + RoundedCornerShape( + topStart = topRadius, + topEnd = topRadius, + bottomStart = bottomRadius, + bottomEnd = bottomRadius, + ) + val outline = shape.createOutline(size, layoutDirection, this) + drawOutline(outline, color = color) + } + + drawContent() + }, + // We center the content in the expanding container. + contentAlignment = Alignment.Center, + ) { + Box(contentModifier) { content(controller) } + } + } + } + + // Set the owners. + val overlayViewGroup = + getOverlayViewGroup( + context, + overlay, + ) + ViewTreeLifecycleOwner.set( + overlayViewGroup, + ViewTreeLifecycleOwner.get(composeViewRoot), + ) + ViewTreeViewModelStoreOwner.set( + overlayViewGroup, + ViewTreeViewModelStoreOwner.get(composeViewRoot), + ) + ViewTreeSavedStateRegistryOwner.set( + overlayViewGroup, + ViewTreeSavedStateRegistryOwner.get(composeViewRoot), + ) + + composeView.setParentCompositionContext(compositionContext) + + composeView + } + + DisposableEffect(overlay, composeViewInOverlay) { + // Add the ComposeView to the overlay. + overlay.add(composeViewInOverlay) + + val startState = + animatorState.value + ?: throw IllegalStateException( + "AnimatedContentInOverlay shouldn't be composed with null animatorState." + ) + measureAndLayoutComposeViewInOverlay(composeViewInOverlay, startState) + onOverlayComposeViewChanged(composeViewInOverlay) + + onDispose { + composeViewInOverlay.disposeComposition() + overlay.remove(composeViewInOverlay) + onOverlayComposeViewChanged(null) + } + } +} + +internal fun measureAndLayoutComposeViewInOverlay( + view: View, + state: LaunchAnimator.State, +) { + val exactWidth = state.width + val exactHeight = state.height + view.measure( + View.MeasureSpec.makeSafeMeasureSpec(exactWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeSafeMeasureSpec(exactHeight, View.MeasureSpec.EXACTLY), + ) + + val parent = view.parent as ViewGroup + val parentLocation = parent.locationOnScreen + val offsetX = parentLocation[0] + val offsetY = parentLocation[1] + view.layout( + state.left - offsetX, + state.top - offsetY, + state.right - offsetX, + state.bottom - offsetY, + ) +} + +// TODO(b/230830644): Add hidden API to ViewGroupOverlay to access this ViewGroup directly? +private fun getOverlayViewGroup(context: Context, overlay: ViewGroupOverlay): ViewGroup { + val view = View(context) + overlay.add(view) + var current = view.parent + while (current.parent != null) { + current = current.parent + } + overlay.remove(view) + return current as ViewGroup +} diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt new file mode 100644 index 000000000000..065c3149c2f5 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2022 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.compose.animation + +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroupOverlay +import android.view.ViewRootImpl +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import com.android.internal.jank.InteractionJankMonitor +import com.android.systemui.animation.ActivityLaunchAnimator +import com.android.systemui.animation.DialogLaunchAnimator +import com.android.systemui.animation.LaunchAnimator +import kotlin.math.roundToInt + +/** A controller that can control animated launches. */ +interface ExpandableController { + /** Create an [ActivityLaunchAnimator.Controller] to animate into an Activity. */ + fun forActivity(): ActivityLaunchAnimator.Controller + + /** Create a [DialogLaunchAnimator.Controller] to animate into a Dialog. */ + fun forDialog(): DialogLaunchAnimator.Controller +} + +/** + * Create an [ExpandableController] to control an [Expandable]. This is useful if you need to create + * the controller before the [Expandable], for instance to handle clicks outside of the Expandable + * that would still trigger a dialog/activity launch animation. + */ +@Composable +fun rememberExpandableController( + color: Color, + shape: Shape, + contentColor: Color = contentColorFor(color), +): ExpandableController { + val composeViewRoot = LocalView.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + + // The current animation state, if we are currently animating a dialog or activity. + val animatorState = remember { mutableStateOf<LaunchAnimator.State?>(null) } + + // Whether a dialog controlled by this ExpandableController is currently showing. + val isDialogShowing = remember { mutableStateOf(false) } + + // The overlay in which we should animate the launch. + val overlay = remember { mutableStateOf<ViewGroupOverlay?>(null) } + + // The current [ComposeView] being animated in the [overlay], if any. + val currentComposeViewInOverlay = remember { mutableStateOf<View?>(null) } + + // The bounds in [composeViewRoot] of the expandable controlled by this controller. + val boundsInComposeViewRoot = remember { mutableStateOf(Rect.Zero) } + + // Whether this composable is still composed. We only do the dialog exit animation if this is + // true. + val isComposed = remember { mutableStateOf(true) } + DisposableEffect(Unit) { onDispose { isComposed.value = false } } + + return remember(color, contentColor, shape, composeViewRoot, density, layoutDirection) { + ExpandableControllerImpl( + color, + contentColor, + shape, + composeViewRoot, + density, + animatorState, + isDialogShowing, + overlay, + currentComposeViewInOverlay, + boundsInComposeViewRoot, + layoutDirection, + isComposed, + ) + } +} + +internal class ExpandableControllerImpl( + internal val color: Color, + internal val contentColor: Color, + internal val shape: Shape, + internal val composeViewRoot: View, + internal val density: Density, + internal val animatorState: MutableState<LaunchAnimator.State?>, + internal val isDialogShowing: MutableState<Boolean>, + internal val overlay: MutableState<ViewGroupOverlay?>, + internal val currentComposeViewInOverlay: MutableState<View?>, + internal val boundsInComposeViewRoot: MutableState<Rect>, + private val layoutDirection: LayoutDirection, + private val isComposed: State<Boolean>, +) : ExpandableController { + override fun forActivity(): ActivityLaunchAnimator.Controller { + return activityController() + } + + override fun forDialog(): DialogLaunchAnimator.Controller { + return dialogController() + } + + /** + * Create a [LaunchAnimator.Controller] that is going to be used to drive an activity or dialog + * animation. This controller will: + * 1. Compute the start/end animation state using [boundsInComposeViewRoot] and the location of + * composeViewRoot on the screen. + * 2. Update [animatorState] with the current animation state if we are animating, or null + * otherwise. + */ + private fun launchController(): LaunchAnimator.Controller { + return object : LaunchAnimator.Controller { + private val rootLocationOnScreen = intArrayOf(0, 0) + + override var launchContainer: ViewGroup = composeViewRoot.rootView as ViewGroup + + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + animatorState.value = null + } + + override fun onLaunchAnimationProgress( + state: LaunchAnimator.State, + progress: Float, + linearProgress: Float + ) { + // We copy state given that it's always the same object that is mutated by + // ActivityLaunchAnimator. + animatorState.value = + LaunchAnimator.State( + state.top, + state.bottom, + state.left, + state.right, + state.topCornerRadius, + state.bottomCornerRadius, + ) + .apply { visible = state.visible } + + // Force measure and layout the ComposeView in the overlay whenever the animation + // state changes. + currentComposeViewInOverlay.value?.let { + measureAndLayoutComposeViewInOverlay(it, state) + } + } + + override fun createAnimatorState(): LaunchAnimator.State { + val boundsInRoot = boundsInComposeViewRoot.value + val outline = + shape.createOutline( + Size(boundsInRoot.width, boundsInRoot.height), + layoutDirection, + density, + ) + + val (topCornerRadius, bottomCornerRadius) = + when (outline) { + is Outline.Rectangle -> 0f to 0f + is Outline.Rounded -> { + val roundRect = outline.roundRect + + // TODO(b/230830644): Add better support different corner radii. + val topCornerRadius = + maxOf( + roundRect.topLeftCornerRadius.x, + roundRect.topLeftCornerRadius.y, + roundRect.topRightCornerRadius.x, + roundRect.topRightCornerRadius.y, + ) + val bottomCornerRadius = + maxOf( + roundRect.bottomLeftCornerRadius.x, + roundRect.bottomLeftCornerRadius.y, + roundRect.bottomRightCornerRadius.x, + roundRect.bottomRightCornerRadius.y, + ) + + topCornerRadius to bottomCornerRadius + } + else -> + error( + "ExpandableState only supports (rounded) rectangles at the " + + "moment." + ) + } + + val rootLocation = rootLocationOnScreen() + return LaunchAnimator.State( + top = rootLocation.y.roundToInt(), + bottom = (rootLocation.y + boundsInRoot.height).roundToInt(), + left = rootLocation.x.roundToInt(), + right = (rootLocation.x + boundsInRoot.width).roundToInt(), + topCornerRadius = topCornerRadius, + bottomCornerRadius = bottomCornerRadius, + ) + } + + private fun rootLocationOnScreen(): Offset { + composeViewRoot.getLocationOnScreen(rootLocationOnScreen) + val boundsInRoot = boundsInComposeViewRoot.value + val x = rootLocationOnScreen[0] + boundsInRoot.left + val y = rootLocationOnScreen[1] + boundsInRoot.top + return Offset(x, y) + } + } + } + + /** Create an [ActivityLaunchAnimator.Controller] that can be used to animate activities. */ + private fun activityController(): ActivityLaunchAnimator.Controller { + val delegate = launchController() + return object : ActivityLaunchAnimator.Controller, LaunchAnimator.Controller by delegate { + override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationStart(isExpandingFullyAbove) + overlay.value = composeViewRoot.rootView.overlay as ViewGroupOverlay + } + + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationEnd(isExpandingFullyAbove) + overlay.value = null + } + } + } + + private fun dialogController(): DialogLaunchAnimator.Controller { + return object : DialogLaunchAnimator.Controller { + override val viewRoot: ViewRootImpl = composeViewRoot.viewRootImpl + override val sourceIdentity: Any = this@ExpandableControllerImpl + + override fun startDrawingInOverlayOf(viewGroup: ViewGroup) { + val newOverlay = viewGroup.overlay as ViewGroupOverlay + if (newOverlay != overlay.value) { + overlay.value = newOverlay + } + } + + override fun stopDrawingInOverlay() { + if (overlay.value != null) { + overlay.value = null + } + } + + override fun createLaunchController(): LaunchAnimator.Controller { + val delegate = launchController() + return object : LaunchAnimator.Controller by delegate { + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationEnd(isExpandingFullyAbove) + + // Make sure we don't draw this expandable when the dialog is showing. + isDialogShowing.value = true + } + } + } + + override fun createExitController(): LaunchAnimator.Controller { + val delegate = launchController() + return object : LaunchAnimator.Controller by delegate { + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationEnd(isExpandingFullyAbove) + isDialogShowing.value = false + } + } + } + + override fun shouldAnimateExit(): Boolean = isComposed.value + + override fun onExitAnimationCancelled() { + isDialogShowing.value = false + } + + override fun jankConfigurationBuilder( + cuj: Int + ): InteractionJankMonitor.Configuration.Builder? { + // TODO(b/252723237): Add support for jank monitoring when animating from a + // Composable. + return null + } + } + } +} |