From 90b0d3360900cfb7856998d7da4a1f6ebf1b88af Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Fri, 7 Oct 2022 14:32:36 +0200 Subject: [PATCH 1/3] Introduce Expandable to launch activities from Composables (1/2) This CL introduces an Expandable {} composable that can be used to easily animate an expandable element into an Activity. This Expandable will be extended in ag/20139844 to also support dialog launches. See b/230830644#comment11 for videos and ag/20139924 for an example. Bug: 230830644 Test: Manual Change-Id: I1ab9ee7052c36418b1c22155bd2281a84454ec16 Merged-In: I1ab9ee7052c36418b1c22155bd2281a84454ec16 --- packages/SystemUI/compose/core/Android.bp | 3 + .../systemui/compose/animation/Expandable.kt | 455 ++++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt diff --git a/packages/SystemUI/compose/core/Android.bp b/packages/SystemUI/compose/core/Android.bp index 4cfe39225a9b4..fbdb526d6b312 100644 --- a/packages/SystemUI/compose/core/Android.bp +++ b/packages/SystemUI/compose/core/Android.bp @@ -30,8 +30,11 @@ android_library { ], static_libs: [ + "SystemUIAnimationLib", + "androidx.compose.runtime_runtime", "androidx.compose.material3_material3", + "androidx.savedstate_savedstate", ], kotlincflags: ["-Xjvm-default=all"], diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt new file mode 100644 index 0000000000000..a98f36ac2a8b8 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt @@ -0,0 +1,455 @@ +/* + * 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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.setValue +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.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.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.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.lifecycle.ViewTreeViewModelStoreOwner +import androidx.savedstate.ViewTreeSavedStateRegistryOwner +import com.android.systemui.animation.ActivityLaunchAnimator +import com.android.systemui.animation.LaunchAnimator +import kotlin.math.min +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 + + // TODO(b/230830644): Add forDialog(): DialogLaunchAnimator.Controller +} + +/** + * 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, +) { + // 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 density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val composeViewRoot = LocalView.current + + val animatorState = remember { mutableStateOf(null) } + var overlay by remember { mutableStateOf(null) } + var currentComposeViewInOverlay by remember { mutableStateOf(null) } + var boundsInComposeViewRoot by remember { mutableStateOf(Rect.Zero) } + val thisExpandableSize by remember { derivedStateOf { boundsInComposeViewRoot.size } } + + // 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. + 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?.let { measureAndLayoutComposeViewInOverlay(it, state) } + } + + override fun createAnimatorState(): LaunchAnimator.State { + val boundsInRoot = boundsInComposeViewRoot + 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 + 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. */ + fun activityController(): ActivityLaunchAnimator.Controller { + val delegate = launchController() + return object : ActivityLaunchAnimator.Controller, LaunchAnimator.Controller by delegate { + override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationStart(isExpandingFullyAbove) + overlay = composeViewRoot.rootView.overlay as ViewGroupOverlay + } + + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationEnd(isExpandingFullyAbove) + overlay = null + } + } + } + + val controller = + object : ExpandableController { + override fun forActivity(): ActivityLaunchAnimator.Controller = activityController() + } + + // 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 { animatorState.value != null && overlay != 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(density) { boundsInComposeViewRoot.size.toDpSize() }) + ) + + // The content and its animated background in the overlay. We draw it only when we are + // animating. + AnimatedContentInOverlay( + color, + thisExpandableSize, + animatorState, + overlay + ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."), + controller, + wrappedContent, + composeViewRoot, + { currentComposeViewInOverlay = it }, + density, + ) + } + else -> { + Box( + modifier.clip(shape).background(color, shape).onGloballyPositioned { + boundsInComposeViewRoot = 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, + 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) + } + } +} + +private 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 +} From 6848267f36dc4cad4873f71ec531bf57d924789d Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Thu, 7 Apr 2022 16:26:37 +0200 Subject: [PATCH 2/3] Add support for dialogs in Expandable (1/2) This CL adds support for dialog animation in Expandable. It does so by refactoring DialogLaunchAnimator so that its implementation doesn't assume it is expanding a View anymore. For consistency with ActivityLaunchAnimator.Controller, I introduced a DialogLaunchAnimator.Controller to abstract the source of a dialog animation. This CL also slightly changes the behavior when a chain of dialogs is dismissed (using DialogLaunchAnimator.dismissStack()): the top-level dialog is not animated into the first source of the chain anymore. The current behavior actually led to sub-optimal animations where it is used (user switcher), so the new behavior from this CL is subjectively better. See b/230830644#comment11 for videos and ag/20139983 for an example. Bug: 230830644 Test: Manual Change-Id: I59cfc5e19d4de8e5d1fec962028d043cbd2c417b Merged-In: I59cfc5e19d4de8e5d1fec962028d043cbd2c417b --- .../SystemUI/animation/res/values/ids.xml | 1 - .../animation/DialogLaunchAnimator.kt | 476 ++++++++++++------ .../systemui/compose/animation/Expandable.kt | 79 ++- 3 files changed, 412 insertions(+), 144 deletions(-) diff --git a/packages/SystemUI/animation/res/values/ids.xml b/packages/SystemUI/animation/res/values/ids.xml index f7150ab548dde..2d82307aca76f 100644 --- a/packages/SystemUI/animation/res/values/ids.xml +++ b/packages/SystemUI/animation/res/values/ids.xml @@ -16,7 +16,6 @@ --> - diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt index 9656b8a99d413..23cee4d0972db 100644 --- a/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt @@ -29,12 +29,12 @@ import android.view.GhostView import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewRootImpl import android.view.WindowInsets import android.view.WindowManager import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS import android.widget.FrameLayout import com.android.internal.jank.InteractionJankMonitor -import com.android.internal.jank.InteractionJankMonitor.Configuration import com.android.internal.jank.InteractionJankMonitor.CujType import kotlin.math.roundToInt @@ -46,6 +46,7 @@ private const val TAG = "DialogLaunchAnimator" * * This animator also allows to easily animate a dialog into an activity. * + * @see show * @see showFromView * @see showFromDialog * @see createActivityLaunchController @@ -67,8 +68,81 @@ constructor( ActivityLaunchAnimator.INTERPOLATORS.copy( positionXInterpolator = ActivityLaunchAnimator.INTERPOLATORS.positionInterpolator ) + } - 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, view: View, cuj: DialogCuj? = null, - animateBackgroundBoundsChange: Boolean = false, + animateBackgroundBoundsChange: Boolean = false + ) { + show(dialog, createController(view), cuj, animateBackgroundBoundsChange) + } + + /** + * Show [dialog] by expanding it from a source controlled by [controller]. + * + * If [animateBackgroundBoundsChange] is true, then the background of the dialog will be + * animated when the dialog bounds change. + * + * Note: The background of [view] should be a (rounded) rectangle so that it can be properly + * animated. + * + * Caveats: When calling this function and [dialog] is not a fullscreen dialog, then it will be + * made fullscreen and 2 views will be inserted between the dialog DecorView and its children. + */ + fun show( + dialog: Dialog, + controller: Controller, + cuj: DialogCuj? = null, + animateBackgroundBoundsChange: Boolean = false ) { if (Looper.myLooper() != Looper.getMainLooper()) { throw IllegalStateException( @@ -109,9 +204,10 @@ constructor( // intent is to launch a dialog from another dialog. val animatedParent = openedDialogs.firstOrNull { - it.dialog.window.decorView.viewRootImpl == view.viewRootImpl + it.dialog.window.decorView.viewRootImpl == controller.viewRoot } - val animateFrom = animatedParent?.dialogContentWithBackground ?: view + val animateFrom = + animatedParent?.dialogContentWithBackground?.let { createController(it) } ?: controller if (animatedParent == null && animateFrom !is LaunchableView) { // Make sure the View we launch from implements LaunchableView to avoid visibility @@ -126,15 +222,17 @@ constructor( ) } - // Make sure we don't run the launch animation from the same view twice at the same time. - if (animateFrom.getTag(TAG_LAUNCH_ANIMATION_RUNNING) != null) { - Log.e(TAG, "Not running dialog launch animation as there is already one running") + // Make sure we don't run the launch animation from the same source twice at the same time. + if (openedDialogs.any { it.controller.sourceIdentity == controller.sourceIdentity }) { + Log.e( + TAG, + "Not running dialog launch animation from source as it is already expanded into a" + + " dialog" + ) dialog.show() return } - animateFrom.setTag(TAG_LAUNCH_ANIMATION_RUNNING, true) - val animatedDialog = AnimatedDialog( launchAnimator, @@ -146,16 +244,99 @@ constructor( animateBackgroundBoundsChange, animatedParent, isForTesting, - cuj + cuj, ) openedDialogs.add(animatedDialog) animatedDialog.start() } + /** Create a [Controller] that can animate [source] to & from a dialog. */ + private fun createController(source: View): Controller { + return object : Controller { + override val viewRoot: ViewRootImpl + get() = source.viewRootImpl + + override val sourceIdentity: Any = source + + override fun startDrawingInOverlayOf(viewGroup: ViewGroup) { + // Create a temporary ghost of the source (which will make it invisible) and add it + // to the host dialog. + GhostView.addGhost(source, viewGroup) + + // The ghost of the source was just created, so the source is currently invisible. + // We need to make sure that it stays invisible as long as the dialog is shown or + // animating. + (source as? LaunchableView)?.setShouldBlockVisibilityChanges(true) + } + + override fun stopDrawingInOverlay() { + // Note: here we should remove the ghost from the overlay, but in practice this is + // already done by the launch controllers created below. + + // Make sure we allow the source to change its visibility again. + (source as? LaunchableView)?.setShouldBlockVisibilityChanges(false) + source.visibility = View.VISIBLE + } + + override fun createLaunchController(): LaunchAnimator.Controller { + val delegate = GhostedViewLaunchAnimatorController(source) + return object : LaunchAnimator.Controller by delegate { + override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { + // Remove the temporary ghost added by [startDrawingInOverlayOf]. Another + // ghost (that ghosts only the source content, and not its background) will + // be added right after this by the delegate and will be animated. + GhostView.removeGhost(source) + delegate.onLaunchAnimationStart(isExpandingFullyAbove) + } + + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationEnd(isExpandingFullyAbove) + + // We hide the source when the dialog is showing. We will make this view + // visible again when dismissing the dialog. This does nothing if the source + // implements [LaunchableView], as it's already INVISIBLE in that case. + source.visibility = View.INVISIBLE + } + } + } + + override fun createExitController(): LaunchAnimator.Controller { + return GhostedViewLaunchAnimatorController(source) + } + + override fun shouldAnimateExit(): Boolean { + // The source should be invisible by now, if it's not then something else changed + // its visibility and we probably don't want to run the animation. + if (source.visibility != View.INVISIBLE) { + return false + } + + return source.isAttachedToWindow && ((source.parent as? View)?.isShown ?: true) + } + + override fun onExitAnimationCancelled() { + // Make sure we allow the source to change its visibility again. + (source as? LaunchableView)?.setShouldBlockVisibilityChanges(false) + + // If the view is invisible it's probably because of us, so we make it visible + // again. + if (source.visibility == View.INVISIBLE) { + source.visibility = View.VISIBLE + } + } + + override fun jankConfigurationBuilder( + cuj: Int + ): InteractionJankMonitor.Configuration.Builder? { + return InteractionJankMonitor.Configuration.Builder.withView(cuj, source) + } + } + } + /** - * Launch [dialog] from [another dialog][animateFrom] that was shown using [showFromView]. This - * will allow for dismissing the whole stack. + * Launch [dialog] from [another dialog][animateFrom] that was shown using [show]. This will + * allow for dismissing the whole stack. * * @see dismissStack */ @@ -181,32 +362,55 @@ constructor( /** * Create an [ActivityLaunchAnimator.Controller] that can be used to launch an activity from the - * dialog that contains [View]. Note that the dialog must have been show using [showFromView] - * and be currently showing, otherwise this will return null. + * dialog that contains [View]. Note that the dialog must have been shown using this animator, + * otherwise this method will return null. * * The returned controller will take care of dismissing the dialog at the right time after the * activity started, when the dialog to app animation is done (or when it is cancelled). If this * method returns null, then the dialog won't be dismissed. * - * Note: The background of [view] should be a (rounded) rectangle so that it can be properly - * animated. - * * @param view any view inside the dialog to animate. */ @JvmOverloads fun createActivityLaunchController( view: View, - cujType: Int? = null + cujType: Int? = null, ): ActivityLaunchAnimator.Controller? { val animatedDialog = openedDialogs.firstOrNull { it.dialog.window.decorView.viewRootImpl == view.viewRootImpl } ?: return null + return createActivityLaunchController(animatedDialog, cujType) + } + /** + * Create an [ActivityLaunchAnimator.Controller] that can be used to launch an activity from + * [dialog]. Note that the dialog must have been shown using this animator, otherwise this + * method will return null. + * + * The returned controller will take care of dismissing the dialog at the right time after the + * activity started, when the dialog to app animation is done (or when it is cancelled). If this + * method returns null, then the dialog won't be dismissed. + * + * @param dialog the dialog to animate. + */ + @JvmOverloads + fun createActivityLaunchController( + dialog: Dialog, + cujType: Int? = null, + ): ActivityLaunchAnimator.Controller? { + val animatedDialog = openedDialogs.firstOrNull { it.dialog == dialog } ?: return null + return createActivityLaunchController(animatedDialog, cujType) + } + + private fun createActivityLaunchController( + animatedDialog: AnimatedDialog, + cujType: Int? = null + ): ActivityLaunchAnimator.Controller? { // At this point, we know that the intent of the caller is to dismiss the dialog to show - // an app, so we disable the exit animation into the touch surface because we will never - // want to run it anyways. + // an app, so we disable the exit animation into the source because we will never want to + // run it anyways. animatedDialog.exitAnimationDisabled = true val dialog = animatedDialog.dialog @@ -252,7 +456,7 @@ constructor( // If this dialog was shown from a cascade of other dialogs, make sure those ones // are dismissed too. - animatedDialog.touchSurface = animatedDialog.prepareForStackDismiss() + animatedDialog.prepareForStackDismiss() // Remove the dim. dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) @@ -283,12 +487,11 @@ constructor( } /** - * Ensure that all dialogs currently shown won't animate into their touch surface when - * dismissed. + * Ensure that all dialogs currently shown won't animate into their source when dismissed. * * This is a temporary API meant to be called right before we both dismiss a dialog and start an - * activity, which currently does not look good if we animate the dialog into the touch surface - * at the same time as the activity starts. + * activity, which currently does not look good if we animate the dialog into their source at + * the same time as the activity starts. * * TODO(b/193634619): Remove this function and animate dialog into opening activity instead. */ @@ -297,13 +500,11 @@ constructor( } /** - * Dismiss [dialog]. If it was launched from another dialog using [showFromView], also dismiss - * the stack of dialogs, animating back to the original touchSurface. + * Dismiss [dialog]. If it was launched from another dialog using this animator, also dismiss + * the stack of dialogs and simply fade out [dialog]. */ fun dismissStack(dialog: Dialog) { - openedDialogs - .firstOrNull { it.dialog == dialog } - ?.let { it.touchSurface = it.prepareForStackDismiss() } + openedDialogs.firstOrNull { it.dialog == dialog }?.prepareForStackDismiss() dialog.dismiss() } @@ -337,8 +538,11 @@ private class AnimatedDialog( private val callback: DialogLaunchAnimator.Callback, private val interactionJankMonitor: InteractionJankMonitor, - /** The view that triggered the dialog after being tapped. */ - var touchSurface: View, + /** + * The controller of the source that triggered the dialog and that will animate into/from the + * dialog. + */ + val controller: DialogLaunchAnimator.Controller, /** * A callback that will be called with this [AnimatedDialog] after the dialog was dismissed and @@ -383,17 +587,18 @@ private class AnimatedDialog( private var originalDialogBackgroundColor = Color.BLACK /** - * Whether we are currently launching/showing the dialog by animating it from [touchSurface]. + * Whether we are currently launching/showing the dialog by animating it from its source + * controlled by [controller]. */ private var isLaunching = true - /** Whether we are currently dismissing/hiding the dialog by animating into [touchSurface]. */ + /** Whether we are currently dismissing/hiding the dialog by animating into its source. */ private var isDismissing = false private var dismissRequested = false var exitAnimationDisabled = false - private var isTouchSurfaceGhostDrawn = false + private var isSourceDrawnInDialog = false private var isOriginalDialogViewLaidOut = false /** A layout listener to animate the dialog height change. */ @@ -410,13 +615,19 @@ private class AnimatedDialog( */ private var decorViewLayoutListener: View.OnLayoutChangeListener? = null + private var hasInstrumentedJank = false + fun start() { if (cuj != null) { - val config = Configuration.Builder.withView(cuj.cujType, touchSurface) - if (cuj.tag != null) { - config.setTag(cuj.tag) + val config = controller.jankConfigurationBuilder(cuj.cujType) + if (config != null) { + if (cuj.tag != null) { + config.setTag(cuj.tag) + } + + interactionJankMonitor.begin(config) + hasInstrumentedJank = true } - interactionJankMonitor.begin(config) } // Create the dialog so that its onCreate() method is called, which usually sets the dialog @@ -618,47 +829,45 @@ private class AnimatedDialog( // Show the dialog. dialog.show() - addTouchSurfaceGhost() + moveSourceDrawingToDialog() } - private fun addTouchSurfaceGhost() { + private fun moveSourceDrawingToDialog() { if (decorView.viewRootImpl == null) { - // Make sure that we have access to the dialog view root to synchronize the creation of - // the ghost. - decorView.post(::addTouchSurfaceGhost) + // Make sure that we have access to the dialog view root to move the drawing to the + // dialog overlay. + decorView.post(::moveSourceDrawingToDialog) return } - // Create a ghost of the touch surface (which will make the touch surface invisible) and add - // it to the host dialog. We trigger a one off synchronization to make sure that this is - // done in sync between the two different windows. + // Move the drawing of the source in the overlay of this dialog, then animate. We trigger a + // one-off synchronization to make sure that this is done in sync between the two different + // windows. synchronizeNextDraw( then = { - isTouchSurfaceGhostDrawn = true + isSourceDrawnInDialog = true maybeStartLaunchAnimation() } ) - GhostView.addGhost(touchSurface, decorView) - - // The ghost of the touch surface was just created, so the touch surface is currently - // invisible. We need to make sure that it stays invisible as long as the dialog is shown or - // animating. - (touchSurface as? LaunchableView)?.setShouldBlockVisibilityChanges(true) + controller.startDrawingInOverlayOf(decorView) } /** - * Synchronize the next draw of the touch surface and dialog view roots so that they are - * performed at the same time, in the same transaction. This is necessary to make sure that the - * ghost of the touch surface is drawn at the same time as the touch surface is made invisible - * (or inversely, removed from the UI when the touch surface is made visible). + * Synchronize the next draw of the source and dialog view roots so that they are performed at + * the same time, in the same transaction. This is necessary to make sure that the source is + * drawn in the overlay at the same time as it is removed from its original position (or + * inversely, removed from the overlay when the source is moved back to its original position). */ private fun synchronizeNextDraw(then: () -> Unit) { if (forceDisableSynchronization) { + // Don't synchronize when inside an automated test. then() return } - ViewRootSync.synchronizeNextDraw(touchSurface, decorView, then) + ViewRootSync.synchronizeNextDraw(decorView, controller.viewRoot.view, then) + decorView.invalidate() + controller.viewRoot.view.invalidate() } private fun findFirstViewGroupWithBackground(view: View): ViewGroup? { @@ -681,7 +890,7 @@ private class AnimatedDialog( } private fun maybeStartLaunchAnimation() { - if (!isTouchSurfaceGhostDrawn || !isOriginalDialogViewLaidOut) { + if (!isSourceDrawnInDialog || !isOriginalDialogViewLaidOut) { return } @@ -690,19 +899,7 @@ private class AnimatedDialog( startAnimation( isLaunching = true, - onLaunchAnimationStart = { - // Remove the temporary ghost. Another ghost (that ghosts only the touch surface - // content, and not its background) will be added right after this and will be - // animated. - GhostView.removeGhost(touchSurface) - }, onLaunchAnimationEnd = { - touchSurface.setTag(R.id.tag_launch_animation_running, null) - - // We hide the touch surface when the dialog is showing. We will make this view - // visible again when dismissing the dialog. - touchSurface.visibility = View.INVISIBLE - isLaunching = false // dismiss was called during the animation, dismiss again now to actually dismiss. @@ -718,7 +915,10 @@ private class AnimatedDialog( backgroundLayoutListener ) } - cuj?.run { interactionJankMonitor.end(cujType) } + + if (hasInstrumentedJank) { + interactionJankMonitor.end(cuj!!.cujType) + } } ) } @@ -753,8 +953,8 @@ private class AnimatedDialog( } /** - * Hide the dialog into the touch surface and call [onAnimationFinished] when the animation is - * done (passing animationRan=true) or if it's skipped (passing animationRan=false) to actually + * Hide the dialog into the source and call [onAnimationFinished] when the animation is done + * (passing animationRan=true) or if it's skipped (passing animationRan=false) to actually * dismiss the dialog. */ private fun hideDialogIntoView(onAnimationFinished: (Boolean) -> Unit) { @@ -763,17 +963,9 @@ private class AnimatedDialog( decorView.removeOnLayoutChangeListener(decorViewLayoutListener) } - if (!shouldAnimateDialogIntoView()) { - Log.i(TAG, "Skipping animation of dialog into the touch surface") - - // Make sure we allow the touch surface to change its visibility again. - (touchSurface as? LaunchableView)?.setShouldBlockVisibilityChanges(false) - - // If the view is invisible it's probably because of us, so we make it visible again. - if (touchSurface.visibility == View.INVISIBLE) { - touchSurface.visibility = View.VISIBLE - } - + if (!shouldAnimateDialogIntoSource()) { + Log.i(TAG, "Skipping animation of dialog into the source") + controller.onExitAnimationCancelled() onAnimationFinished(false /* instantDismiss */) onDialogDismissed(this@AnimatedDialog) return @@ -786,10 +978,6 @@ private class AnimatedDialog( dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) }, onLaunchAnimationEnd = { - // Make sure we allow the touch surface to change its visibility again. - (touchSurface as? LaunchableView)?.setShouldBlockVisibilityChanges(false) - - touchSurface.visibility = View.VISIBLE val dialogContentWithBackground = this.dialogContentWithBackground!! dialogContentWithBackground.visibility = View.INVISIBLE @@ -799,14 +987,11 @@ private class AnimatedDialog( ) } - // Make sure that the removal of the ghost and making the touch surface visible is - // done at the same time. - synchronizeNextDraw( - then = { - onAnimationFinished(true /* instantDismiss */) - onDialogDismissed(this@AnimatedDialog) - } - ) + controller.stopDrawingInOverlay() + synchronizeNextDraw { + onAnimationFinished(true /* instantDismiss */) + onDialogDismissed(this@AnimatedDialog) + } } ) } @@ -816,27 +1001,34 @@ private class AnimatedDialog( onLaunchAnimationStart: () -> Unit = {}, onLaunchAnimationEnd: () -> Unit = {} ) { - // Create 2 ghost controllers to animate both the dialog and the touch surface in the - // dialog. - val startView = if (isLaunching) touchSurface else dialogContentWithBackground!! - val endView = if (isLaunching) dialogContentWithBackground!! else touchSurface - val startViewController = GhostedViewLaunchAnimatorController(startView) - val endViewController = GhostedViewLaunchAnimatorController(endView) - startViewController.launchContainer = decorView - endViewController.launchContainer = decorView + // Create 2 controllers to animate both the dialog and the source. + val startController = + if (isLaunching) { + controller.createLaunchController() + } else { + GhostedViewLaunchAnimatorController(dialogContentWithBackground!!) + } + val endController = + if (isLaunching) { + GhostedViewLaunchAnimatorController(dialogContentWithBackground!!) + } else { + controller.createExitController() + } + startController.launchContainer = decorView + endController.launchContainer = decorView - val endState = endViewController.createAnimatorState() + val endState = endController.createAnimatorState() val controller = object : LaunchAnimator.Controller { override var launchContainer: ViewGroup - get() = startViewController.launchContainer + get() = startController.launchContainer set(value) { - startViewController.launchContainer = value - endViewController.launchContainer = value + startController.launchContainer = value + endController.launchContainer = value } override fun createAnimatorState(): LaunchAnimator.State { - return startViewController.createAnimatorState() + return startController.createAnimatorState() } override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { @@ -845,15 +1037,29 @@ private class AnimatedDialog( // onLaunchAnimationStart on the controller (which will create its own ghost). onLaunchAnimationStart() - startViewController.onLaunchAnimationStart(isExpandingFullyAbove) - endViewController.onLaunchAnimationStart(isExpandingFullyAbove) + startController.onLaunchAnimationStart(isExpandingFullyAbove) + endController.onLaunchAnimationStart(isExpandingFullyAbove) } override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { - startViewController.onLaunchAnimationEnd(isExpandingFullyAbove) - endViewController.onLaunchAnimationEnd(isExpandingFullyAbove) + // onLaunchAnimationEnd is called by an Animator at the end of the animation, + // on a Choreographer animation tick. The following calls will move the animated + // content from the dialog overlay back to its original position, and this + // change must be reflected in the next frame given that we then sync the next + // frame of both the content and dialog ViewRoots. However, in case that content + // is rendered by Compose, whose compositions are also scheduled on a + // Choreographer frame, any state change made *right now* won't be reflected in + // the next frame given that a Choreographer frame can't schedule another and + // have it happen in the same frame. So we post the forwarded calls to + // [Controller.onLaunchAnimationEnd], leaving this Choreographer frame, ensuring + // that the move of the content back to its original window will be reflected in + // the next frame right after [onLaunchAnimationEnd] is called. + dialog.context.mainExecutor.execute { + startController.onLaunchAnimationEnd(isExpandingFullyAbove) + endController.onLaunchAnimationEnd(isExpandingFullyAbove) - onLaunchAnimationEnd() + onLaunchAnimationEnd() + } } override fun onLaunchAnimationProgress( @@ -861,11 +1067,11 @@ private class AnimatedDialog( progress: Float, linearProgress: Float ) { - startViewController.onLaunchAnimationProgress(state, progress, linearProgress) + startController.onLaunchAnimationProgress(state, progress, linearProgress) // The end view is visible only iff the starting view is not visible. state.visible = !state.visible - endViewController.onLaunchAnimationProgress(state, progress, linearProgress) + endController.onLaunchAnimationProgress(state, progress, linearProgress) // If the dialog content is complex, its dimension might change during the // launch animation. The animation end position might also change during the @@ -873,14 +1079,16 @@ private class AnimatedDialog( // Therefore we update the end state to the new position/size. Usually the // dialog dimension or position will change in the early frames, so changing the // end state shouldn't really be noticeable. - endViewController.fillGhostedViewState(endState) + if (endController is GhostedViewLaunchAnimatorController) { + endController.fillGhostedViewState(endState) + } } } launchAnimator.startAnimation(controller, endState, originalDialogBackgroundColor) } - private fun shouldAnimateDialogIntoView(): Boolean { + private fun shouldAnimateDialogIntoSource(): Boolean { // Don't animate if the dialog was previously hidden using hide() or if we disabled the exit // animation. if (exitAnimationDisabled || !dialog.isShowing) { @@ -888,24 +1096,12 @@ private class AnimatedDialog( } // If we are dreaming, the dialog was probably closed because of that so we don't animate - // into the touchSurface. + // into the source. if (callback.isDreaming()) { return false } - // The touch surface should be invisible by now, if it's not then something else changed its - // visibility and we probably don't want to run the animation. - if (touchSurface.visibility != View.INVISIBLE) { - return false - } - - // If the touch surface is not attached or one of its ancestors is not visible, then we - // don't run the animation either. - if (!touchSurface.isAttachedToWindow) { - return false - } - - return (touchSurface.parent as? View)?.isShown ?: true + return controller.shouldAnimateExit() } /** A layout listener to animate the change of bounds of the dialog background. */ @@ -988,17 +1184,13 @@ private class AnimatedDialog( } } - fun prepareForStackDismiss(): View { + fun prepareForStackDismiss() { if (parentAnimatedDialog == null) { - return touchSurface + return } parentAnimatedDialog.exitAnimationDisabled = true parentAnimatedDialog.dialog.hide() - val view = parentAnimatedDialog.prepareForStackDismiss() + parentAnimatedDialog.prepareForStackDismiss() parentAnimatedDialog.dialog.dismiss() - // Make the touch surface invisible, so we end up animating to it when we actually - // dismiss the stack - view.visibility = View.INVISIBLE - return view } } diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt index a98f36ac2a8b8..10a2c8db51d2a 100644 --- a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt @@ -20,6 +20,7 @@ import android.content.Context import android.view.View import android.view.ViewGroup import android.view.ViewGroupOverlay +import android.view.ViewRootImpl import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -62,7 +63,9 @@ import androidx.compose.ui.unit.Density import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.lifecycle.ViewTreeViewModelStoreOwner import androidx.savedstate.ViewTreeSavedStateRegistryOwner +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.min import kotlin.math.roundToInt @@ -72,7 +75,8 @@ interface ExpandableController { /** Create an [ActivityLaunchAnimator.Controller] to animate into an Activity. */ fun forActivity(): ActivityLaunchAnimator.Controller - // TODO(b/230830644): Add forDialog(): DialogLaunchAnimator.Controller + /** Create a [DialogLaunchAnimator.Controller] to animate into a Dialog. */ + fun forDialog(): DialogLaunchAnimator.Controller } /** @@ -123,6 +127,7 @@ fun Expandable( val animatorState = remember { mutableStateOf(null) } var overlay by remember { mutableStateOf(null) } + var isDialogShowing by remember { mutableStateOf(false) } var currentComposeViewInOverlay by remember { mutableStateOf(null) } var boundsInComposeViewRoot by remember { mutableStateOf(Rect.Zero) } val thisExpandableSize by remember { derivedStateOf { boundsInComposeViewRoot.size } } @@ -243,9 +248,74 @@ fun Expandable( } } + // Whether this composable is still composed. We only do the dialog exit animation if this is + // true. + var isComposed by remember { mutableStateOf(true) } + DisposableEffect(Unit) { onDispose { isComposed = false } } + + /** Create a [DialogLaunchAnimator.Controller] that can be used to animate dialogs. */ + val identity = remember { Object() } + fun dialogController(): DialogLaunchAnimator.Controller { + return object : DialogLaunchAnimator.Controller { + override val viewRoot: ViewRootImpl = composeViewRoot.viewRootImpl + override val sourceIdentity: Any = identity + + override fun startDrawingInOverlayOf(viewGroup: ViewGroup) { + val newOverlay = viewGroup.overlay as ViewGroupOverlay + if (newOverlay != overlay) { + overlay = newOverlay + } + } + + override fun stopDrawingInOverlay() { + if (overlay != null) { + overlay = 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 = true + } + } + } + + override fun createExitController(): LaunchAnimator.Controller { + val delegate = launchController() + return object : LaunchAnimator.Controller by delegate { + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + delegate.onLaunchAnimationEnd(isExpandingFullyAbove) + isDialogShowing = false + } + } + } + + override fun shouldAnimateExit(): Boolean = isComposed + + override fun onExitAnimationCancelled() { + isDialogShowing = false + } + + override fun jankConfigurationBuilder( + cuj: Int + ): InteractionJankMonitor.Configuration.Builder? { + // TODO(b/252723237): Add support for jank monitoring when animating from a + // Composable. + return null + } + } + } + val controller = object : ExpandableController { override fun forActivity(): ActivityLaunchAnimator.Controller = activityController() + + override fun forDialog(): DialogLaunchAnimator.Controller = dialogController() } // Make sure we don't read animatorState directly here to avoid recomposition every time the @@ -280,6 +350,13 @@ fun Expandable( density, ) } + isDialogShowing -> { + Box( + modifier + .drawWithContent { /* Don't draw anything when the dialog is shown. */} + .onGloballyPositioned { boundsInComposeViewRoot = it.boundsInRoot() } + ) { wrappedContent(controller) } + } else -> { Box( modifier.clip(shape).background(color, shape).onGloballyPositioned { From 227e6c78e93733fb0e15915f75b9930131945e50 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Tue, 11 Oct 2022 15:58:55 +0200 Subject: [PATCH 3/3] Extract ExpandableControllerImpl out of Expandable This CL extracts ExpandableControllerImpl and introduces an Expandable(ExpandableController) overload so that people can create a controller before the Expandable that it will control. This allows to trigger the animation from outside the Expandable. See [1] for an example. Outside of that new Expandable overload and rememberExpandableController(), this is a pure refactoring. [1] https://drive.google.com/file/d/1ghz2Zh9Syf4k2nHXddSrZxcl8Es6pA9m/view?usp=sharing Bug: 230830644 Test: Manual Change-Id: Iaa39a2c75e8a49770eaf5d8ce66d5ac562d9320c Merged-In: Iaa39a2c75e8a49770eaf5d8ce66d5ac562d9320c --- .../systemui/compose/animation/Expandable.kt | 287 ++++------------ .../compose/animation/ExpandableController.kt | 306 ++++++++++++++++++ 2 files changed, 365 insertions(+), 228 deletions(-) create mode 100644 packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt index 10a2c8db51d2a..8f9a4dae67bd8 100644 --- a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt @@ -20,7 +20,6 @@ import android.content.Context import android.view.View import android.view.ViewGroup import android.view.ViewGroupOverlay -import android.view.ViewRootImpl import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -35,20 +34,15 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext -import androidx.compose.runtime.setValue 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.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.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.scale @@ -56,28 +50,12 @@ 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.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Density import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.lifecycle.ViewTreeViewModelStoreOwner import androidx.savedstate.ViewTreeSavedStateRegistryOwner -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.min -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 expandable shape that can launch into an Activity or a Dialog. @@ -110,6 +88,48 @@ fun Expandable( 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 = @@ -121,207 +141,16 @@ fun Expandable( } } - val density = LocalDensity.current - val layoutDirection = LocalLayoutDirection.current - val composeViewRoot = LocalView.current - - val animatorState = remember { mutableStateOf(null) } - var overlay by remember { mutableStateOf(null) } - var isDialogShowing by remember { mutableStateOf(false) } - var currentComposeViewInOverlay by remember { mutableStateOf(null) } - var boundsInComposeViewRoot by remember { mutableStateOf(Rect.Zero) } - val thisExpandableSize by remember { derivedStateOf { boundsInComposeViewRoot.size } } - - // 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. - 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?.let { measureAndLayoutComposeViewInOverlay(it, state) } - } - - override fun createAnimatorState(): LaunchAnimator.State { - val boundsInRoot = boundsInComposeViewRoot - 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 - val x = rootLocationOnScreen[0] + boundsInRoot.left - val y = rootLocationOnScreen[1] + boundsInRoot.top - return Offset(x, y) - } - } + val thisExpandableSize by remember { + derivedStateOf { controller.boundsInComposeViewRoot.value.size } } - /** Create an [ActivityLaunchAnimator.Controller] that can be used to animate activities. */ - fun activityController(): ActivityLaunchAnimator.Controller { - val delegate = launchController() - return object : ActivityLaunchAnimator.Controller, LaunchAnimator.Controller by delegate { - override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { - delegate.onLaunchAnimationStart(isExpandingFullyAbove) - overlay = composeViewRoot.rootView.overlay as ViewGroupOverlay - } - - override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { - delegate.onLaunchAnimationEnd(isExpandingFullyAbove) - overlay = null - } - } - } - - // Whether this composable is still composed. We only do the dialog exit animation if this is - // true. - var isComposed by remember { mutableStateOf(true) } - DisposableEffect(Unit) { onDispose { isComposed = false } } - - /** Create a [DialogLaunchAnimator.Controller] that can be used to animate dialogs. */ - val identity = remember { Object() } - fun dialogController(): DialogLaunchAnimator.Controller { - return object : DialogLaunchAnimator.Controller { - override val viewRoot: ViewRootImpl = composeViewRoot.viewRootImpl - override val sourceIdentity: Any = identity - - override fun startDrawingInOverlayOf(viewGroup: ViewGroup) { - val newOverlay = viewGroup.overlay as ViewGroupOverlay - if (newOverlay != overlay) { - overlay = newOverlay - } - } - - override fun stopDrawingInOverlay() { - if (overlay != null) { - overlay = 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 = true - } - } - } - - override fun createExitController(): LaunchAnimator.Controller { - val delegate = launchController() - return object : LaunchAnimator.Controller by delegate { - override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { - delegate.onLaunchAnimationEnd(isExpandingFullyAbove) - isDialogShowing = false - } - } - } - - override fun shouldAnimateExit(): Boolean = isComposed - - override fun onExitAnimationCancelled() { - isDialogShowing = false - } - - override fun jankConfigurationBuilder( - cuj: Int - ): InteractionJankMonitor.Configuration.Builder? { - // TODO(b/252723237): Add support for jank monitoring when animating from a - // Composable. - return null - } - } - } - - val controller = - object : ExpandableController { - override fun forActivity(): ActivityLaunchAnimator.Controller = activityController() - - override fun forDialog(): DialogLaunchAnimator.Controller = dialogController() - } - // 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 { animatorState.value != null && overlay != null } + derivedStateOf { + controller.animatorState.value != null && controller.overlay.value != null + } } when { @@ -332,7 +161,7 @@ fun Expandable( Spacer( modifier .clip(shape) - .requiredSize(with(density) { boundsInComposeViewRoot.size.toDpSize() }) + .requiredSize(with(controller.density) { thisExpandableSize.toDpSize() }) ) // The content and its animated background in the overlay. We draw it only when we are @@ -340,27 +169,29 @@ fun Expandable( AnimatedContentInOverlay( color, thisExpandableSize, - animatorState, - overlay + controller.animatorState, + controller.overlay.value ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."), controller, wrappedContent, - composeViewRoot, - { currentComposeViewInOverlay = it }, - density, + controller.composeViewRoot, + { controller.currentComposeViewInOverlay.value = it }, + controller.density, ) } - isDialogShowing -> { + controller.isDialogShowing.value -> { Box( modifier .drawWithContent { /* Don't draw anything when the dialog is shown. */} - .onGloballyPositioned { boundsInComposeViewRoot = it.boundsInRoot() } + .onGloballyPositioned { + controller.boundsInComposeViewRoot.value = it.boundsInRoot() + } ) { wrappedContent(controller) } } else -> { Box( modifier.clip(shape).background(color, shape).onGloballyPositioned { - boundsInComposeViewRoot = it.boundsInRoot() + controller.boundsInComposeViewRoot.value = it.boundsInRoot() } ) { wrappedContent(controller) } } @@ -496,7 +327,7 @@ private fun AnimatedContentInOverlay( } } -private fun measureAndLayoutComposeViewInOverlay( +internal fun measureAndLayoutComposeViewInOverlay( view: View, state: LaunchAnimator.State, ) { diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt new file mode 100644 index 0000000000000..065c3149c2f57 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.compose.animation + +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroupOverlay +import android.view.ViewRootImpl +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import com.android.internal.jank.InteractionJankMonitor +import com.android.systemui.animation.ActivityLaunchAnimator +import com.android.systemui.animation.DialogLaunchAnimator +import com.android.systemui.animation.LaunchAnimator +import kotlin.math.roundToInt + +/** A controller that can control animated launches. */ +interface ExpandableController { + /** Create an [ActivityLaunchAnimator.Controller] to animate into an Activity. */ + fun forActivity(): ActivityLaunchAnimator.Controller + + /** Create a [DialogLaunchAnimator.Controller] to animate into a Dialog. */ + fun forDialog(): DialogLaunchAnimator.Controller +} + +/** + * Create an [ExpandableController] to control an [Expandable]. This is useful if you need to create + * the controller before the [Expandable], for instance to handle clicks outside of the Expandable + * that would still trigger a dialog/activity launch animation. + */ +@Composable +fun rememberExpandableController( + color: Color, + shape: Shape, + contentColor: Color = contentColorFor(color), +): ExpandableController { + val composeViewRoot = LocalView.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + + // The current animation state, if we are currently animating a dialog or activity. + val animatorState = remember { mutableStateOf(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(null) } + + // The current [ComposeView] being animated in the [overlay], if any. + val currentComposeViewInOverlay = remember { mutableStateOf(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, + internal val isDialogShowing: MutableState, + internal val overlay: MutableState, + internal val currentComposeViewInOverlay: MutableState, + internal val boundsInComposeViewRoot: MutableState, + private val layoutDirection: LayoutDirection, + private val isComposed: State, +) : 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 + } + } + } +}