Loading packages/SystemUI/animation/Android.bp +1 −1 Original line number Original line Diff line number Diff line Loading @@ -39,5 +39,5 @@ android_library { ], ], manifest: "AndroidManifest.xml", manifest: "AndroidManifest.xml", kotlincflags: ["-Xjvm-default=enable"], kotlincflags: ["-Xjvm-default=all"], } } packages/SystemUI/animation/src/com/android/systemui/animation/ActivityLaunchAnimator.kt +55 −6 Original line number Original line Diff line number Diff line Loading @@ -48,9 +48,16 @@ private const val TAG = "ActivityLaunchAnimator" * nicely into the starting window. * nicely into the starting window. */ */ class ActivityLaunchAnimator( class ActivityLaunchAnimator( private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS) /** The animator used when animating a View into an app. */ private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS), /** The animator used when animating a Dialog into an app. */ // TODO(b/218989950): Remove this animator and instead set the duration of the dim fade out to // TIMINGS.contentBeforeFadeOutDuration. private val dialogToAppAnimator: LaunchAnimator = LaunchAnimator(DIALOG_TIMINGS, INTERPOLATORS) ) { ) { companion object { companion object { /** The timings when animating a View into an app. */ @JvmField @JvmField val TIMINGS = LaunchAnimator.Timings( val TIMINGS = LaunchAnimator.Timings( totalDuration = 500L, totalDuration = 500L, Loading @@ -60,6 +67,17 @@ class ActivityLaunchAnimator( contentAfterFadeInDuration = 183L contentAfterFadeInDuration = 183L ) ) /** * The timings when animating a Dialog into an app. We need to wait at least 200ms before * showing the app (which is under the dialog window) so that the dialog window dim is fully * faded out, to avoid flicker. */ val DIALOG_TIMINGS = TIMINGS.copy( contentBeforeFadeOutDuration = 200L, contentAfterFadeInDelay = 200L ) /** The interpolators when animating a View or a dialog into an app. */ val INTERPOLATORS = LaunchAnimator.Interpolators( val INTERPOLATORS = LaunchAnimator.Interpolators( positionInterpolator = Interpolators.EMPHASIZED, positionInterpolator = Interpolators.EMPHASIZED, positionXInterpolator = createPositionXInterpolator(), positionXInterpolator = createPositionXInterpolator(), Loading Loading @@ -297,11 +315,18 @@ class ActivityLaunchAnimator( } } } } /** * Whether this controller is controlling a dialog launch. This will be used to adapt the * timings, making sure we don't show the app until the dialog dim had the time to fade out. */ // TODO(b/218989950): Remove this. val isDialogLaunch: Boolean get() = false /** /** * The intent was started. If [willAnimate] is false, nothing else will happen and the * The intent was started. If [willAnimate] is false, nothing else will happen and the * animation will not be started. * animation will not be started. */ */ @JvmDefault fun onIntentStarted(willAnimate: Boolean) {} fun onIntentStarted(willAnimate: Boolean) {} /** /** Loading @@ -309,7 +334,6 @@ class ActivityLaunchAnimator( * this if the animation was already started, i.e. if [onLaunchAnimationStart] was called * this if the animation was already started, i.e. if [onLaunchAnimationStart] was called * before the cancellation. * before the cancellation. */ */ @JvmDefault fun onLaunchAnimationCancelled() {} fun onLaunchAnimationCancelled() {} } } Loading @@ -317,7 +341,9 @@ class ActivityLaunchAnimator( inner class Runner(private val controller: Controller) : IRemoteAnimationRunner.Stub() { inner class Runner(private val controller: Controller) : IRemoteAnimationRunner.Stub() { private val launchContainer = controller.launchContainer private val launchContainer = controller.launchContainer private val context = launchContainer.context private val context = launchContainer.context private val transactionApplier = SyncRtSurfaceTransactionApplier(launchContainer) private val transactionApplierView = controller.openingWindowSyncView ?: controller.launchContainer private val transactionApplier = SyncRtSurfaceTransactionApplier(transactionApplierView) private val matrix = Matrix() private val matrix = Matrix() private val invertMatrix = Matrix() private val invertMatrix = Matrix() Loading Loading @@ -405,6 +431,13 @@ class ActivityLaunchAnimator( val callback = this@ActivityLaunchAnimator.callback!! val callback = this@ActivityLaunchAnimator.callback!! val windowBackgroundColor = callback.getBackgroundColor(window.taskInfo) val windowBackgroundColor = callback.getBackgroundColor(window.taskInfo) // Make sure we use the modified timings when animating a dialog into an app. val launchAnimator = if (controller.isDialogLaunch) { dialogToAppAnimator } else { launchAnimator } // TODO(b/184121838): We should somehow get the top and bottom radius of the window // TODO(b/184121838): We should somehow get the top and bottom radius of the window // instead of recomputing isExpandingFullyAbove here. // instead of recomputing isExpandingFullyAbove here. val isExpandingFullyAbove = val isExpandingFullyAbove = Loading Loading @@ -440,19 +473,29 @@ class ActivityLaunchAnimator( progress: Float, progress: Float, linearProgress: Float linearProgress: Float ) { ) { // Apply the state to the window only if it is visible, i.e. when the expanding // view is *not* visible. if (!state.visible) { applyStateToWindow(window, state) applyStateToWindow(window, state) } navigationBar?.let { applyStateToNavigationBar(it, state, linearProgress) } navigationBar?.let { applyStateToNavigationBar(it, state, linearProgress) } listeners.forEach { it.onLaunchAnimationProgress(linearProgress) } listeners.forEach { it.onLaunchAnimationProgress(linearProgress) } delegate.onLaunchAnimationProgress(state, progress, linearProgress) delegate.onLaunchAnimationProgress(state, progress, linearProgress) } } } } // We draw a hole when the additional layer is fading out to reveal the opening window. animation = launchAnimator.startAnimation( animation = launchAnimator.startAnimation( controller, endState, windowBackgroundColor, drawHole = true) controller, endState, windowBackgroundColor, drawHole = true) } } private fun applyStateToWindow(window: RemoteAnimationTarget, state: LaunchAnimator.State) { private fun applyStateToWindow(window: RemoteAnimationTarget, state: LaunchAnimator.State) { if (transactionApplierView.viewRootImpl == null) { // If the view root we synchronize with was detached, don't apply any transaction // (as [SyncRtSurfaceTransactionApplier.scheduleApply] would otherwise throw). return } val screenBounds = window.screenSpaceBounds val screenBounds = window.screenSpaceBounds val centerX = (screenBounds.left + screenBounds.right) / 2f val centerX = (screenBounds.left + screenBounds.right) / 2f val centerY = (screenBounds.top + screenBounds.bottom) / 2f val centerY = (screenBounds.top + screenBounds.bottom) / 2f Loading Loading @@ -510,6 +553,12 @@ class ActivityLaunchAnimator( state: LaunchAnimator.State, state: LaunchAnimator.State, linearProgress: Float linearProgress: Float ) { ) { if (transactionApplierView.viewRootImpl == null) { // If the view root we synchronize with was detached, don't apply any transaction // (as [SyncRtSurfaceTransactionApplier.scheduleApply] would otherwise throw). return } val fadeInProgress = LaunchAnimator.getProgress(TIMINGS, linearProgress, val fadeInProgress = LaunchAnimator.getProgress(TIMINGS, linearProgress, ANIMATION_DELAY_NAV_FADE_IN, ANIMATION_DURATION_NAV_FADE_OUT) ANIMATION_DELAY_NAV_FADE_IN, ANIMATION_DURATION_NAV_FADE_OUT) Loading packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt +122 −42 Original line number Original line Diff line number Diff line Loading @@ -19,7 +19,6 @@ package com.android.systemui.animation import android.animation.Animator import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.animation.ValueAnimator import android.app.ActivityManager import android.app.Dialog import android.app.Dialog import android.graphics.Color import android.graphics.Color import android.graphics.Rect import android.graphics.Rect Loading @@ -28,12 +27,12 @@ import android.service.dreams.IDreamManager import android.util.Log import android.util.Log import android.util.MathUtils import android.util.MathUtils import android.view.GhostView import android.view.GhostView import android.view.SurfaceControl import android.view.View import android.view.View import android.view.ViewGroup import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewRootImpl import android.view.WindowInsets import android.view.WindowManager import android.view.WindowManager import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS import android.widget.FrameLayout import android.widget.FrameLayout import kotlin.math.roundToInt import kotlin.math.roundToInt Loading @@ -42,12 +41,17 @@ private const val TAG = "DialogLaunchAnimator" /** /** * A class that allows dialogs to be started in a seamless way from a view that is transforming * A class that allows dialogs to be started in a seamless way from a view that is transforming * nicely into the starting dialog. * nicely into the starting dialog. * * This animator also allows to easily animate a dialog into an activity. * * @see showFromView * @see showFromDialog * @see createActivityLaunchController */ */ class DialogLaunchAnimator @JvmOverloads constructor( class DialogLaunchAnimator @JvmOverloads constructor( private val dreamManager: IDreamManager, private val dreamManager: IDreamManager, private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS), private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS), // TODO(b/217621394): Remove special handling for low-RAM devices after animation sync is fixed private val isForTesting: Boolean = false private var forceDisableSynchronization: Boolean = ActivityManager.isLowRamDeviceStatic() ) { ) { private companion object { private companion object { private val TIMINGS = ActivityLaunchAnimator.TIMINGS private val TIMINGS = ActivityLaunchAnimator.TIMINGS Loading Loading @@ -113,7 +117,7 @@ class DialogLaunchAnimator @JvmOverloads constructor( dialog = dialog, dialog = dialog, animateBackgroundBoundsChange, animateBackgroundBoundsChange, animatedParent, animatedParent, forceDisableSynchronization isForTesting ) ) openedDialogs.add(animatedDialog) openedDialogs.add(animatedDialog) Loading @@ -140,6 +144,100 @@ class DialogLaunchAnimator @JvmOverloads constructor( showFromView(dialog, view, animateBackgroundBoundsChange) showFromView(dialog, view, animateBackgroundBoundsChange) } } /** * 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. * * 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 createActivityLaunchController( view: View, cujType: Int? = null ): ActivityLaunchAnimator.Controller? { val animatedDialog = openedDialogs .firstOrNull { it.dialog.window.decorView.viewRootImpl == view.viewRootImpl } ?: return null // 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. animatedDialog.exitAnimationDisabled = true val dialog = animatedDialog.dialog // Don't animate if the dialog is not showing. if (!dialog.isShowing) { return null } val dialogContentWithBackground = animatedDialog.dialogContentWithBackground ?: return null val controller = ActivityLaunchAnimator.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 : ActivityLaunchAnimator.Controller by controller { override val isDialogLaunch = true override fun onIntentStarted(willAnimate: Boolean) { controller.onIntentStarted(willAnimate) if (!willAnimate) { dialog.dismiss() } } override fun onLaunchAnimationCancelled() { controller.onLaunchAnimationCancelled() enableDialogDismiss() dialog.dismiss() } override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { controller.onLaunchAnimationStart(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.touchSurface = animatedDialog.prepareForStackDismiss() // Remove the dim. dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) } override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { controller.onLaunchAnimationEnd(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 touch surface when * Ensure that all dialogs currently shown won't animate into their touch surface when * dismissed. * dismissed. Loading Loading @@ -358,6 +456,21 @@ private class AnimatedDialog( // Make sure the dialog is visible instantly and does not do any window animation. // Make sure the dialog is visible instantly and does not do any window animation. window.attributes.windowAnimations = R.style.Animation_LaunchAnimation window.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. window.attributes.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS 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 insets = windowInsets.getInsets(WindowInsets.Type.displayCutout()) view.setPadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsets.CONSUMED } // Start the animation once the background view is properly laid out. // Start the animation once the background view is properly laid out. dialogContentWithBackground.addOnLayoutChangeListener(object : View.OnLayoutChangeListener { dialogContentWithBackground.addOnLayoutChangeListener(object : View.OnLayoutChangeListener { override fun onLayoutChange( override fun onLayoutChange( Loading Loading @@ -421,45 +534,12 @@ private class AnimatedDialog( * (or inversely, removed from the UI when the touch surface is made visible). * (or inversely, removed from the UI when the touch surface is made visible). */ */ private fun synchronizeNextDraw(then: () -> Unit) { private fun synchronizeNextDraw(then: () -> Unit) { if (forceDisableSynchronization || if (forceDisableSynchronization) { !touchSurface.isAttachedToWindow || touchSurface.viewRootImpl == null || !decorView.isAttachedToWindow || decorView.viewRootImpl == null) { // No need to synchronize if either the touch surface or dialog view is not attached // to a window. then() then() return return } } // Consume the next frames of both view roots to make sure the ghost view is drawn at ViewRootSync.synchronizeNextDraw(touchSurface, decorView, then) // exactly the same time as when the touch surface is made invisible. var remainingTransactions = 0 val mergedTransactions = SurfaceControl.Transaction() fun onTransaction(transaction: SurfaceControl.Transaction?) { remainingTransactions-- transaction?.let { mergedTransactions.merge(it) } if (remainingTransactions == 0) { mergedTransactions.apply() then() } } fun consumeNextDraw(viewRootImpl: ViewRootImpl) { if (viewRootImpl.consumeNextDraw(::onTransaction)) { remainingTransactions++ // Make sure we trigger a traversal. viewRootImpl.view.invalidate() } } consumeNextDraw(touchSurface.viewRootImpl) consumeNextDraw(decorView.viewRootImpl) if (remainingTransactions == 0) { then() } } } private fun findFirstViewGroupWithBackground(view: View): ViewGroup? { private fun findFirstViewGroupWithBackground(view: View): ViewGroup? { Loading Loading @@ -523,7 +603,7 @@ private class AnimatedDialog( ) ) } } private fun onDialogDismissed() { fun onDialogDismissed() { if (Looper.myLooper() != Looper.getMainLooper()) { if (Looper.myLooper() != Looper.getMainLooper()) { dialog.context.mainExecutor.execute { onDialogDismissed() } dialog.context.mainExecutor.execute { onDialogDismissed() } return return Loading packages/SystemUI/animation/src/com/android/systemui/animation/LaunchAnimator.kt +47 −7 Original line number Original line Diff line number Diff line Loading @@ -77,8 +77,8 @@ class LaunchAnimator( * This will be used to: * This will be used to: * - Get the associated [Context]. * - Get the associated [Context]. * - Compute whether we are expanding fully above the launch container. * - Compute whether we are expanding fully above the launch container. * - Apply surface transactions in sync with RenderThread when animating an activity * - Get to overlay to which we initially put the window background layer, until the * launch. * opening window is made visible (see [openingWindowSyncView]). * * * This container can be changed to force this [Controller] to animate the expanding view * This container can be changed to force this [Controller] to animate the expanding view * inside a different location, for instance to ensure correct layering during the * inside a different location, for instance to ensure correct layering during the Loading @@ -86,6 +86,18 @@ class LaunchAnimator( */ */ var launchContainer: ViewGroup var launchContainer: ViewGroup /** * The [View] with which the opening app window should be synchronized with once it starts * to be visible. * * We will also move the window background layer to this view's overlay once the opening * window is visible. * * If null, this will default to [launchContainer]. */ val openingWindowSyncView: View? get() = null /** /** * Return the [State] of the view that will be animated. We will animate from this state to * Return the [State] of the view that will be animated. We will animate from this state to * the final window state. * the final window state. Loading @@ -100,11 +112,9 @@ class LaunchAnimator( * needed for the animation. [isExpandingFullyAbove] will be true if the window is expanding * needed for the animation. [isExpandingFullyAbove] will be true if the window is expanding * fully above the [launchContainer]. * fully above the [launchContainer]. */ */ @JvmDefault fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {} fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {} /** The animation made progress and the expandable view [state] should be updated. */ /** The animation made progress and the expandable view [state] should be updated. */ @JvmDefault fun onLaunchAnimationProgress(state: State, progress: Float, linearProgress: Float) {} fun onLaunchAnimationProgress(state: State, progress: Float, linearProgress: Float) {} /** /** Loading @@ -112,7 +122,6 @@ class LaunchAnimator( * called previously. This is typically used to clean up the resources initialized when the * called previously. This is typically used to clean up the resources initialized when the * animation was started. * animation was started. */ */ @JvmDefault fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {} fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {} } } Loading Loading @@ -154,7 +163,7 @@ class LaunchAnimator( } } /** The timings (durations and delays) used by this animator. */ /** The timings (durations and delays) used by this animator. */ class Timings( data class Timings( /** The total duration of the animation. */ /** The total duration of the animation. */ val totalDuration: Long, val totalDuration: Long, Loading Loading @@ -257,8 +266,17 @@ class LaunchAnimator( animator.duration = timings.totalDuration animator.duration = timings.totalDuration animator.interpolator = LINEAR animator.interpolator = LINEAR // Whether we should move the [windowBackgroundLayer] into the overlay of // [Controller.openingWindowSyncView] once the opening app window starts to be visible. val openingWindowSyncView = controller.openingWindowSyncView val openingWindowSyncViewOverlay = openingWindowSyncView?.overlay val moveBackgroundLayerWhenAppIsVisible = openingWindowSyncView != null && openingWindowSyncView.viewRootImpl != controller.launchContainer.viewRootImpl val launchContainerOverlay = launchContainer.overlay val launchContainerOverlay = launchContainer.overlay var cancelled = false var cancelled = false var movedBackgroundLayer = false animator.addListener(object : AnimatorListenerAdapter() { animator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator?, isReverse: Boolean) { override fun onAnimationStart(animation: Animator?, isReverse: Boolean) { if (DEBUG) { if (DEBUG) { Loading @@ -278,6 +296,10 @@ class LaunchAnimator( } } controller.onLaunchAnimationEnd(isExpandingFullyAbove) controller.onLaunchAnimationEnd(isExpandingFullyAbove) launchContainerOverlay.remove(windowBackgroundLayer) launchContainerOverlay.remove(windowBackgroundLayer) if (moveBackgroundLayerWhenAppIsVisible) { openingWindowSyncViewOverlay?.remove(windowBackgroundLayer) } } } }) }) Loading Loading @@ -318,11 +340,29 @@ class LaunchAnimator( timings.contentBeforeFadeOutDuration timings.contentBeforeFadeOutDuration ) < 1 ) < 1 if (moveBackgroundLayerWhenAppIsVisible && !state.visible && !movedBackgroundLayer) { // The expanding view is not visible, so the opening app is visible. If this is the // first frame when it happens, trigger a one-off sync and move the background layer // in its new container. movedBackgroundLayer = true launchContainerOverlay.remove(windowBackgroundLayer) openingWindowSyncViewOverlay!!.add(windowBackgroundLayer) ViewRootSync.synchronizeNextDraw(launchContainer, openingWindowSyncView, then = {}) } val container = if (movedBackgroundLayer) { openingWindowSyncView!! } else { controller.launchContainer } applyStateToWindowBackgroundLayer( applyStateToWindowBackgroundLayer( windowBackgroundLayer, windowBackgroundLayer, state, state, linearProgress, linearProgress, launchContainer, container, drawHole drawHole ) ) controller.onLaunchAnimationProgress(state, progress, linearProgress) controller.onLaunchAnimationProgress(state, progress, linearProgress) Loading packages/SystemUI/animation/src/com/android/systemui/animation/ViewRootSync.kt 0 → 100644 +75 −0 Original line number Original line Diff line number Diff line package com.android.systemui.animation import android.app.ActivityManager import android.view.SurfaceControl import android.view.View import android.view.ViewRootImpl /** A util class to synchronize 2 view roots. */ // TODO(b/200284684): Remove this class. object ViewRootSync { // TODO(b/217621394): Remove special handling for low-RAM devices after animation sync is fixed private val forceDisableSynchronization = ActivityManager.isLowRamDeviceStatic() /** * Synchronize the next draw between the view roots of [view] and [otherView], then run [then]. * * Note that in some cases, the synchronization might not be possible (e.g. WM consumed the * next transactions) or disabled (temporarily, on low ram devices). In this case, [then] will * be called without synchronizing. */ fun synchronizeNextDraw( view: View, otherView: View, then: () -> Unit ) { if (forceDisableSynchronization || !view.isAttachedToWindow || view.viewRootImpl == null || !otherView.isAttachedToWindow || otherView.viewRootImpl == null || view.viewRootImpl == otherView.viewRootImpl) { // No need to synchronize if either the touch surface or dialog view is not attached // to a window. then() return } // Consume the next frames of both view roots to make sure the ghost view is drawn at // exactly the same time as when the touch surface is made invisible. var remainingTransactions = 0 val mergedTransactions = SurfaceControl.Transaction() fun onTransaction(transaction: SurfaceControl.Transaction?) { remainingTransactions-- transaction?.let { mergedTransactions.merge(it) } if (remainingTransactions == 0) { mergedTransactions.apply() then() } } fun consumeNextDraw(viewRootImpl: ViewRootImpl) { if (viewRootImpl.consumeNextDraw(::onTransaction)) { remainingTransactions++ // Make sure we trigger a traversal. viewRootImpl.view.invalidate() } } consumeNextDraw(view.viewRootImpl) consumeNextDraw(otherView.viewRootImpl) if (remainingTransactions == 0) { then() } } /** * A Java-friendly API for [synchronizeNextDraw]. */ @JvmStatic fun synchronizeNextDraw(view: View, otherView: View, then: Runnable) { synchronizeNextDraw(view, otherView, then::run) } } No newline at end of file Loading
packages/SystemUI/animation/Android.bp +1 −1 Original line number Original line Diff line number Diff line Loading @@ -39,5 +39,5 @@ android_library { ], ], manifest: "AndroidManifest.xml", manifest: "AndroidManifest.xml", kotlincflags: ["-Xjvm-default=enable"], kotlincflags: ["-Xjvm-default=all"], } }
packages/SystemUI/animation/src/com/android/systemui/animation/ActivityLaunchAnimator.kt +55 −6 Original line number Original line Diff line number Diff line Loading @@ -48,9 +48,16 @@ private const val TAG = "ActivityLaunchAnimator" * nicely into the starting window. * nicely into the starting window. */ */ class ActivityLaunchAnimator( class ActivityLaunchAnimator( private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS) /** The animator used when animating a View into an app. */ private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS), /** The animator used when animating a Dialog into an app. */ // TODO(b/218989950): Remove this animator and instead set the duration of the dim fade out to // TIMINGS.contentBeforeFadeOutDuration. private val dialogToAppAnimator: LaunchAnimator = LaunchAnimator(DIALOG_TIMINGS, INTERPOLATORS) ) { ) { companion object { companion object { /** The timings when animating a View into an app. */ @JvmField @JvmField val TIMINGS = LaunchAnimator.Timings( val TIMINGS = LaunchAnimator.Timings( totalDuration = 500L, totalDuration = 500L, Loading @@ -60,6 +67,17 @@ class ActivityLaunchAnimator( contentAfterFadeInDuration = 183L contentAfterFadeInDuration = 183L ) ) /** * The timings when animating a Dialog into an app. We need to wait at least 200ms before * showing the app (which is under the dialog window) so that the dialog window dim is fully * faded out, to avoid flicker. */ val DIALOG_TIMINGS = TIMINGS.copy( contentBeforeFadeOutDuration = 200L, contentAfterFadeInDelay = 200L ) /** The interpolators when animating a View or a dialog into an app. */ val INTERPOLATORS = LaunchAnimator.Interpolators( val INTERPOLATORS = LaunchAnimator.Interpolators( positionInterpolator = Interpolators.EMPHASIZED, positionInterpolator = Interpolators.EMPHASIZED, positionXInterpolator = createPositionXInterpolator(), positionXInterpolator = createPositionXInterpolator(), Loading Loading @@ -297,11 +315,18 @@ class ActivityLaunchAnimator( } } } } /** * Whether this controller is controlling a dialog launch. This will be used to adapt the * timings, making sure we don't show the app until the dialog dim had the time to fade out. */ // TODO(b/218989950): Remove this. val isDialogLaunch: Boolean get() = false /** /** * The intent was started. If [willAnimate] is false, nothing else will happen and the * The intent was started. If [willAnimate] is false, nothing else will happen and the * animation will not be started. * animation will not be started. */ */ @JvmDefault fun onIntentStarted(willAnimate: Boolean) {} fun onIntentStarted(willAnimate: Boolean) {} /** /** Loading @@ -309,7 +334,6 @@ class ActivityLaunchAnimator( * this if the animation was already started, i.e. if [onLaunchAnimationStart] was called * this if the animation was already started, i.e. if [onLaunchAnimationStart] was called * before the cancellation. * before the cancellation. */ */ @JvmDefault fun onLaunchAnimationCancelled() {} fun onLaunchAnimationCancelled() {} } } Loading @@ -317,7 +341,9 @@ class ActivityLaunchAnimator( inner class Runner(private val controller: Controller) : IRemoteAnimationRunner.Stub() { inner class Runner(private val controller: Controller) : IRemoteAnimationRunner.Stub() { private val launchContainer = controller.launchContainer private val launchContainer = controller.launchContainer private val context = launchContainer.context private val context = launchContainer.context private val transactionApplier = SyncRtSurfaceTransactionApplier(launchContainer) private val transactionApplierView = controller.openingWindowSyncView ?: controller.launchContainer private val transactionApplier = SyncRtSurfaceTransactionApplier(transactionApplierView) private val matrix = Matrix() private val matrix = Matrix() private val invertMatrix = Matrix() private val invertMatrix = Matrix() Loading Loading @@ -405,6 +431,13 @@ class ActivityLaunchAnimator( val callback = this@ActivityLaunchAnimator.callback!! val callback = this@ActivityLaunchAnimator.callback!! val windowBackgroundColor = callback.getBackgroundColor(window.taskInfo) val windowBackgroundColor = callback.getBackgroundColor(window.taskInfo) // Make sure we use the modified timings when animating a dialog into an app. val launchAnimator = if (controller.isDialogLaunch) { dialogToAppAnimator } else { launchAnimator } // TODO(b/184121838): We should somehow get the top and bottom radius of the window // TODO(b/184121838): We should somehow get the top and bottom radius of the window // instead of recomputing isExpandingFullyAbove here. // instead of recomputing isExpandingFullyAbove here. val isExpandingFullyAbove = val isExpandingFullyAbove = Loading Loading @@ -440,19 +473,29 @@ class ActivityLaunchAnimator( progress: Float, progress: Float, linearProgress: Float linearProgress: Float ) { ) { // Apply the state to the window only if it is visible, i.e. when the expanding // view is *not* visible. if (!state.visible) { applyStateToWindow(window, state) applyStateToWindow(window, state) } navigationBar?.let { applyStateToNavigationBar(it, state, linearProgress) } navigationBar?.let { applyStateToNavigationBar(it, state, linearProgress) } listeners.forEach { it.onLaunchAnimationProgress(linearProgress) } listeners.forEach { it.onLaunchAnimationProgress(linearProgress) } delegate.onLaunchAnimationProgress(state, progress, linearProgress) delegate.onLaunchAnimationProgress(state, progress, linearProgress) } } } } // We draw a hole when the additional layer is fading out to reveal the opening window. animation = launchAnimator.startAnimation( animation = launchAnimator.startAnimation( controller, endState, windowBackgroundColor, drawHole = true) controller, endState, windowBackgroundColor, drawHole = true) } } private fun applyStateToWindow(window: RemoteAnimationTarget, state: LaunchAnimator.State) { private fun applyStateToWindow(window: RemoteAnimationTarget, state: LaunchAnimator.State) { if (transactionApplierView.viewRootImpl == null) { // If the view root we synchronize with was detached, don't apply any transaction // (as [SyncRtSurfaceTransactionApplier.scheduleApply] would otherwise throw). return } val screenBounds = window.screenSpaceBounds val screenBounds = window.screenSpaceBounds val centerX = (screenBounds.left + screenBounds.right) / 2f val centerX = (screenBounds.left + screenBounds.right) / 2f val centerY = (screenBounds.top + screenBounds.bottom) / 2f val centerY = (screenBounds.top + screenBounds.bottom) / 2f Loading Loading @@ -510,6 +553,12 @@ class ActivityLaunchAnimator( state: LaunchAnimator.State, state: LaunchAnimator.State, linearProgress: Float linearProgress: Float ) { ) { if (transactionApplierView.viewRootImpl == null) { // If the view root we synchronize with was detached, don't apply any transaction // (as [SyncRtSurfaceTransactionApplier.scheduleApply] would otherwise throw). return } val fadeInProgress = LaunchAnimator.getProgress(TIMINGS, linearProgress, val fadeInProgress = LaunchAnimator.getProgress(TIMINGS, linearProgress, ANIMATION_DELAY_NAV_FADE_IN, ANIMATION_DURATION_NAV_FADE_OUT) ANIMATION_DELAY_NAV_FADE_IN, ANIMATION_DURATION_NAV_FADE_OUT) Loading
packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt +122 −42 Original line number Original line Diff line number Diff line Loading @@ -19,7 +19,6 @@ package com.android.systemui.animation import android.animation.Animator import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.animation.ValueAnimator import android.app.ActivityManager import android.app.Dialog import android.app.Dialog import android.graphics.Color import android.graphics.Color import android.graphics.Rect import android.graphics.Rect Loading @@ -28,12 +27,12 @@ import android.service.dreams.IDreamManager import android.util.Log import android.util.Log import android.util.MathUtils import android.util.MathUtils import android.view.GhostView import android.view.GhostView import android.view.SurfaceControl import android.view.View import android.view.View import android.view.ViewGroup import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewRootImpl import android.view.WindowInsets import android.view.WindowManager import android.view.WindowManager import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS import android.widget.FrameLayout import android.widget.FrameLayout import kotlin.math.roundToInt import kotlin.math.roundToInt Loading @@ -42,12 +41,17 @@ private const val TAG = "DialogLaunchAnimator" /** /** * A class that allows dialogs to be started in a seamless way from a view that is transforming * A class that allows dialogs to be started in a seamless way from a view that is transforming * nicely into the starting dialog. * nicely into the starting dialog. * * This animator also allows to easily animate a dialog into an activity. * * @see showFromView * @see showFromDialog * @see createActivityLaunchController */ */ class DialogLaunchAnimator @JvmOverloads constructor( class DialogLaunchAnimator @JvmOverloads constructor( private val dreamManager: IDreamManager, private val dreamManager: IDreamManager, private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS), private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS), // TODO(b/217621394): Remove special handling for low-RAM devices after animation sync is fixed private val isForTesting: Boolean = false private var forceDisableSynchronization: Boolean = ActivityManager.isLowRamDeviceStatic() ) { ) { private companion object { private companion object { private val TIMINGS = ActivityLaunchAnimator.TIMINGS private val TIMINGS = ActivityLaunchAnimator.TIMINGS Loading Loading @@ -113,7 +117,7 @@ class DialogLaunchAnimator @JvmOverloads constructor( dialog = dialog, dialog = dialog, animateBackgroundBoundsChange, animateBackgroundBoundsChange, animatedParent, animatedParent, forceDisableSynchronization isForTesting ) ) openedDialogs.add(animatedDialog) openedDialogs.add(animatedDialog) Loading @@ -140,6 +144,100 @@ class DialogLaunchAnimator @JvmOverloads constructor( showFromView(dialog, view, animateBackgroundBoundsChange) showFromView(dialog, view, animateBackgroundBoundsChange) } } /** * 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. * * 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 createActivityLaunchController( view: View, cujType: Int? = null ): ActivityLaunchAnimator.Controller? { val animatedDialog = openedDialogs .firstOrNull { it.dialog.window.decorView.viewRootImpl == view.viewRootImpl } ?: return null // 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. animatedDialog.exitAnimationDisabled = true val dialog = animatedDialog.dialog // Don't animate if the dialog is not showing. if (!dialog.isShowing) { return null } val dialogContentWithBackground = animatedDialog.dialogContentWithBackground ?: return null val controller = ActivityLaunchAnimator.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 : ActivityLaunchAnimator.Controller by controller { override val isDialogLaunch = true override fun onIntentStarted(willAnimate: Boolean) { controller.onIntentStarted(willAnimate) if (!willAnimate) { dialog.dismiss() } } override fun onLaunchAnimationCancelled() { controller.onLaunchAnimationCancelled() enableDialogDismiss() dialog.dismiss() } override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { controller.onLaunchAnimationStart(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.touchSurface = animatedDialog.prepareForStackDismiss() // Remove the dim. dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) } override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { controller.onLaunchAnimationEnd(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 touch surface when * Ensure that all dialogs currently shown won't animate into their touch surface when * dismissed. * dismissed. Loading Loading @@ -358,6 +456,21 @@ private class AnimatedDialog( // Make sure the dialog is visible instantly and does not do any window animation. // Make sure the dialog is visible instantly and does not do any window animation. window.attributes.windowAnimations = R.style.Animation_LaunchAnimation window.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. window.attributes.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS 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 insets = windowInsets.getInsets(WindowInsets.Type.displayCutout()) view.setPadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsets.CONSUMED } // Start the animation once the background view is properly laid out. // Start the animation once the background view is properly laid out. dialogContentWithBackground.addOnLayoutChangeListener(object : View.OnLayoutChangeListener { dialogContentWithBackground.addOnLayoutChangeListener(object : View.OnLayoutChangeListener { override fun onLayoutChange( override fun onLayoutChange( Loading Loading @@ -421,45 +534,12 @@ private class AnimatedDialog( * (or inversely, removed from the UI when the touch surface is made visible). * (or inversely, removed from the UI when the touch surface is made visible). */ */ private fun synchronizeNextDraw(then: () -> Unit) { private fun synchronizeNextDraw(then: () -> Unit) { if (forceDisableSynchronization || if (forceDisableSynchronization) { !touchSurface.isAttachedToWindow || touchSurface.viewRootImpl == null || !decorView.isAttachedToWindow || decorView.viewRootImpl == null) { // No need to synchronize if either the touch surface or dialog view is not attached // to a window. then() then() return return } } // Consume the next frames of both view roots to make sure the ghost view is drawn at ViewRootSync.synchronizeNextDraw(touchSurface, decorView, then) // exactly the same time as when the touch surface is made invisible. var remainingTransactions = 0 val mergedTransactions = SurfaceControl.Transaction() fun onTransaction(transaction: SurfaceControl.Transaction?) { remainingTransactions-- transaction?.let { mergedTransactions.merge(it) } if (remainingTransactions == 0) { mergedTransactions.apply() then() } } fun consumeNextDraw(viewRootImpl: ViewRootImpl) { if (viewRootImpl.consumeNextDraw(::onTransaction)) { remainingTransactions++ // Make sure we trigger a traversal. viewRootImpl.view.invalidate() } } consumeNextDraw(touchSurface.viewRootImpl) consumeNextDraw(decorView.viewRootImpl) if (remainingTransactions == 0) { then() } } } private fun findFirstViewGroupWithBackground(view: View): ViewGroup? { private fun findFirstViewGroupWithBackground(view: View): ViewGroup? { Loading Loading @@ -523,7 +603,7 @@ private class AnimatedDialog( ) ) } } private fun onDialogDismissed() { fun onDialogDismissed() { if (Looper.myLooper() != Looper.getMainLooper()) { if (Looper.myLooper() != Looper.getMainLooper()) { dialog.context.mainExecutor.execute { onDialogDismissed() } dialog.context.mainExecutor.execute { onDialogDismissed() } return return Loading
packages/SystemUI/animation/src/com/android/systemui/animation/LaunchAnimator.kt +47 −7 Original line number Original line Diff line number Diff line Loading @@ -77,8 +77,8 @@ class LaunchAnimator( * This will be used to: * This will be used to: * - Get the associated [Context]. * - Get the associated [Context]. * - Compute whether we are expanding fully above the launch container. * - Compute whether we are expanding fully above the launch container. * - Apply surface transactions in sync with RenderThread when animating an activity * - Get to overlay to which we initially put the window background layer, until the * launch. * opening window is made visible (see [openingWindowSyncView]). * * * This container can be changed to force this [Controller] to animate the expanding view * This container can be changed to force this [Controller] to animate the expanding view * inside a different location, for instance to ensure correct layering during the * inside a different location, for instance to ensure correct layering during the Loading @@ -86,6 +86,18 @@ class LaunchAnimator( */ */ var launchContainer: ViewGroup var launchContainer: ViewGroup /** * The [View] with which the opening app window should be synchronized with once it starts * to be visible. * * We will also move the window background layer to this view's overlay once the opening * window is visible. * * If null, this will default to [launchContainer]. */ val openingWindowSyncView: View? get() = null /** /** * Return the [State] of the view that will be animated. We will animate from this state to * Return the [State] of the view that will be animated. We will animate from this state to * the final window state. * the final window state. Loading @@ -100,11 +112,9 @@ class LaunchAnimator( * needed for the animation. [isExpandingFullyAbove] will be true if the window is expanding * needed for the animation. [isExpandingFullyAbove] will be true if the window is expanding * fully above the [launchContainer]. * fully above the [launchContainer]. */ */ @JvmDefault fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {} fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {} /** The animation made progress and the expandable view [state] should be updated. */ /** The animation made progress and the expandable view [state] should be updated. */ @JvmDefault fun onLaunchAnimationProgress(state: State, progress: Float, linearProgress: Float) {} fun onLaunchAnimationProgress(state: State, progress: Float, linearProgress: Float) {} /** /** Loading @@ -112,7 +122,6 @@ class LaunchAnimator( * called previously. This is typically used to clean up the resources initialized when the * called previously. This is typically used to clean up the resources initialized when the * animation was started. * animation was started. */ */ @JvmDefault fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {} fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {} } } Loading Loading @@ -154,7 +163,7 @@ class LaunchAnimator( } } /** The timings (durations and delays) used by this animator. */ /** The timings (durations and delays) used by this animator. */ class Timings( data class Timings( /** The total duration of the animation. */ /** The total duration of the animation. */ val totalDuration: Long, val totalDuration: Long, Loading Loading @@ -257,8 +266,17 @@ class LaunchAnimator( animator.duration = timings.totalDuration animator.duration = timings.totalDuration animator.interpolator = LINEAR animator.interpolator = LINEAR // Whether we should move the [windowBackgroundLayer] into the overlay of // [Controller.openingWindowSyncView] once the opening app window starts to be visible. val openingWindowSyncView = controller.openingWindowSyncView val openingWindowSyncViewOverlay = openingWindowSyncView?.overlay val moveBackgroundLayerWhenAppIsVisible = openingWindowSyncView != null && openingWindowSyncView.viewRootImpl != controller.launchContainer.viewRootImpl val launchContainerOverlay = launchContainer.overlay val launchContainerOverlay = launchContainer.overlay var cancelled = false var cancelled = false var movedBackgroundLayer = false animator.addListener(object : AnimatorListenerAdapter() { animator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator?, isReverse: Boolean) { override fun onAnimationStart(animation: Animator?, isReverse: Boolean) { if (DEBUG) { if (DEBUG) { Loading @@ -278,6 +296,10 @@ class LaunchAnimator( } } controller.onLaunchAnimationEnd(isExpandingFullyAbove) controller.onLaunchAnimationEnd(isExpandingFullyAbove) launchContainerOverlay.remove(windowBackgroundLayer) launchContainerOverlay.remove(windowBackgroundLayer) if (moveBackgroundLayerWhenAppIsVisible) { openingWindowSyncViewOverlay?.remove(windowBackgroundLayer) } } } }) }) Loading Loading @@ -318,11 +340,29 @@ class LaunchAnimator( timings.contentBeforeFadeOutDuration timings.contentBeforeFadeOutDuration ) < 1 ) < 1 if (moveBackgroundLayerWhenAppIsVisible && !state.visible && !movedBackgroundLayer) { // The expanding view is not visible, so the opening app is visible. If this is the // first frame when it happens, trigger a one-off sync and move the background layer // in its new container. movedBackgroundLayer = true launchContainerOverlay.remove(windowBackgroundLayer) openingWindowSyncViewOverlay!!.add(windowBackgroundLayer) ViewRootSync.synchronizeNextDraw(launchContainer, openingWindowSyncView, then = {}) } val container = if (movedBackgroundLayer) { openingWindowSyncView!! } else { controller.launchContainer } applyStateToWindowBackgroundLayer( applyStateToWindowBackgroundLayer( windowBackgroundLayer, windowBackgroundLayer, state, state, linearProgress, linearProgress, launchContainer, container, drawHole drawHole ) ) controller.onLaunchAnimationProgress(state, progress, linearProgress) controller.onLaunchAnimationProgress(state, progress, linearProgress) Loading
packages/SystemUI/animation/src/com/android/systemui/animation/ViewRootSync.kt 0 → 100644 +75 −0 Original line number Original line Diff line number Diff line package com.android.systemui.animation import android.app.ActivityManager import android.view.SurfaceControl import android.view.View import android.view.ViewRootImpl /** A util class to synchronize 2 view roots. */ // TODO(b/200284684): Remove this class. object ViewRootSync { // TODO(b/217621394): Remove special handling for low-RAM devices after animation sync is fixed private val forceDisableSynchronization = ActivityManager.isLowRamDeviceStatic() /** * Synchronize the next draw between the view roots of [view] and [otherView], then run [then]. * * Note that in some cases, the synchronization might not be possible (e.g. WM consumed the * next transactions) or disabled (temporarily, on low ram devices). In this case, [then] will * be called without synchronizing. */ fun synchronizeNextDraw( view: View, otherView: View, then: () -> Unit ) { if (forceDisableSynchronization || !view.isAttachedToWindow || view.viewRootImpl == null || !otherView.isAttachedToWindow || otherView.viewRootImpl == null || view.viewRootImpl == otherView.viewRootImpl) { // No need to synchronize if either the touch surface or dialog view is not attached // to a window. then() return } // Consume the next frames of both view roots to make sure the ghost view is drawn at // exactly the same time as when the touch surface is made invisible. var remainingTransactions = 0 val mergedTransactions = SurfaceControl.Transaction() fun onTransaction(transaction: SurfaceControl.Transaction?) { remainingTransactions-- transaction?.let { mergedTransactions.merge(it) } if (remainingTransactions == 0) { mergedTransactions.apply() then() } } fun consumeNextDraw(viewRootImpl: ViewRootImpl) { if (viewRootImpl.consumeNextDraw(::onTransaction)) { remainingTransactions++ // Make sure we trigger a traversal. viewRootImpl.view.invalidate() } } consumeNextDraw(view.viewRootImpl) consumeNextDraw(otherView.viewRootImpl) if (remainingTransactions == 0) { then() } } /** * A Java-friendly API for [synchronizeNextDraw]. */ @JvmStatic fun synchronizeNextDraw(view: View, otherView: View, then: Runnable) { synchronizeNextDraw(view, otherView, then::run) } } No newline at end of file