Merge changes from topics "expandable-activity", "expandable-controller", "expandable-dialog" into tm-qpr-dev

* changes:
  Extract ExpandableControllerImpl out of Expandable
  Add support for dialogs in Expandable (1/2)
  Introduce Expandable to launch activities from Composables (1/2)
This commit is contained in:
TreeHugger Robot
2022-10-13 11:24:46 +00:00
committed by Android (Google) Code Review
5 changed files with 1006 additions and 143 deletions

View File

@@ -16,7 +16,6 @@
--> -->
<resources> <resources>
<!-- DialogLaunchAnimator --> <!-- DialogLaunchAnimator -->
<item type="id" name="tag_launch_animation_running"/>
<item type="id" name="tag_dialog_background"/> <item type="id" name="tag_dialog_background"/>
<!-- ViewBoundsAnimator --> <!-- ViewBoundsAnimator -->

View File

@@ -29,12 +29,12 @@ import android.view.GhostView
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.WindowInsets
import android.view.WindowManager import android.view.WindowManager
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
import android.widget.FrameLayout import android.widget.FrameLayout
import com.android.internal.jank.InteractionJankMonitor import com.android.internal.jank.InteractionJankMonitor
import com.android.internal.jank.InteractionJankMonitor.Configuration
import com.android.internal.jank.InteractionJankMonitor.CujType import com.android.internal.jank.InteractionJankMonitor.CujType
import kotlin.math.roundToInt 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. * This animator also allows to easily animate a dialog into an activity.
* *
* @see show
* @see showFromView * @see showFromView
* @see showFromDialog * @see showFromDialog
* @see createActivityLaunchController * @see createActivityLaunchController
@@ -67,8 +68,81 @@ constructor(
ActivityLaunchAnimator.INTERPOLATORS.copy( ActivityLaunchAnimator.INTERPOLATORS.copy(
positionXInterpolator = ActivityLaunchAnimator.INTERPOLATORS.positionInterpolator positionXInterpolator = ActivityLaunchAnimator.INTERPOLATORS.positionInterpolator
) )
}
private val TAG_LAUNCH_ANIMATION_RUNNING = R.id.tag_launch_animation_running /**
* 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
/**
* 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, dialog: Dialog,
view: View, view: View,
cuj: DialogCuj? = null, 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()) { if (Looper.myLooper() != Looper.getMainLooper()) {
throw IllegalStateException( throw IllegalStateException(
@@ -109,9 +204,10 @@ constructor(
// intent is to launch a dialog from another dialog. // intent is to launch a dialog from another dialog.
val animatedParent = val animatedParent =
openedDialogs.firstOrNull { 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) { if (animatedParent == null && animateFrom !is LaunchableView) {
// Make sure the View we launch from implements LaunchableView to avoid visibility // 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. // Make sure we don't run the launch animation from the same source twice at the same time.
if (animateFrom.getTag(TAG_LAUNCH_ANIMATION_RUNNING) != null) { if (openedDialogs.any { it.controller.sourceIdentity == controller.sourceIdentity }) {
Log.e(TAG, "Not running dialog launch animation as there is already one running") Log.e(
TAG,
"Not running dialog launch animation from source as it is already expanded into a" +
" dialog"
)
dialog.show() dialog.show()
return return
} }
animateFrom.setTag(TAG_LAUNCH_ANIMATION_RUNNING, true)
val animatedDialog = val animatedDialog =
AnimatedDialog( AnimatedDialog(
launchAnimator, launchAnimator,
@@ -146,16 +244,99 @@ constructor(
animateBackgroundBoundsChange, animateBackgroundBoundsChange,
animatedParent, animatedParent,
isForTesting, isForTesting,
cuj cuj,
) )
openedDialogs.add(animatedDialog) openedDialogs.add(animatedDialog)
animatedDialog.start() 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 * Launch [dialog] from [another dialog][animateFrom] that was shown using [show]. This will
* will allow for dismissing the whole stack. * allow for dismissing the whole stack.
* *
* @see dismissStack * @see dismissStack
*/ */
@@ -181,32 +362,55 @@ constructor(
/** /**
* Create an [ActivityLaunchAnimator.Controller] that can be used to launch an activity from the * 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] * dialog that contains [View]. Note that the dialog must have been shown using this animator,
* and be currently showing, otherwise this will return null. * otherwise this method will return null.
* *
* The returned controller will take care of dismissing the dialog at the right time after the * 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 * 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. * 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. * @param view any view inside the dialog to animate.
*/ */
@JvmOverloads @JvmOverloads
fun createActivityLaunchController( fun createActivityLaunchController(
view: View, view: View,
cujType: Int? = null cujType: Int? = null,
): ActivityLaunchAnimator.Controller? { ): ActivityLaunchAnimator.Controller? {
val animatedDialog = val animatedDialog =
openedDialogs.firstOrNull { openedDialogs.firstOrNull {
it.dialog.window.decorView.viewRootImpl == view.viewRootImpl it.dialog.window.decorView.viewRootImpl == view.viewRootImpl
} }
?: return null ?: 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 // 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 // an app, so we disable the exit animation into the source because we will never want to
// want to run it anyways. // run it anyways.
animatedDialog.exitAnimationDisabled = true animatedDialog.exitAnimationDisabled = true
val dialog = animatedDialog.dialog val dialog = animatedDialog.dialog
@@ -252,7 +456,7 @@ constructor(
// If this dialog was shown from a cascade of other dialogs, make sure those ones // If this dialog was shown from a cascade of other dialogs, make sure those ones
// are dismissed too. // are dismissed too.
animatedDialog.touchSurface = animatedDialog.prepareForStackDismiss() animatedDialog.prepareForStackDismiss()
// Remove the dim. // Remove the dim.
dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) 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 * Ensure that all dialogs currently shown won't animate into their source when dismissed.
* dismissed.
* *
* This is a temporary API meant to be called right before we both dismiss a dialog and start an * 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 * activity, which currently does not look good if we animate the dialog into their source at
* at the same time as the activity starts. * the same time as the activity starts.
* *
* TODO(b/193634619): Remove this function and animate dialog into opening activity instead. * 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 * Dismiss [dialog]. If it was launched from another dialog using this animator, also dismiss
* the stack of dialogs, animating back to the original touchSurface. * the stack of dialogs and simply fade out [dialog].
*/ */
fun dismissStack(dialog: Dialog) { fun dismissStack(dialog: Dialog) {
openedDialogs openedDialogs.firstOrNull { it.dialog == dialog }?.prepareForStackDismiss()
.firstOrNull { it.dialog == dialog }
?.let { it.touchSurface = it.prepareForStackDismiss() }
dialog.dismiss() dialog.dismiss()
} }
@@ -337,8 +538,11 @@ private class AnimatedDialog(
private val callback: DialogLaunchAnimator.Callback, private val callback: DialogLaunchAnimator.Callback,
private val interactionJankMonitor: InteractionJankMonitor, 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 * 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 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 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 isDismissing = false
private var dismissRequested = false private var dismissRequested = false
var exitAnimationDisabled = false var exitAnimationDisabled = false
private var isTouchSurfaceGhostDrawn = false private var isSourceDrawnInDialog = false
private var isOriginalDialogViewLaidOut = false private var isOriginalDialogViewLaidOut = false
/** A layout listener to animate the dialog height change. */ /** A layout listener to animate the dialog height change. */
@@ -410,13 +615,19 @@ private class AnimatedDialog(
*/ */
private var decorViewLayoutListener: View.OnLayoutChangeListener? = null private var decorViewLayoutListener: View.OnLayoutChangeListener? = null
private var hasInstrumentedJank = false
fun start() { fun start() {
if (cuj != null) { if (cuj != null) {
val config = Configuration.Builder.withView(cuj.cujType, touchSurface) val config = controller.jankConfigurationBuilder(cuj.cujType)
if (cuj.tag != null) { if (config != null) {
config.setTag(cuj.tag) 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 // 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. // Show the dialog.
dialog.show() dialog.show()
addTouchSurfaceGhost() moveSourceDrawingToDialog()
} }
private fun addTouchSurfaceGhost() { private fun moveSourceDrawingToDialog() {
if (decorView.viewRootImpl == null) { if (decorView.viewRootImpl == null) {
// Make sure that we have access to the dialog view root to synchronize the creation of // Make sure that we have access to the dialog view root to move the drawing to the
// the ghost. // dialog overlay.
decorView.post(::addTouchSurfaceGhost) decorView.post(::moveSourceDrawingToDialog)
return return
} }
// Create a ghost of the touch surface (which will make the touch surface invisible) and add // Move the drawing of the source in the overlay of this dialog, then animate. We trigger a
// it to the host dialog. We trigger a one off synchronization to make sure that this is // one-off synchronization to make sure that this is done in sync between the two different
// done in sync between the two different windows. // windows.
synchronizeNextDraw( synchronizeNextDraw(
then = { then = {
isTouchSurfaceGhostDrawn = true isSourceDrawnInDialog = true
maybeStartLaunchAnimation() maybeStartLaunchAnimation()
} }
) )
GhostView.addGhost(touchSurface, decorView) controller.startDrawingInOverlayOf(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)
} }
/** /**
* Synchronize the next draw of the touch surface and dialog view roots so that they are * Synchronize the next draw of the source and dialog view roots so that they are performed at
* performed at the same time, in the same transaction. This is necessary to make sure that the * the same time, in the same transaction. This is necessary to make sure that the source is
* ghost of the touch surface is drawn at the same time as the touch surface is made invisible * drawn in the overlay at the same time as it is removed from its original position (or
* (or inversely, removed from the UI when the touch surface is made visible). * inversely, removed from the overlay when the source is moved back to its original position).
*/ */
private fun synchronizeNextDraw(then: () -> Unit) { private fun synchronizeNextDraw(then: () -> Unit) {
if (forceDisableSynchronization) { if (forceDisableSynchronization) {
// Don't synchronize when inside an automated test.
then() then()
return 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? { private fun findFirstViewGroupWithBackground(view: View): ViewGroup? {
@@ -681,7 +890,7 @@ private class AnimatedDialog(
} }
private fun maybeStartLaunchAnimation() { private fun maybeStartLaunchAnimation() {
if (!isTouchSurfaceGhostDrawn || !isOriginalDialogViewLaidOut) { if (!isSourceDrawnInDialog || !isOriginalDialogViewLaidOut) {
return return
} }
@@ -690,19 +899,7 @@ private class AnimatedDialog(
startAnimation( startAnimation(
isLaunching = true, 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 = { 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 isLaunching = false
// dismiss was called during the animation, dismiss again now to actually dismiss. // dismiss was called during the animation, dismiss again now to actually dismiss.
@@ -718,7 +915,10 @@ private class AnimatedDialog(
backgroundLayoutListener 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 * Hide the dialog into the source and call [onAnimationFinished] when the animation is done
* done (passing animationRan=true) or if it's skipped (passing animationRan=false) to actually * (passing animationRan=true) or if it's skipped (passing animationRan=false) to actually
* dismiss the dialog. * dismiss the dialog.
*/ */
private fun hideDialogIntoView(onAnimationFinished: (Boolean) -> Unit) { private fun hideDialogIntoView(onAnimationFinished: (Boolean) -> Unit) {
@@ -763,17 +963,9 @@ private class AnimatedDialog(
decorView.removeOnLayoutChangeListener(decorViewLayoutListener) decorView.removeOnLayoutChangeListener(decorViewLayoutListener)
} }
if (!shouldAnimateDialogIntoView()) { if (!shouldAnimateDialogIntoSource()) {
Log.i(TAG, "Skipping animation of dialog into the touch surface") Log.i(TAG, "Skipping animation of dialog into the source")
controller.onExitAnimationCancelled()
// 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
}
onAnimationFinished(false /* instantDismiss */) onAnimationFinished(false /* instantDismiss */)
onDialogDismissed(this@AnimatedDialog) onDialogDismissed(this@AnimatedDialog)
return return
@@ -786,10 +978,6 @@ private class AnimatedDialog(
dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
}, },
onLaunchAnimationEnd = { 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!! val dialogContentWithBackground = this.dialogContentWithBackground!!
dialogContentWithBackground.visibility = View.INVISIBLE 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 controller.stopDrawingInOverlay()
// done at the same time. synchronizeNextDraw {
synchronizeNextDraw( onAnimationFinished(true /* instantDismiss */)
then = { onDialogDismissed(this@AnimatedDialog)
onAnimationFinished(true /* instantDismiss */) }
onDialogDismissed(this@AnimatedDialog)
}
)
} }
) )
} }
@@ -816,27 +1001,34 @@ private class AnimatedDialog(
onLaunchAnimationStart: () -> Unit = {}, onLaunchAnimationStart: () -> Unit = {},
onLaunchAnimationEnd: () -> Unit = {} onLaunchAnimationEnd: () -> Unit = {}
) { ) {
// Create 2 ghost controllers to animate both the dialog and the touch surface in the // Create 2 controllers to animate both the dialog and the source.
// dialog. val startController =
val startView = if (isLaunching) touchSurface else dialogContentWithBackground!! if (isLaunching) {
val endView = if (isLaunching) dialogContentWithBackground!! else touchSurface controller.createLaunchController()
val startViewController = GhostedViewLaunchAnimatorController(startView) } else {
val endViewController = GhostedViewLaunchAnimatorController(endView) GhostedViewLaunchAnimatorController(dialogContentWithBackground!!)
startViewController.launchContainer = decorView }
endViewController.launchContainer = decorView val endController =
if (isLaunching) {
GhostedViewLaunchAnimatorController(dialogContentWithBackground!!)
} else {
controller.createExitController()
}
startController.launchContainer = decorView
endController.launchContainer = decorView
val endState = endViewController.createAnimatorState() val endState = endController.createAnimatorState()
val controller = val controller =
object : LaunchAnimator.Controller { object : LaunchAnimator.Controller {
override var launchContainer: ViewGroup override var launchContainer: ViewGroup
get() = startViewController.launchContainer get() = startController.launchContainer
set(value) { set(value) {
startViewController.launchContainer = value startController.launchContainer = value
endViewController.launchContainer = value endController.launchContainer = value
} }
override fun createAnimatorState(): LaunchAnimator.State { override fun createAnimatorState(): LaunchAnimator.State {
return startViewController.createAnimatorState() return startController.createAnimatorState()
} }
override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {
@@ -845,15 +1037,29 @@ private class AnimatedDialog(
// onLaunchAnimationStart on the controller (which will create its own ghost). // onLaunchAnimationStart on the controller (which will create its own ghost).
onLaunchAnimationStart() onLaunchAnimationStart()
startViewController.onLaunchAnimationStart(isExpandingFullyAbove) startController.onLaunchAnimationStart(isExpandingFullyAbove)
endViewController.onLaunchAnimationStart(isExpandingFullyAbove) endController.onLaunchAnimationStart(isExpandingFullyAbove)
} }
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
startViewController.onLaunchAnimationEnd(isExpandingFullyAbove) // onLaunchAnimationEnd is called by an Animator at the end of the animation,
endViewController.onLaunchAnimationEnd(isExpandingFullyAbove) // 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() onLaunchAnimationEnd()
}
} }
override fun onLaunchAnimationProgress( override fun onLaunchAnimationProgress(
@@ -861,11 +1067,11 @@ private class AnimatedDialog(
progress: Float, progress: Float,
linearProgress: 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. // The end view is visible only iff the starting view is not visible.
state.visible = !state.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 // If the dialog content is complex, its dimension might change during the
// launch animation. The animation end position might also 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 // 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 // dialog dimension or position will change in the early frames, so changing the
// end state shouldn't really be noticeable. // end state shouldn't really be noticeable.
endViewController.fillGhostedViewState(endState) if (endController is GhostedViewLaunchAnimatorController) {
endController.fillGhostedViewState(endState)
}
} }
} }
launchAnimator.startAnimation(controller, endState, originalDialogBackgroundColor) 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 // Don't animate if the dialog was previously hidden using hide() or if we disabled the exit
// animation. // animation.
if (exitAnimationDisabled || !dialog.isShowing) { 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 // 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()) { if (callback.isDreaming()) {
return false return false
} }
// The touch surface should be invisible by now, if it's not then something else changed its return controller.shouldAnimateExit()
// 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
} }
/** A layout listener to animate the change of bounds of the dialog background. */ /** 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) { if (parentAnimatedDialog == null) {
return touchSurface return
} }
parentAnimatedDialog.exitAnimationDisabled = true parentAnimatedDialog.exitAnimationDisabled = true
parentAnimatedDialog.dialog.hide() parentAnimatedDialog.dialog.hide()
val view = parentAnimatedDialog.prepareForStackDismiss() parentAnimatedDialog.prepareForStackDismiss()
parentAnimatedDialog.dialog.dismiss() 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
} }
} }

View File

@@ -30,8 +30,11 @@ android_library {
], ],
static_libs: [ static_libs: [
"SystemUIAnimationLib",
"androidx.compose.runtime_runtime", "androidx.compose.runtime_runtime",
"androidx.compose.material3_material3", "androidx.compose.material3_material3",
"androidx.savedstate_savedstate",
], ],
kotlincflags: ["-Xjvm-default=all"], kotlincflags: ["-Xjvm-default=all"],

View File

@@ -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
}

View File

@@ -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
}
}
}
}