Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1c2633f9 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge changes If0c48e56,Ib4541b47 into main

* changes:
  Clean-up ExpandableController
  Extract Expandable related functions to be used by ag/31718343
parents 4e16555d cad8f822
Loading
Loading
Loading
Loading
+57 −54
Original line number Diff line number Diff line
@@ -39,7 +39,6 @@ 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.movableContentOf
import androidx.compose.runtime.mutableStateOf
@@ -175,21 +174,7 @@ fun Expandable(
    val wrappedContent =
        remember(content) {
            movableContentOf { expandable: Expandable ->
                CompositionLocalProvider(LocalContentColor provides contentColor) {
                    // We make sure that the content itself (wrapped by the background) is at least
                    // 40.dp, which is the same as the M3 buttons. This applies even if onClick is
                    // null, to make it easier to write expandables that are sometimes clickable and
                    // sometimes not. There shouldn't be any Expandable smaller than 40dp because if
                    // the expandable is not clickable directly, then something in its content
                    // should be (and with a size >= 40dp).
                    val minSize = 40.dp
                    Box(
                        Modifier.defaultMinSize(minWidth = minSize, minHeight = minSize),
                        contentAlignment = Alignment.Center,
                    ) {
                        content(expandable)
                    }
                }
                WrappedContent(expandable, contentColor, content)
            }
        }

@@ -209,11 +194,7 @@ fun Expandable(

    // Make sure we don't read animatorState directly here to avoid recomposition every time the
    // state changes (i.e. every frame of the animation).
    val isAnimating by remember {
        derivedStateOf {
            controller.animatorState.value != null && controller.overlay.value != null
        }
    }
    val isAnimating = controller.isAnimating

    // If this expandable is expanded when it's being directly clicked on, let's ensure that it has
    // the minimum interactive size followed by all M3 components (48.dp).
@@ -237,58 +218,36 @@ fun Expandable(
            // animating.
            AnimatedContentInOverlay(
                color,
                controller.boundsInComposeViewRoot.value.size,
                controller.animatorState,
                controller.overlay.value
                controller.boundsInComposeViewRoot.size,
                controller.overlay
                    ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."),
                controller,
                wrappedContent,
                controller.composeViewRoot,
                { controller.currentComposeViewInOverlay.value = it },
                { controller.currentComposeViewInOverlay = it },
                controller.density,
            )
        }
        controller.isDialogShowing.value -> {
        controller.isDialogShowing -> {
            Box(
                modifier
                    .updateExpandableSize()
                    .then(minInteractiveSizeModifier)
                    .drawWithContent { /* Don't draw anything when the dialog is shown. */ }
                    .onGloballyPositioned {
                        controller.boundsInComposeViewRoot.value = it.boundsInRoot()
                    }
                    .onGloballyPositioned { controller.boundsInComposeViewRoot = it.boundsInRoot() }
            ) {
                wrappedContent(controller.expandable)
            }
        }
        else -> {
            val clickModifier =
                if (onClick != null) {
                    if (interactionSource != null) {
                        // If the caller provided an interaction source, then that means that they
                        // will draw the click indication themselves.
                        Modifier.clickable(interactionSource, indication = null) {
                            onClick(controller.expandable)
                        }
                    } else {
                        // If no interaction source is provided, we draw the default indication (a
                        // ripple) and make sure it's clipped by the expandable shape.
                        Modifier.clip(shape).clickable { onClick(controller.expandable) }
                    }
                } else {
                    Modifier
                }

            Box(
                modifier
                    .updateExpandableSize()
                    .then(minInteractiveSizeModifier)
                    .then(clickModifier)
                    .then(clickModifier(controller, onClick, interactionSource))
                    .background(color, shape)
                    .border(controller)
                    .onGloballyPositioned {
                        controller.boundsInComposeViewRoot.value = it.boundsInRoot()
                    }
                    .onGloballyPositioned { controller.boundsInComposeViewRoot = it.boundsInRoot() }
            ) {
                wrappedContent(controller.expandable)
            }
@@ -296,12 +255,55 @@ fun Expandable(
    }
}

@Composable
private fun WrappedContent(
    expandable: Expandable,
    contentColor: Color,
    content: @Composable (Expandable) -> Unit,
) {
    CompositionLocalProvider(LocalContentColor provides contentColor) {
        // We make sure that the content itself (wrapped by the background) is at least 40.dp, which
        // is the same as the M3 buttons. This applies even if onClick is null, to make it easier to
        // write expandables that are sometimes clickable and sometimes not. There shouldn't be any
        // Expandable smaller than 40dp because if the expandable is not clickable directly, then
        // something in its content should be (and with a size >= 40dp).
        val minSize = 40.dp
        Box(
            Modifier.defaultMinSize(minWidth = minSize, minHeight = minSize),
            contentAlignment = Alignment.Center,
        ) {
            content(expandable)
        }
    }
}

private fun clickModifier(
    controller: ExpandableControllerImpl,
    onClick: ((Expandable) -> Unit)?,
    interactionSource: MutableInteractionSource?,
): Modifier {
    if (onClick == null) {
        return Modifier
    }

    if (interactionSource != null) {
        // If the caller provided an interaction source, then that means that they will draw the
        // click indication themselves.
        return Modifier.clickable(interactionSource, indication = null) {
            onClick(controller.expandable)
        }
    }

    // If no interaction source is provided, we draw the default indication (a ripple) and make sure
    // it's clipped by the expandable shape.
    return Modifier.clip(controller.shape).clickable { onClick(controller.expandable) }
}

/** Draw [content] in [overlay] while respecting its screen position given by [animatorState]. */
@Composable
private fun AnimatedContentInOverlay(
    color: Color,
    sizeInOriginalLayout: Size,
    animatorState: State<TransitionAnimator.State?>,
    overlay: ViewGroupOverlay,
    controller: ExpandableControllerImpl,
    content: @Composable (Expandable) -> Unit,
@@ -324,7 +326,7 @@ private fun AnimatedContentInOverlay(
                    // so that its content is laid out exactly the same way.
                    .requiredSize(with(density) { sizeInOriginalLayout.toDpSize() })
                    .drawWithContent {
                        val animatorState = animatorState.value ?: return@drawWithContent
                        val animatorState = controller.animatorState ?: return@drawWithContent

                        // Scale the content with the background while keeping its aspect ratio.
                        val widthRatio =
@@ -348,7 +350,8 @@ private fun AnimatedContentInOverlay(
                    setContent {
                        Box(
                            Modifier.fillMaxSize().drawWithContent {
                                val animatorState = animatorState.value ?: return@drawWithContent
                                val animatorState =
                                    controller.animatorState ?: return@drawWithContent
                                if (!animatorState.visible) {
                                    return@drawWithContent
                                }
@@ -385,7 +388,7 @@ private fun AnimatedContentInOverlay(
        overlay.add(composeViewInOverlay)

        val startState =
            animatorState.value
            controller.animatorState
                ?: throw IllegalStateException(
                    "AnimatedContentInOverlay shouldn't be composed with null animatorState."
                )
+49 −51
Original line number Diff line number Diff line
@@ -25,10 +25,11 @@ import androidx.compose.foundation.BorderStroke
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.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
@@ -53,6 +54,9 @@ interface ExpandableController {
    /** The [Expandable] controlled by this controller. */
    val expandable: Expandable

    /** Whether this controller is currently animating a launch. */
    val isAnimating: Boolean

    /** Called when the [Expandable] stop being included in the composition. */
    fun onDispose()
}
@@ -73,24 +77,9 @@ fun rememberExpandableController(
    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<TransitionAnimator.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) }
    var isComposed by remember { mutableStateOf(true) }

    val controller =
        remember(
@@ -109,19 +98,14 @@ fun rememberExpandableController(
                borderStroke,
                composeViewRoot,
                density,
                animatorState,
                isDialogShowing,
                overlay,
                currentComposeViewInOverlay,
                boundsInComposeViewRoot,
                layoutDirection,
                isComposed,
                { isComposed },
            )
        }

    DisposableEffect(Unit) {
        onDispose {
            isComposed.value = false
            isComposed = false
            if (TransitionAnimator.returnAnimationsEnabled()) {
                controller.onDispose()
            }
@@ -138,14 +122,27 @@ internal class ExpandableControllerImpl(
    internal val borderStroke: BorderStroke?,
    internal val composeViewRoot: View,
    internal val density: Density,
    internal val animatorState: MutableState<TransitionAnimator.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>,
    private val isComposed: () -> Boolean,
) : ExpandableController {
    /** The current animation state, if we are currently animating a dialog or activity. */
    var animatorState by mutableStateOf<TransitionAnimator.State?>(null)
        private set

    /** Whether a dialog controlled by this ExpandableController is currently showing. */
    var isDialogShowing by mutableStateOf(false)
        private set

    /** The overlay in which we should animate the launch. */
    var overlay by mutableStateOf<ViewGroupOverlay?>(null)
        private set

    /** The current [ComposeView] being animated in the [overlay], if any. */
    var currentComposeViewInOverlay by mutableStateOf<View?>(null)

    /** The bounds in [composeViewRoot] of the expandable controlled by this controller. */
    var boundsInComposeViewRoot by mutableStateOf(Rect.Zero)

    /** The [ActivityTransitionAnimator.Controller] to be cleaned up [onDispose]. */
    private var activityControllerForDisposal: ActivityTransitionAnimator.Controller? = null

@@ -158,7 +155,7 @@ internal class ExpandableControllerImpl(
                returnCujType: Int?,
                isEphemeral: Boolean,
            ): ActivityTransitionAnimator.Controller? {
                if (!isComposed.value) {
                if (!isComposed()) {
                    return null
                }

@@ -174,7 +171,7 @@ internal class ExpandableControllerImpl(
            override fun dialogTransitionController(
                cuj: DialogCuj?
            ): DialogTransitionAnimator.Controller? {
                if (!isComposed.value) {
                if (!isComposed()) {
                    return null
                }

@@ -182,6 +179,8 @@ internal class ExpandableControllerImpl(
            }
        }

    override val isAnimating: Boolean by derivedStateOf { animatorState != null && overlay != null }

    override fun onDispose() {
        activityControllerForDisposal?.onDispose()
        activityControllerForDisposal = null
@@ -204,7 +203,7 @@ internal class ExpandableControllerImpl(
            override val isLaunching: Boolean = true

            override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) {
                animatorState.value = null
                animatorState = null
            }

            override fun onTransitionAnimationProgress(
@@ -214,7 +213,7 @@ internal class ExpandableControllerImpl(
            ) {
                // We copy state given that it's always the same object that is mutated by
                // ActivityTransitionAnimator.
                animatorState.value =
                animatorState =
                    TransitionAnimator.State(
                            state.top,
                            state.bottom,
@@ -227,13 +226,11 @@ internal class ExpandableControllerImpl(

                // Force measure and layout the ComposeView in the overlay whenever the animation
                // state changes.
                currentComposeViewInOverlay.value?.let {
                    measureAndLayoutComposeViewInOverlay(it, state)
                }
                currentComposeViewInOverlay?.let { measureAndLayoutComposeViewInOverlay(it, state) }
            }

            override fun createAnimatorState(): TransitionAnimator.State {
                val boundsInRoot = boundsInComposeViewRoot.value
                val boundsInRoot = boundsInComposeViewRoot
                val outline =
                    shape.createOutline(
                        Size(boundsInRoot.width, boundsInRoot.height),
@@ -285,7 +282,7 @@ internal class ExpandableControllerImpl(

            private fun rootLocationOnScreen(): Offset {
                composeViewRoot.getLocationOnScreen(rootLocationOnScreen)
                val boundsInRoot = boundsInComposeViewRoot.value
                val boundsInRoot = boundsInComposeViewRoot
                val x = rootLocationOnScreen[0] + boundsInRoot.left
                val y = rootLocationOnScreen[1] + boundsInRoot.top
                return Offset(x, y)
@@ -319,14 +316,14 @@ internal class ExpandableControllerImpl(

            override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) {
                delegate.onTransitionAnimationStart(isExpandingFullyAbove)
                overlay.value = transitionContainer.overlay as ViewGroupOverlay
                overlay = transitionContainer.overlay as ViewGroupOverlay
                cujType?.let { InteractionJankMonitor.getInstance().begin(composeViewRoot, it) }
            }

            override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) {
                cujType?.let { InteractionJankMonitor.getInstance().end(it) }
                delegate.onTransitionAnimationEnd(isExpandingFullyAbove)
                overlay.value = null
                overlay = null
            }
        }
    }
@@ -339,14 +336,14 @@ internal class ExpandableControllerImpl(

            override fun startDrawingInOverlayOf(viewGroup: ViewGroup) {
                val newOverlay = viewGroup.overlay as ViewGroupOverlay
                if (newOverlay != overlay.value) {
                    overlay.value = newOverlay
                if (newOverlay != overlay) {
                    overlay = newOverlay
                }
            }

            override fun stopDrawingInOverlay() {
                if (overlay.value != null) {
                    overlay.value = null
                if (overlay != null) {
                    overlay = null
                }
            }

@@ -357,7 +354,7 @@ internal class ExpandableControllerImpl(
                        delegate.onTransitionAnimationEnd(isExpandingFullyAbove)

                        // Make sure we don't draw this expandable when the dialog is showing.
                        isDialogShowing.value = true
                        isDialogShowing = true
                    }
                }
            }
@@ -367,16 +364,17 @@ internal class ExpandableControllerImpl(
                return object : TransitionAnimator.Controller by delegate {
                    override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) {
                        delegate.onTransitionAnimationEnd(isExpandingFullyAbove)
                        isDialogShowing.value = false
                        isDialogShowing = false
                    }
                }
            }

            override fun shouldAnimateExit(): Boolean =
                isComposed.value && composeViewRoot.isAttachedToWindow && composeViewRoot.isShown
            override fun shouldAnimateExit(): Boolean {
                return isComposed() && composeViewRoot.isAttachedToWindow && composeViewRoot.isShown
            }

            override fun onExitAnimationCancelled() {
                isDialogShowing.value = false
                isDialogShowing = false
            }

            override fun jankConfigurationBuilder(): InteractionJankMonitor.Configuration.Builder? {
+9 −4
Original line number Diff line number Diff line
@@ -42,13 +42,19 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
@Composable
fun Modifier.drawInOverlay(): Modifier {
    val containerState = remember { ContainerState() }
    FullScreenComposeViewInOverlay { Modifier.container(containerState) }
    return this.drawInContainer(containerState, enabled = { true })
}

@Composable
internal fun FullScreenComposeViewInOverlay(modifier: (ComposeView) -> Modifier = { Modifier }) {
    val context = LocalContext.current
    val localView = LocalView.current
    val compositionContext = rememberCompositionContext()
    val displayMetrics = context.resources.displayMetrics
    val displaySize = IntSize(displayMetrics.widthPixels, displayMetrics.heightPixels)

    DisposableEffect(containerState, context, localView, compositionContext, displaySize) {
    DisposableEffect(context, localView, compositionContext, displaySize) {
        val overlay = localView.rootView.overlay as ViewGroupOverlay
        val view =
            ComposeView(context).apply {
@@ -59,7 +65,8 @@ fun Modifier.drawInOverlay(): Modifier {
                setViewTreeViewModelStoreOwner(localView.findViewTreeViewModelStoreOwner())
                setViewTreeSavedStateRegistryOwner(localView.findViewTreeSavedStateRegistryOwner())

                setContent { Box(Modifier.fillMaxSize().container(containerState)) }
                val view = this
                setContent { Box(modifier(view).fillMaxSize()) }
            }

        overlay.add(view)
@@ -74,6 +81,4 @@ fun Modifier.drawInOverlay(): Modifier {

        onDispose { overlay.remove(view) }
    }

    return this.drawInContainer(containerState, enabled = { true })
}