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:
@@ -20,7 +20,6 @@ import android.content.Context
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewGroupOverlay
|
import android.view.ViewGroupOverlay
|
||||||
import android.view.ViewRootImpl
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -35,20 +34,15 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCompositionContext
|
import androidx.compose.runtime.rememberCompositionContext
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
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.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Outline
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.drawOutline
|
import androidx.compose.ui.graphics.drawOutline
|
||||||
import androidx.compose.ui.graphics.drawscope.scale
|
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.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.ComposeView
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.compose.ui.unit.Density
|
||||||
import androidx.lifecycle.ViewTreeLifecycleOwner
|
import androidx.lifecycle.ViewTreeLifecycleOwner
|
||||||
import androidx.lifecycle.ViewTreeViewModelStoreOwner
|
import androidx.lifecycle.ViewTreeViewModelStoreOwner
|
||||||
import androidx.savedstate.ViewTreeSavedStateRegistryOwner
|
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 com.android.systemui.animation.LaunchAnimator
|
||||||
import kotlin.math.min
|
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.
|
* Create an expandable shape that can launch into an Activity or a Dialog.
|
||||||
@@ -110,6 +88,48 @@ fun Expandable(
|
|||||||
contentColor: Color = contentColorFor(color),
|
contentColor: Color = contentColorFor(color),
|
||||||
content: @Composable (ExpandableController) -> Unit,
|
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
|
// TODO(b/230830644): Use movableContentOf to preserve the content state instead once the
|
||||||
// Compose libraries have been updated and include aosp/2163631.
|
// Compose libraries have been updated and include aosp/2163631.
|
||||||
val wrappedContent =
|
val wrappedContent =
|
||||||
@@ -121,207 +141,16 @@ fun Expandable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val thisExpandableSize by remember {
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
derivedStateOf { controller.boundsInComposeViewRoot.value.size }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
// Make sure we don't read animatorState directly here to avoid recomposition every time the
|
||||||
// state changes (i.e. every frame of the animation).
|
// state changes (i.e. every frame of the animation).
|
||||||
val isAnimating by remember {
|
val isAnimating by remember {
|
||||||
derivedStateOf { animatorState.value != null && overlay != null }
|
derivedStateOf {
|
||||||
|
controller.animatorState.value != null && controller.overlay.value != null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -332,7 +161,7 @@ fun Expandable(
|
|||||||
Spacer(
|
Spacer(
|
||||||
modifier
|
modifier
|
||||||
.clip(shape)
|
.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
|
// The content and its animated background in the overlay. We draw it only when we are
|
||||||
@@ -340,27 +169,29 @@ fun Expandable(
|
|||||||
AnimatedContentInOverlay(
|
AnimatedContentInOverlay(
|
||||||
color,
|
color,
|
||||||
thisExpandableSize,
|
thisExpandableSize,
|
||||||
animatorState,
|
controller.animatorState,
|
||||||
overlay
|
controller.overlay.value
|
||||||
?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."),
|
?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."),
|
||||||
controller,
|
controller,
|
||||||
wrappedContent,
|
wrappedContent,
|
||||||
composeViewRoot,
|
controller.composeViewRoot,
|
||||||
{ currentComposeViewInOverlay = it },
|
{ controller.currentComposeViewInOverlay.value = it },
|
||||||
density,
|
controller.density,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
isDialogShowing -> {
|
controller.isDialogShowing.value -> {
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.drawWithContent { /* Don't draw anything when the dialog is shown. */}
|
.drawWithContent { /* Don't draw anything when the dialog is shown. */}
|
||||||
.onGloballyPositioned { boundsInComposeViewRoot = it.boundsInRoot() }
|
.onGloballyPositioned {
|
||||||
|
controller.boundsInComposeViewRoot.value = it.boundsInRoot()
|
||||||
|
}
|
||||||
) { wrappedContent(controller) }
|
) { wrappedContent(controller) }
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Box(
|
Box(
|
||||||
modifier.clip(shape).background(color, shape).onGloballyPositioned {
|
modifier.clip(shape).background(color, shape).onGloballyPositioned {
|
||||||
boundsInComposeViewRoot = it.boundsInRoot()
|
controller.boundsInComposeViewRoot.value = it.boundsInRoot()
|
||||||
}
|
}
|
||||||
) { wrappedContent(controller) }
|
) { wrappedContent(controller) }
|
||||||
}
|
}
|
||||||
@@ -496,7 +327,7 @@ private fun AnimatedContentInOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun measureAndLayoutComposeViewInOverlay(
|
internal fun measureAndLayoutComposeViewInOverlay(
|
||||||
view: View,
|
view: View,
|
||||||
state: LaunchAnimator.State,
|
state: LaunchAnimator.State,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.android.systemui.compose.animation
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroupOverlay
|
||||||
|
import android.view.ViewRootImpl
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import com.android.internal.jank.InteractionJankMonitor
|
||||||
|
import com.android.systemui.animation.ActivityLaunchAnimator
|
||||||
|
import com.android.systemui.animation.DialogLaunchAnimator
|
||||||
|
import com.android.systemui.animation.LaunchAnimator
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** A controller that can control animated launches. */
|
||||||
|
interface ExpandableController {
|
||||||
|
/** Create an [ActivityLaunchAnimator.Controller] to animate into an Activity. */
|
||||||
|
fun forActivity(): ActivityLaunchAnimator.Controller
|
||||||
|
|
||||||
|
/** Create a [DialogLaunchAnimator.Controller] to animate into a Dialog. */
|
||||||
|
fun forDialog(): DialogLaunchAnimator.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an [ExpandableController] to control an [Expandable]. This is useful if you need to create
|
||||||
|
* the controller before the [Expandable], for instance to handle clicks outside of the Expandable
|
||||||
|
* that would still trigger a dialog/activity launch animation.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberExpandableController(
|
||||||
|
color: Color,
|
||||||
|
shape: Shape,
|
||||||
|
contentColor: Color = contentColorFor(color),
|
||||||
|
): ExpandableController {
|
||||||
|
val composeViewRoot = LocalView.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
|
|
||||||
|
// The current animation state, if we are currently animating a dialog or activity.
|
||||||
|
val animatorState = remember { mutableStateOf<LaunchAnimator.State?>(null) }
|
||||||
|
|
||||||
|
// Whether a dialog controlled by this ExpandableController is currently showing.
|
||||||
|
val isDialogShowing = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// The overlay in which we should animate the launch.
|
||||||
|
val overlay = remember { mutableStateOf<ViewGroupOverlay?>(null) }
|
||||||
|
|
||||||
|
// The current [ComposeView] being animated in the [overlay], if any.
|
||||||
|
val currentComposeViewInOverlay = remember { mutableStateOf<View?>(null) }
|
||||||
|
|
||||||
|
// The bounds in [composeViewRoot] of the expandable controlled by this controller.
|
||||||
|
val boundsInComposeViewRoot = remember { mutableStateOf(Rect.Zero) }
|
||||||
|
|
||||||
|
// Whether this composable is still composed. We only do the dialog exit animation if this is
|
||||||
|
// true.
|
||||||
|
val isComposed = remember { mutableStateOf(true) }
|
||||||
|
DisposableEffect(Unit) { onDispose { isComposed.value = false } }
|
||||||
|
|
||||||
|
return remember(color, contentColor, shape, composeViewRoot, density, layoutDirection) {
|
||||||
|
ExpandableControllerImpl(
|
||||||
|
color,
|
||||||
|
contentColor,
|
||||||
|
shape,
|
||||||
|
composeViewRoot,
|
||||||
|
density,
|
||||||
|
animatorState,
|
||||||
|
isDialogShowing,
|
||||||
|
overlay,
|
||||||
|
currentComposeViewInOverlay,
|
||||||
|
boundsInComposeViewRoot,
|
||||||
|
layoutDirection,
|
||||||
|
isComposed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ExpandableControllerImpl(
|
||||||
|
internal val color: Color,
|
||||||
|
internal val contentColor: Color,
|
||||||
|
internal val shape: Shape,
|
||||||
|
internal val composeViewRoot: View,
|
||||||
|
internal val density: Density,
|
||||||
|
internal val animatorState: MutableState<LaunchAnimator.State?>,
|
||||||
|
internal val isDialogShowing: MutableState<Boolean>,
|
||||||
|
internal val overlay: MutableState<ViewGroupOverlay?>,
|
||||||
|
internal val currentComposeViewInOverlay: MutableState<View?>,
|
||||||
|
internal val boundsInComposeViewRoot: MutableState<Rect>,
|
||||||
|
private val layoutDirection: LayoutDirection,
|
||||||
|
private val isComposed: State<Boolean>,
|
||||||
|
) : ExpandableController {
|
||||||
|
override fun forActivity(): ActivityLaunchAnimator.Controller {
|
||||||
|
return activityController()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun forDialog(): DialogLaunchAnimator.Controller {
|
||||||
|
return dialogController()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a [LaunchAnimator.Controller] that is going to be used to drive an activity or dialog
|
||||||
|
* animation. This controller will:
|
||||||
|
* 1. Compute the start/end animation state using [boundsInComposeViewRoot] and the location of
|
||||||
|
* composeViewRoot on the screen.
|
||||||
|
* 2. Update [animatorState] with the current animation state if we are animating, or null
|
||||||
|
* otherwise.
|
||||||
|
*/
|
||||||
|
private fun launchController(): LaunchAnimator.Controller {
|
||||||
|
return object : LaunchAnimator.Controller {
|
||||||
|
private val rootLocationOnScreen = intArrayOf(0, 0)
|
||||||
|
|
||||||
|
override var launchContainer: ViewGroup = composeViewRoot.rootView as ViewGroup
|
||||||
|
|
||||||
|
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
|
||||||
|
animatorState.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLaunchAnimationProgress(
|
||||||
|
state: LaunchAnimator.State,
|
||||||
|
progress: Float,
|
||||||
|
linearProgress: Float
|
||||||
|
) {
|
||||||
|
// We copy state given that it's always the same object that is mutated by
|
||||||
|
// ActivityLaunchAnimator.
|
||||||
|
animatorState.value =
|
||||||
|
LaunchAnimator.State(
|
||||||
|
state.top,
|
||||||
|
state.bottom,
|
||||||
|
state.left,
|
||||||
|
state.right,
|
||||||
|
state.topCornerRadius,
|
||||||
|
state.bottomCornerRadius,
|
||||||
|
)
|
||||||
|
.apply { visible = state.visible }
|
||||||
|
|
||||||
|
// Force measure and layout the ComposeView in the overlay whenever the animation
|
||||||
|
// state changes.
|
||||||
|
currentComposeViewInOverlay.value?.let {
|
||||||
|
measureAndLayoutComposeViewInOverlay(it, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createAnimatorState(): LaunchAnimator.State {
|
||||||
|
val boundsInRoot = boundsInComposeViewRoot.value
|
||||||
|
val outline =
|
||||||
|
shape.createOutline(
|
||||||
|
Size(boundsInRoot.width, boundsInRoot.height),
|
||||||
|
layoutDirection,
|
||||||
|
density,
|
||||||
|
)
|
||||||
|
|
||||||
|
val (topCornerRadius, bottomCornerRadius) =
|
||||||
|
when (outline) {
|
||||||
|
is Outline.Rectangle -> 0f to 0f
|
||||||
|
is Outline.Rounded -> {
|
||||||
|
val roundRect = outline.roundRect
|
||||||
|
|
||||||
|
// TODO(b/230830644): Add better support different corner radii.
|
||||||
|
val topCornerRadius =
|
||||||
|
maxOf(
|
||||||
|
roundRect.topLeftCornerRadius.x,
|
||||||
|
roundRect.topLeftCornerRadius.y,
|
||||||
|
roundRect.topRightCornerRadius.x,
|
||||||
|
roundRect.topRightCornerRadius.y,
|
||||||
|
)
|
||||||
|
val bottomCornerRadius =
|
||||||
|
maxOf(
|
||||||
|
roundRect.bottomLeftCornerRadius.x,
|
||||||
|
roundRect.bottomLeftCornerRadius.y,
|
||||||
|
roundRect.bottomRightCornerRadius.x,
|
||||||
|
roundRect.bottomRightCornerRadius.y,
|
||||||
|
)
|
||||||
|
|
||||||
|
topCornerRadius to bottomCornerRadius
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
error(
|
||||||
|
"ExpandableState only supports (rounded) rectangles at the " +
|
||||||
|
"moment."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rootLocation = rootLocationOnScreen()
|
||||||
|
return LaunchAnimator.State(
|
||||||
|
top = rootLocation.y.roundToInt(),
|
||||||
|
bottom = (rootLocation.y + boundsInRoot.height).roundToInt(),
|
||||||
|
left = rootLocation.x.roundToInt(),
|
||||||
|
right = (rootLocation.x + boundsInRoot.width).roundToInt(),
|
||||||
|
topCornerRadius = topCornerRadius,
|
||||||
|
bottomCornerRadius = bottomCornerRadius,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rootLocationOnScreen(): Offset {
|
||||||
|
composeViewRoot.getLocationOnScreen(rootLocationOnScreen)
|
||||||
|
val boundsInRoot = boundsInComposeViewRoot.value
|
||||||
|
val x = rootLocationOnScreen[0] + boundsInRoot.left
|
||||||
|
val y = rootLocationOnScreen[1] + boundsInRoot.top
|
||||||
|
return Offset(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an [ActivityLaunchAnimator.Controller] that can be used to animate activities. */
|
||||||
|
private fun activityController(): ActivityLaunchAnimator.Controller {
|
||||||
|
val delegate = launchController()
|
||||||
|
return object : ActivityLaunchAnimator.Controller, LaunchAnimator.Controller by delegate {
|
||||||
|
override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {
|
||||||
|
delegate.onLaunchAnimationStart(isExpandingFullyAbove)
|
||||||
|
overlay.value = composeViewRoot.rootView.overlay as ViewGroupOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
|
||||||
|
delegate.onLaunchAnimationEnd(isExpandingFullyAbove)
|
||||||
|
overlay.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dialogController(): DialogLaunchAnimator.Controller {
|
||||||
|
return object : DialogLaunchAnimator.Controller {
|
||||||
|
override val viewRoot: ViewRootImpl = composeViewRoot.viewRootImpl
|
||||||
|
override val sourceIdentity: Any = this@ExpandableControllerImpl
|
||||||
|
|
||||||
|
override fun startDrawingInOverlayOf(viewGroup: ViewGroup) {
|
||||||
|
val newOverlay = viewGroup.overlay as ViewGroupOverlay
|
||||||
|
if (newOverlay != overlay.value) {
|
||||||
|
overlay.value = newOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopDrawingInOverlay() {
|
||||||
|
if (overlay.value != null) {
|
||||||
|
overlay.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createLaunchController(): LaunchAnimator.Controller {
|
||||||
|
val delegate = launchController()
|
||||||
|
return object : LaunchAnimator.Controller by delegate {
|
||||||
|
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
|
||||||
|
delegate.onLaunchAnimationEnd(isExpandingFullyAbove)
|
||||||
|
|
||||||
|
// Make sure we don't draw this expandable when the dialog is showing.
|
||||||
|
isDialogShowing.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createExitController(): LaunchAnimator.Controller {
|
||||||
|
val delegate = launchController()
|
||||||
|
return object : LaunchAnimator.Controller by delegate {
|
||||||
|
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
|
||||||
|
delegate.onLaunchAnimationEnd(isExpandingFullyAbove)
|
||||||
|
isDialogShowing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldAnimateExit(): Boolean = isComposed.value
|
||||||
|
|
||||||
|
override fun onExitAnimationCancelled() {
|
||||||
|
isDialogShowing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun jankConfigurationBuilder(
|
||||||
|
cuj: Int
|
||||||
|
): InteractionJankMonitor.Configuration.Builder? {
|
||||||
|
// TODO(b/252723237): Add support for jank monitoring when animating from a
|
||||||
|
// Composable.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user