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:
committed by
Android (Google) Code Review
commit
ef833d42a6
@@ -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 -->
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user