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
This commit is contained in:
Jordan Demeulenaere
2022-10-11 15:58:55 +02:00
parent 6848267f36
commit 227e6c78e9
2 changed files with 365 additions and 228 deletions

View File

@@ -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<LaunchAnimator.State?>(null) }
var overlay by remember { mutableStateOf<ViewGroupOverlay?>(null) }
var isDialogShowing by remember { mutableStateOf(false) }
var currentComposeViewInOverlay by remember { mutableStateOf<View?>(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,
) {

View File

@@ -0,0 +1,306 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.compose.animation
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroupOverlay
import android.view.ViewRootImpl
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import com.android.internal.jank.InteractionJankMonitor
import com.android.systemui.animation.ActivityLaunchAnimator
import com.android.systemui.animation.DialogLaunchAnimator
import com.android.systemui.animation.LaunchAnimator
import kotlin.math.roundToInt
/** A controller that can control animated launches. */
interface ExpandableController {
/** Create an [ActivityLaunchAnimator.Controller] to animate into an Activity. */
fun forActivity(): ActivityLaunchAnimator.Controller
/** Create a [DialogLaunchAnimator.Controller] to animate into a Dialog. */
fun forDialog(): DialogLaunchAnimator.Controller
}
/**
* Create an [ExpandableController] to control an [Expandable]. This is useful if you need to create
* the controller before the [Expandable], for instance to handle clicks outside of the Expandable
* that would still trigger a dialog/activity launch animation.
*/
@Composable
fun rememberExpandableController(
color: Color,
shape: Shape,
contentColor: Color = contentColorFor(color),
): ExpandableController {
val composeViewRoot = LocalView.current
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
// The current animation state, if we are currently animating a dialog or activity.
val animatorState = remember { mutableStateOf<LaunchAnimator.State?>(null) }
// Whether a dialog controlled by this ExpandableController is currently showing.
val isDialogShowing = remember { mutableStateOf(false) }
// The overlay in which we should animate the launch.
val overlay = remember { mutableStateOf<ViewGroupOverlay?>(null) }
// The current [ComposeView] being animated in the [overlay], if any.
val currentComposeViewInOverlay = remember { mutableStateOf<View?>(null) }
// The bounds in [composeViewRoot] of the expandable controlled by this controller.
val boundsInComposeViewRoot = remember { mutableStateOf(Rect.Zero) }
// Whether this composable is still composed. We only do the dialog exit animation if this is
// true.
val isComposed = remember { mutableStateOf(true) }
DisposableEffect(Unit) { onDispose { isComposed.value = false } }
return remember(color, contentColor, shape, composeViewRoot, density, layoutDirection) {
ExpandableControllerImpl(
color,
contentColor,
shape,
composeViewRoot,
density,
animatorState,
isDialogShowing,
overlay,
currentComposeViewInOverlay,
boundsInComposeViewRoot,
layoutDirection,
isComposed,
)
}
}
internal class ExpandableControllerImpl(
internal val color: Color,
internal val contentColor: Color,
internal val shape: Shape,
internal val composeViewRoot: View,
internal val density: Density,
internal val animatorState: MutableState<LaunchAnimator.State?>,
internal val isDialogShowing: MutableState<Boolean>,
internal val overlay: MutableState<ViewGroupOverlay?>,
internal val currentComposeViewInOverlay: MutableState<View?>,
internal val boundsInComposeViewRoot: MutableState<Rect>,
private val layoutDirection: LayoutDirection,
private val isComposed: State<Boolean>,
) : ExpandableController {
override fun forActivity(): ActivityLaunchAnimator.Controller {
return activityController()
}
override fun forDialog(): DialogLaunchAnimator.Controller {
return dialogController()
}
/**
* Create a [LaunchAnimator.Controller] that is going to be used to drive an activity or dialog
* animation. This controller will:
* 1. Compute the start/end animation state using [boundsInComposeViewRoot] and the location of
* composeViewRoot on the screen.
* 2. Update [animatorState] with the current animation state if we are animating, or null
* otherwise.
*/
private fun launchController(): LaunchAnimator.Controller {
return object : LaunchAnimator.Controller {
private val rootLocationOnScreen = intArrayOf(0, 0)
override var launchContainer: ViewGroup = composeViewRoot.rootView as ViewGroup
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
animatorState.value = null
}
override fun onLaunchAnimationProgress(
state: LaunchAnimator.State,
progress: Float,
linearProgress: Float
) {
// We copy state given that it's always the same object that is mutated by
// ActivityLaunchAnimator.
animatorState.value =
LaunchAnimator.State(
state.top,
state.bottom,
state.left,
state.right,
state.topCornerRadius,
state.bottomCornerRadius,
)
.apply { visible = state.visible }
// Force measure and layout the ComposeView in the overlay whenever the animation
// state changes.
currentComposeViewInOverlay.value?.let {
measureAndLayoutComposeViewInOverlay(it, state)
}
}
override fun createAnimatorState(): LaunchAnimator.State {
val boundsInRoot = boundsInComposeViewRoot.value
val outline =
shape.createOutline(
Size(boundsInRoot.width, boundsInRoot.height),
layoutDirection,
density,
)
val (topCornerRadius, bottomCornerRadius) =
when (outline) {
is Outline.Rectangle -> 0f to 0f
is Outline.Rounded -> {
val roundRect = outline.roundRect
// TODO(b/230830644): Add better support different corner radii.
val topCornerRadius =
maxOf(
roundRect.topLeftCornerRadius.x,
roundRect.topLeftCornerRadius.y,
roundRect.topRightCornerRadius.x,
roundRect.topRightCornerRadius.y,
)
val bottomCornerRadius =
maxOf(
roundRect.bottomLeftCornerRadius.x,
roundRect.bottomLeftCornerRadius.y,
roundRect.bottomRightCornerRadius.x,
roundRect.bottomRightCornerRadius.y,
)
topCornerRadius to bottomCornerRadius
}
else ->
error(
"ExpandableState only supports (rounded) rectangles at the " +
"moment."
)
}
val rootLocation = rootLocationOnScreen()
return LaunchAnimator.State(
top = rootLocation.y.roundToInt(),
bottom = (rootLocation.y + boundsInRoot.height).roundToInt(),
left = rootLocation.x.roundToInt(),
right = (rootLocation.x + boundsInRoot.width).roundToInt(),
topCornerRadius = topCornerRadius,
bottomCornerRadius = bottomCornerRadius,
)
}
private fun rootLocationOnScreen(): Offset {
composeViewRoot.getLocationOnScreen(rootLocationOnScreen)
val boundsInRoot = boundsInComposeViewRoot.value
val x = rootLocationOnScreen[0] + boundsInRoot.left
val y = rootLocationOnScreen[1] + boundsInRoot.top
return Offset(x, y)
}
}
}
/** Create an [ActivityLaunchAnimator.Controller] that can be used to animate activities. */
private fun activityController(): ActivityLaunchAnimator.Controller {
val delegate = launchController()
return object : ActivityLaunchAnimator.Controller, LaunchAnimator.Controller by delegate {
override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {
delegate.onLaunchAnimationStart(isExpandingFullyAbove)
overlay.value = composeViewRoot.rootView.overlay as ViewGroupOverlay
}
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
delegate.onLaunchAnimationEnd(isExpandingFullyAbove)
overlay.value = null
}
}
}
private fun dialogController(): DialogLaunchAnimator.Controller {
return object : DialogLaunchAnimator.Controller {
override val viewRoot: ViewRootImpl = composeViewRoot.viewRootImpl
override val sourceIdentity: Any = this@ExpandableControllerImpl
override fun startDrawingInOverlayOf(viewGroup: ViewGroup) {
val newOverlay = viewGroup.overlay as ViewGroupOverlay
if (newOverlay != overlay.value) {
overlay.value = newOverlay
}
}
override fun stopDrawingInOverlay() {
if (overlay.value != null) {
overlay.value = null
}
}
override fun createLaunchController(): LaunchAnimator.Controller {
val delegate = launchController()
return object : LaunchAnimator.Controller by delegate {
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
delegate.onLaunchAnimationEnd(isExpandingFullyAbove)
// Make sure we don't draw this expandable when the dialog is showing.
isDialogShowing.value = true
}
}
}
override fun createExitController(): LaunchAnimator.Controller {
val delegate = launchController()
return object : LaunchAnimator.Controller by delegate {
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
delegate.onLaunchAnimationEnd(isExpandingFullyAbove)
isDialogShowing.value = false
}
}
}
override fun shouldAnimateExit(): Boolean = isComposed.value
override fun onExitAnimationCancelled() {
isDialogShowing.value = false
}
override fun jankConfigurationBuilder(
cuj: Int
): InteractionJankMonitor.Configuration.Builder? {
// TODO(b/252723237): Add support for jank monitoring when animating from a
// Composable.
return null
}
}
}
}