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

Commit 60742519 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Add support for user actions in overlays

This CL adds new user actions that can be used to animate overlays in &
out using swipe gestures:

 - UserActionResult.ShowOverlay
 - UserActionResult.HideOverlay
 - UserActionResult.ReplaceByOverlay

We now use the overlay with the highest zIndex (if any) when fetching
the current user actions.

Bug: 353679003
Test: atest DraggableHandlerTest
Flag: com.android.systemui.scene_container

Change-Id: Ibaa2dfc9a172cbc75cce9afb095be836a64f0f0c
parent 7167a27a
Loading
Loading
Loading
Loading
+169 −15
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.util.fastCoerceIn
import com.android.compose.animation.scene.content.Content
import com.android.compose.animation.scene.content.Overlay
import com.android.compose.animation.scene.content.Scene
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.content.state.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified
@@ -165,7 +166,7 @@ internal class DraggableHandlerImpl(
        }

        val swipes = computeSwipes(startedPosition, pointersDown)
        val fromContent = layoutImpl.scene(layoutImpl.state.currentScene)
        val fromContent = layoutImpl.contentForUserActions()
        val result =
            swipes.findUserActionResult(fromContent, overSlop, updateSwipesResults = true)
                // As we were unable to locate a valid target scene, the initial SwipeAnimation
@@ -199,21 +200,66 @@ internal class DraggableHandlerImpl(
                else -> error("Unknown result $result ($upOrLeftResult $downOrRightResult)")
            }

        fun <T : Content> swipeAnimation(fromContent: T, toContent: T): SwipeAnimation<T> {
            return SwipeAnimation(
                layoutImpl = layoutImpl,
                fromContent = fromContent,
                toContent = toContent,
                userActionDistanceScope = layoutImpl.userActionDistanceScope,
                orientation = orientation,
                isUpOrLeft = isUpOrLeft,
                requiresFullDistanceSwipe = result.requiresFullDistanceSwipe,
            )
        }

        val layoutState = layoutImpl.state
        return when (result) {
            is UserActionResult.ChangeScene -> {
                val fromScene = layoutImpl.scene(layoutState.currentScene)
                val toScene = layoutImpl.scene(result.toScene)
                ChangeCurrentSceneSwipeTransition(
                        layoutState = layoutState,
                        swipeAnimation =
                            SwipeAnimation(
                                layoutImpl = layoutImpl,
                                fromContent = layoutImpl.scene(layoutState.currentScene),
                                toContent = layoutImpl.scene(result.toScene),
                                userActionDistanceScope = layoutImpl.userActionDistanceScope,
                                orientation = orientation,
                                isUpOrLeft = isUpOrLeft,
                                requiresFullDistanceSwipe = result.requiresFullDistanceSwipe,
                            ),
                            swipeAnimation(fromContent = fromScene, toContent = toScene),
                        key = result.transitionKey,
                        replacedTransition = null,
                    )
                    .swipeAnimation
            }
            is UserActionResult.ShowOverlay -> {
                val fromScene = layoutImpl.scene(layoutState.currentScene)
                val overlay = layoutImpl.overlay(result.overlay)
                ShowOrHideOverlaySwipeTransition(
                        layoutState = layoutState,
                        _fromOrToScene = fromScene,
                        _overlay = overlay,
                        swipeAnimation =
                            swipeAnimation(fromContent = fromScene, toContent = overlay),
                        key = result.transitionKey,
                        replacedTransition = null,
                    )
                    .swipeAnimation
            }
            is UserActionResult.HideOverlay -> {
                val toScene = layoutImpl.scene(layoutState.currentScene)
                val overlay = layoutImpl.overlay(result.overlay)
                ShowOrHideOverlaySwipeTransition(
                        layoutState = layoutState,
                        _fromOrToScene = toScene,
                        _overlay = overlay,
                        swipeAnimation = swipeAnimation(fromContent = overlay, toContent = toScene),
                        key = result.transitionKey,
                        replacedTransition = null,
                    )
                    .swipeAnimation
            }
            is UserActionResult.ReplaceByOverlay -> {
                val fromOverlay = layoutImpl.contentForUserActions() as Overlay
                val toOverlay = layoutImpl.overlay(result.overlay)
                ReplaceOverlaySwipeTransition(
                        layoutState = layoutState,
                        swipeAnimation =
                            swipeAnimation(fromContent = fromOverlay, toContent = toOverlay),
                        key = result.transitionKey,
                        replacedTransition = null,
                    )
@@ -228,8 +274,14 @@ internal class DraggableHandlerImpl(
                ChangeCurrentSceneSwipeTransition(transition as ChangeCurrentSceneSwipeTransition)
                    .swipeAnimation
            }
            is TransitionState.Transition.OverlayTransition ->
                TODO("b/353679003: Support overlay transitions")
            is TransitionState.Transition.ShowOrHideOverlay -> {
                ShowOrHideOverlaySwipeTransition(transition as ShowOrHideOverlaySwipeTransition)
                    .swipeAnimation
            }
            is TransitionState.Transition.ReplaceOverlay -> {
                ReplaceOverlaySwipeTransition(transition as ReplaceOverlaySwipeTransition)
                    .swipeAnimation
            }
        }
    }

@@ -495,11 +547,23 @@ private class DragControllerImpl(
            }

            fun shouldChangeContent(): Boolean {
                return when (swipeAnimation.contentTransition) {
                return when (val transition = swipeAnimation.contentTransition) {
                    is TransitionState.Transition.ChangeCurrentScene ->
                        layoutState.canChangeScene(targetContent.key as SceneKey)
                    is TransitionState.Transition.OverlayTransition ->
                        TODO("b/353679003: Support overlay transitions")
                    is TransitionState.Transition.ShowOrHideOverlay -> {
                        if (targetContent.key == transition.overlay) {
                            layoutState.canShowOverlay(transition.overlay)
                        } else {
                            layoutState.canHideOverlay(transition.overlay)
                        }
                    }
                    is TransitionState.Transition.ReplaceOverlay -> {
                        val to = targetContent.key as OverlayKey
                        val from =
                            if (to == transition.toOverlay) transition.fromOverlay
                            else transition.toOverlay
                        layoutState.canReplaceOverlay(from, to)
                    }
                }
            }

@@ -618,6 +682,96 @@ private class ChangeCurrentSceneSwipeTransition(
    override fun finish(): Job = swipeAnimation.finish()
}

private class ShowOrHideOverlaySwipeTransition(
    val layoutState: MutableSceneTransitionLayoutStateImpl,
    val swipeAnimation: SwipeAnimation<Content>,
    val _overlay: Overlay,
    val _fromOrToScene: Scene,
    override val key: TransitionKey?,
    replacedTransition: ShowOrHideOverlaySwipeTransition?,
) :
    TransitionState.Transition.ShowOrHideOverlay(
        _overlay.key,
        _fromOrToScene.key,
        swipeAnimation.fromContent.key,
        swipeAnimation.toContent.key,
        replacedTransition,
    ),
    TransitionState.HasOverscrollProperties by swipeAnimation {
    constructor(
        other: ShowOrHideOverlaySwipeTransition
    ) : this(
        layoutState = other.layoutState,
        swipeAnimation = SwipeAnimation(other.swipeAnimation),
        _overlay = other._overlay,
        _fromOrToScene = other._fromOrToScene,
        key = other.key,
        replacedTransition = other,
    )

    init {
        swipeAnimation.contentTransition = this
    }

    override val isEffectivelyShown: Boolean
        get() = swipeAnimation.currentContent == _overlay

    override val progress: Float
        get() = swipeAnimation.progress

    override val progressVelocity: Float
        get() = swipeAnimation.progressVelocity

    override val isInitiatedByUserInput: Boolean = true

    override val isUserInputOngoing: Boolean
        get() = swipeAnimation.isUserInputOngoing

    override fun finish(): Job = swipeAnimation.finish()
}

private class ReplaceOverlaySwipeTransition(
    val layoutState: MutableSceneTransitionLayoutStateImpl,
    val swipeAnimation: SwipeAnimation<Overlay>,
    override val key: TransitionKey?,
    replacedTransition: ReplaceOverlaySwipeTransition?,
) :
    TransitionState.Transition.ReplaceOverlay(
        swipeAnimation.fromContent.key,
        swipeAnimation.toContent.key,
        replacedTransition,
    ),
    TransitionState.HasOverscrollProperties by swipeAnimation {
    constructor(
        other: ReplaceOverlaySwipeTransition
    ) : this(
        layoutState = other.layoutState,
        swipeAnimation = SwipeAnimation(other.swipeAnimation),
        key = other.key,
        replacedTransition = other,
    )

    init {
        swipeAnimation.contentTransition = this
    }

    override val effectivelyShownOverlay: OverlayKey
        get() = swipeAnimation.currentContent.key

    override val progress: Float
        get() = swipeAnimation.progress

    override val progressVelocity: Float
        get() = swipeAnimation.progressVelocity

    override val isInitiatedByUserInput: Boolean = true

    override val isUserInputOngoing: Boolean
        get() = swipeAnimation.isUserInputOngoing

    override fun finish(): Job = swipeAnimation.finish()
}

/** A helper class that contains the main logic for swipe transitions. */
internal class SwipeAnimation<T : Content>(
    val layoutImpl: SceneTransitionLayoutImpl,
+34 −2
Original line number Diff line number Diff line
@@ -104,10 +104,10 @@ interface SceneTransitionLayoutScope {
     * call order. Calling overlay(A) followed by overlay(B) will mean that overlay B renders
     * after/above overlay A.
     */
    // TODO(b/353679003): Allow to specify user actions. When overlays are shown, the user actions
    // of the top-most overlay in currentOverlays will be used.
    fun overlay(
        key: OverlayKey,
        userActions: Map<UserAction, UserActionResult> =
            mapOf(Back to UserActionResult.HideOverlay(key)),
        alignment: Alignment = Alignment.Center,
        content: @Composable ContentScope.() -> Unit,
    )
@@ -502,6 +502,38 @@ sealed class UserActionResult(
        override fun toContent(currentScene: SceneKey): ContentKey = toScene
    }

    /** A [UserActionResult] that shows [overlay]. */
    class ShowOverlay(
        val overlay: OverlayKey,
        transitionKey: TransitionKey? = null,
        requiresFullDistanceSwipe: Boolean = false,
    ) : UserActionResult(transitionKey, requiresFullDistanceSwipe) {
        override fun toContent(currentScene: SceneKey): ContentKey = overlay
    }

    /** A [UserActionResult] that hides [overlay]. */
    class HideOverlay(
        val overlay: OverlayKey,
        transitionKey: TransitionKey? = null,
        requiresFullDistanceSwipe: Boolean = false,
    ) : UserActionResult(transitionKey, requiresFullDistanceSwipe) {
        override fun toContent(currentScene: SceneKey): ContentKey = currentScene
    }

    /**
     * A [UserActionResult] that replaces the current overlay by [overlay].
     *
     * Note: This result can only be used for user actions of overlays and an exception will be
     * thrown if it is used for a scene.
     */
    class ReplaceByOverlay(
        val overlay: OverlayKey,
        transitionKey: TransitionKey? = null,
        requiresFullDistanceSwipe: Boolean = false,
    ) : UserActionResult(transitionKey, requiresFullDistanceSwipe) {
        override fun toContent(currentScene: SceneKey): ContentKey = overlay
    }

    companion object {
        /** A [UserActionResult] that changes the current scene to [toScene]. */
        operator fun invoke(
+78 −8
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.compose.animation.scene

import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -182,6 +183,28 @@ internal class SceneTransitionLayoutImpl(
        }
    }

    internal fun contentForUserActions(): Content {
        return findOverlayWithHighestZIndex() ?: scene(state.transitionState.currentScene)
    }

    private fun findOverlayWithHighestZIndex(): Overlay? {
        val currentOverlays = state.transitionState.currentOverlays
        if (currentOverlays.isEmpty()) {
            return null
        }

        var overlay: Overlay? = null
        currentOverlays.forEach { key ->
            val previousZIndex = overlay?.zIndex
            val candidate = overlay(key)
            if (previousZIndex == null || candidate.zIndex > previousZIndex) {
                overlay = candidate
            }
        }

        return overlay
    }

    internal fun updateContents(
        builder: SceneTransitionLayoutScope.() -> Unit,
        layoutDirection: LayoutDirection,
@@ -206,8 +229,7 @@ internal class SceneTransitionLayoutImpl(

                    scenesToRemove.remove(key)

                    val resolvedUserActions =
                        userActions.mapKeys { it.key.resolve(layoutDirection) }
                    val resolvedUserActions = resolveUserActions(key, userActions, layoutDirection)
                    val scene = scenes[key]
                    if (scene != null) {
                        // Update an existing scene.
@@ -231,6 +253,7 @@ internal class SceneTransitionLayoutImpl(

                override fun overlay(
                    key: OverlayKey,
                    userActions: Map<UserAction, UserActionResult>,
                    alignment: Alignment,
                    content: @Composable (ContentScope.() -> Unit)
                ) {
@@ -238,10 +261,12 @@ internal class SceneTransitionLayoutImpl(
                    overlaysToRemove.remove(key)

                    val overlay = overlays[key]
                    val resolvedUserActions = resolveUserActions(key, userActions, layoutDirection)
                    if (overlay != null) {
                        // Update an existing overlay.
                        overlay.content = content
                        overlay.zIndex = zIndex
                        overlay.userActions = resolvedUserActions
                        overlay.alignment = alignment
                    } else {
                        // New overlay.
@@ -250,8 +275,7 @@ internal class SceneTransitionLayoutImpl(
                                key,
                                this@SceneTransitionLayoutImpl,
                                content,
                                // TODO(b/353679003): Allow to specify user actions
                                actions = emptyMap(),
                                resolvedUserActions,
                                zIndex,
                                alignment,
                            )
@@ -266,6 +290,46 @@ internal class SceneTransitionLayoutImpl(
        overlaysToRemove.forEach { overlays.remove(it) }
    }

    private fun resolveUserActions(
        key: ContentKey,
        userActions: Map<UserAction, UserActionResult>,
        layoutDirection: LayoutDirection
    ): Map<UserAction.Resolved, UserActionResult> {
        return userActions
            .mapKeys { it.key.resolve(layoutDirection) }
            .also { checkUserActions(key, it) }
    }

    private fun checkUserActions(
        key: ContentKey,
        userActions: Map<UserAction.Resolved, UserActionResult>,
    ) {
        userActions.forEach { (action, result) ->
            fun details() = "Content $key, action $action, result $result."

            when (result) {
                is UserActionResult.ChangeScene -> {
                    check(key != result.toScene) {
                        error("Transition to the same scene is not supported. ${details()}")
                    }
                }
                is UserActionResult.ReplaceByOverlay -> {
                    check(key is OverlayKey) {
                        "ReplaceByOverlay() can only be used for overlays, not scenes. ${details()}"
                    }

                    check(key != result.overlay) {
                        "Transition to the same overlay is not supported. ${details()}"
                    }
                }
                is UserActionResult.ShowOverlay,
                is UserActionResult.HideOverlay -> {
                    /* Always valid. */
                }
            }
        }
    }

    @Composable
    internal fun Content(modifier: Modifier, swipeDetector: SwipeDetector) {
        Box(
@@ -289,11 +353,16 @@ internal class SceneTransitionLayoutImpl(

    @Composable
    private fun BackHandler() {
        val result = scene(state.transitionState.currentScene).userActions[Back.Resolved]
        val targetSceneForBack =
            when (result) {
            when (val result = contentForUserActions().userActions[Back.Resolved]) {
                null -> null
                is UserActionResult.ChangeScene -> result.toScene
                is UserActionResult.ShowOverlay,
                is UserActionResult.HideOverlay,
                is UserActionResult.ReplaceByOverlay -> {
                    // TODO(b/353679003): Support overlay transitions when going back
                    null
                }
            }

        PredictiveBackHandler(state, coroutineScope, targetSceneForBack)
@@ -387,9 +456,10 @@ internal class SceneTransitionLayoutImpl(
            .sortedBy { it.zIndex }
    }

    internal fun setScenesAndLayoutTargetSizeForTest(size: IntSize) {
    @VisibleForTesting
    internal fun setContentsAndLayoutTargetSizeForTest(size: IntSize) {
        lastSize = size
        scenes.values.forEach { it.targetSize = size }
        (scenes.values + overlays.values).forEach { it.targetSize = size }
    }

    internal fun overlaysOrNullForTest(): Map<OverlayKey, Overlay>? = _overlays
+17 −0
Original line number Diff line number Diff line
@@ -183,6 +183,12 @@ sealed interface MutableSceneTransitionLayoutState : SceneTransitionLayoutState
 *   commits a transition to a new scene because of a [UserAction]. If [canChangeScene] returns
 *   `true`, then the gesture will be committed and we will animate to the other scene. Otherwise,
 *   the gesture will be cancelled and we will animate back to the current scene.
 * @param canShowOverlay whether we should commit a user action that will result in showing the
 *   given overlay.
 * @param canHideOverlay whether we should commit a user action that will result in hiding the given
 *   overlay.
 * @param canReplaceOverlay whether we should commit a user action that will result in replacing
 *   `from` overlay by `to` overlay.
 * @param stateLinks the [StateLink] connecting this [SceneTransitionLayoutState] to other
 *   [SceneTransitionLayoutState]s.
 */
@@ -191,6 +197,9 @@ fun MutableSceneTransitionLayoutState(
    transitions: SceneTransitions = SceneTransitions.Empty,
    initialOverlays: Set<OverlayKey> = emptySet(),
    canChangeScene: (SceneKey) -> Boolean = { true },
    canShowOverlay: (OverlayKey) -> Boolean = { true },
    canHideOverlay: (OverlayKey) -> Boolean = { true },
    canReplaceOverlay: (from: OverlayKey, to: OverlayKey) -> Boolean = { _, _ -> true },
    stateLinks: List<StateLink> = emptyList(),
    enableInterruptions: Boolean = DEFAULT_INTERRUPTIONS_ENABLED,
): MutableSceneTransitionLayoutState {
@@ -199,6 +208,9 @@ fun MutableSceneTransitionLayoutState(
        transitions,
        initialOverlays,
        canChangeScene,
        canShowOverlay,
        canHideOverlay,
        canReplaceOverlay,
        stateLinks,
        enableInterruptions,
    )
@@ -210,6 +222,11 @@ internal class MutableSceneTransitionLayoutStateImpl(
    override var transitions: SceneTransitions = transitions {},
    initialOverlays: Set<OverlayKey> = emptySet(),
    internal val canChangeScene: (SceneKey) -> Boolean = { true },
    internal val canShowOverlay: (OverlayKey) -> Boolean = { true },
    internal val canHideOverlay: (OverlayKey) -> Boolean = { true },
    internal val canReplaceOverlay: (from: OverlayKey, to: OverlayKey) -> Boolean = { _, _ ->
        true
    },
    private val stateLinks: List<StateLink> = emptyList(),

    // TODO(b/290930950): Remove this flag.
+6 −7
Original line number Diff line number Diff line
@@ -31,7 +31,7 @@ import androidx.compose.ui.node.PointerInputModifierNode
import androidx.compose.ui.node.TraversableNode
import androidx.compose.ui.node.findNearestAncestor
import androidx.compose.ui.unit.IntSize
import com.android.compose.animation.scene.content.Scene
import com.android.compose.animation.scene.content.Content

/**
 * Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state.
@@ -126,16 +126,15 @@ private class SwipeToSceneNode(

    private fun enabled(): Boolean {
        return draggableHandler.isDrivingTransition ||
            currentScene().shouldEnableSwipes(multiPointerDraggableNode.orientation)
            contentForSwipes().shouldEnableSwipes(multiPointerDraggableNode.orientation)
    }

    private fun currentScene(): Scene {
        val layoutImpl = draggableHandler.layoutImpl
        return layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
    private fun contentForSwipes(): Content {
        return draggableHandler.layoutImpl.contentForUserActions()
    }

    /** Whether swipe should be enabled in the given [orientation]. */
    private fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean {
    private fun Content.shouldEnableSwipes(orientation: Orientation): Boolean {
        return userActions.keys.any {
            it is Swipe.Resolved && it.direction.orientation == orientation
        }
@@ -153,7 +152,7 @@ private class SwipeToSceneNode(
                Orientation.Vertical -> Orientation.Horizontal
                Orientation.Horizontal -> Orientation.Vertical
            }
        return currentScene().shouldEnableSwipes(oppositeOrientation)
        return contentForSwipes().shouldEnableSwipes(oppositeOrientation)
    }
}

Loading