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

Commit 9aca08b5 authored by omarmt's avatar omarmt
Browse files

STL introduces OverscrollEffects [1/2]

STL will generate two OverscrollEffects for each scene: one vertical and
 one horizontal. These effects can be used within the scene to handle
overscroll gestures from the SLT.

Once attached to the scene, the effects can also be used by other
scrollable components to manage their overscroll gesture.

Test: atest OffsetOverscrollEffectTest
Test: atest ElementTest
Bug: 378470603
Flag: com.android.systemui.scene_container
Change-Id: I0c6c19293a9f559a0bb2c366bd1186c6b0ecce21
parent 1f8b3c36
Loading
Loading
Loading
Loading
+77 −14
Original line number Diff line number Diff line
@@ -27,8 +27,11 @@ import com.android.compose.animation.scene.content.state.TransitionState.HasOver
import com.android.compose.nestedscroll.OnStopScope
import com.android.compose.nestedscroll.PriorityNestedScrollConnection
import com.android.compose.nestedscroll.ScrollController
import com.android.compose.ui.util.SpaceVectorConverter
import kotlin.math.absoluteValue
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

internal interface DraggableHandler {
    /**
@@ -191,9 +194,15 @@ private class DragControllerImpl(
    private val draggableHandler: DraggableHandlerImpl,
    val swipes: Swipes,
    var swipeAnimation: SwipeAnimation<*>,
) : DragController {
) : DragController, SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
    val layoutState = draggableHandler.layoutImpl.state

    val overscrollableContent: OverscrollableContent =
        when (draggableHandler.orientation) {
            Orientation.Vertical -> draggableHandler.layoutImpl.verticalOverscrollableContent
            Orientation.Horizontal -> draggableHandler.layoutImpl.horizontalOverscrollableContent
        }

    /**
     * Whether this handle is active. If this returns false, calling [onDrag] and [onStop] will do
     * nothing.
@@ -224,36 +233,75 @@ private class DragControllerImpl(
     * @return the consumed delta
     */
    override fun onDrag(delta: Float): Float {
        return onDrag(delta, swipeAnimation)
        val initialAnimation = swipeAnimation
        if (delta == 0f || !isDrivingTransition || initialAnimation.isAnimatingOffset()) {
            return 0f
        }
        // swipeAnimation can change during the gesture, we want to always use the initial reference
        // during the whole drag gesture.
        return dragWithOverscroll(delta, animation = initialAnimation)
    }

    private fun <T : ContentKey> onDrag(delta: Float, swipeAnimation: SwipeAnimation<T>): Float {
        if (delta == 0f || !isDrivingTransition || swipeAnimation.isAnimatingOffset()) {
            return 0f
    private fun <T : ContentKey> dragWithOverscroll(
        delta: Float,
        animation: SwipeAnimation<T>,
    ): Float {
        require(delta != 0f) { "delta should not be 0" }
        var overscrollEffect = overscrollableContent.currentOverscrollEffect

        // If we're already overscrolling, continue with the current effect for a smooth finish.
        if (overscrollEffect == null || !overscrollEffect.isInProgress) {
            // Otherwise, determine the target content (toContent or fromContent) for the new
            // overscroll effect based on the gesture's direction.
            val content = animation.contentByDirection(delta)
            overscrollEffect = overscrollableContent.applyOverscrollEffectOn(content)
        }

        val distance = swipeAnimation.distance()
        val previousOffset = swipeAnimation.dragOffset
        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
        if (!overscrollEffect.node.node.isAttached) {
            return drag(delta, animation)
        }

        return overscrollEffect
            .applyToScroll(
                delta = delta.toOffset(),
                source = NestedScrollSource.UserInput,
                performScroll = {
                    val preScrollAvailable = it.toFloat()
                    drag(preScrollAvailable, animation).toOffset()
                },
            )
            .toFloat()
    }

    private fun <T : ContentKey> drag(delta: Float, animation: SwipeAnimation<T>): Float {
        if (delta == 0f) return 0f

        val distance = animation.distance()
        val previousOffset = animation.dragOffset
        val desiredOffset = previousOffset + delta
        val desiredProgress = swipeAnimation.computeProgress(desiredOffset)
        val desiredProgress = animation.computeProgress(desiredOffset)

        // Note: the distance could be negative if fromContent is above or to the left of
        // toContent.
        // Note: the distance could be negative if fromContent is above or to the left of toContent.
        val newOffset =
            when {
                distance == DistanceUnspecified ||
                    swipeAnimation.contentTransition.isWithinProgressRange(desiredProgress) ->
                    animation.contentTransition.isWithinProgressRange(desiredProgress) ->
                    desiredOffset
                distance > 0f -> desiredOffset.fastCoerceIn(0f, distance)
                else -> desiredOffset.fastCoerceIn(distance, 0f)
            }

        swipeAnimation.dragOffset = newOffset
        animation.dragOffset = newOffset
        return newOffset - previousOffset
    }

    override suspend fun onStop(velocity: Float, canChangeContent: Boolean): Float {
        return onStop(velocity, canChangeContent, swipeAnimation)
        // To ensure that any ongoing animation completes gracefully and avoids an undefined state,
        // we execute the actual `onStop` logic in a non-cancellable context. This prevents the
        // coroutine from being cancelled prematurely, which could interrupt the animation.
        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
        return withContext(NonCancellable) { onStop(velocity, canChangeContent, swipeAnimation) }
    }

    private suspend fun <T : ContentKey> onStop(
@@ -304,9 +352,24 @@ private class DragControllerImpl(
                fromContent
            }

        val overscrollEffect = overscrollableContent.applyOverscrollEffectOn(targetContent)

        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
        if (!overscrollEffect.node.node.isAttached) {
            return swipeAnimation.animateOffset(velocity, targetContent)
        }

        overscrollEffect.applyToFling(
            velocity = velocity.toVelocity(),
            performFling = {
                val velocityLeft = it.toFloat()
                swipeAnimation.animateOffset(velocityLeft, targetContent).toVelocity()
            },
        )

        return velocity
    }

    /**
     * Whether the swipe to the target scene should be committed or not. This is inspired by
     * SwipeableV2.computeTarget().
+48 −0
Original line number Diff line number Diff line
@@ -36,6 +36,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import com.android.compose.animation.scene.effect.ContentOverscrollEffect

/**
 * [SceneTransitionLayout] is a container that automatically animates its content whenever its state
@@ -282,6 +283,53 @@ typealias SceneScope = ContentScope
@Stable
@ElementDsl
interface ContentScope : BaseContentScope {
    /**
     * The overscroll effect applied to the content in the vertical direction. This can be used to
     * customize how the content behaves when the scene is over scrolled.
     *
     * For example, you can use it with the `Modifier.overscroll()` modifier:
     * ```kotlin
     * @Composable
     * fun ContentScope.MyScene() {
     *     Box(
     *         modifier = Modifier
     *             // Apply the effect
     *             .overscroll(verticalOverscrollEffect)
     *     ) {
     *         // ... your content ...
     *     }
     * }
     * ```
     *
     * Or you can read the `overscrollDistance` value directly, if you need some custom overscroll
     * behavior:
     * ```kotlin
     * @Composable
     * fun ContentScope.MyScene() {
     *     Box(
     *         modifier = Modifier
     *             .graphicsLayer {
     *                 // Translate half of the overscroll
     *                 translationY = verticalOverscrollEffect.overscrollDistance * 0.5f
     *             }
     *     ) {
     *         // ... your content ...
     *     }
     * }
     * ```
     *
     * @see horizontalOverscrollEffect
     */
    val verticalOverscrollEffect: ContentOverscrollEffect

    /**
     * The overscroll effect applied to the content in the horizontal direction. This can be used to
     * customize how the content behaves when the scene is over scrolled.
     *
     * @see verticalOverscrollEffect
     */
    val horizontalOverscrollEffect: ContentOverscrollEffect

    /**
     * Animate some value at the content level.
     *
+34 −0
Original line number Diff line number Diff line
@@ -49,8 +49,10 @@ 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.effect.GestureEffect
import com.android.compose.ui.util.lerp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

/** The type for the content of movable elements. */
internal typealias MovableElementContent = @Composable (@Composable () -> Unit) -> Unit
@@ -134,6 +136,18 @@ internal class SceneTransitionLayoutImpl(
                    _movableContents = it
                }

    internal var horizontalOverscrollableContent =
        OverscrollableContent(
            animationScope = animationScope,
            overscrollEffect = { content(it).scope.horizontalOverscrollGestureEffect },
        )

    internal var verticalOverscrollableContent =
        OverscrollableContent(
            animationScope = animationScope,
            overscrollEffect = { content(it).scope.verticalOverscrollGestureEffect },
        )

    /**
     * The different values of a shared value keyed by a a [ValueKey] and the different elements and
     * contents it is associated to.
@@ -561,3 +575,23 @@ private class LayoutNode(var layoutImpl: SceneTransitionLayoutImpl) :
        return layout(width, height) { placeable.place(0, 0) }
    }
}

internal class OverscrollableContent(
    private val animationScope: CoroutineScope,
    private val overscrollEffect: (ContentKey) -> GestureEffect,
) {
    private var currentContent: ContentKey? = null
    var currentOverscrollEffect: GestureEffect? = null

    fun applyOverscrollEffectOn(contentKey: ContentKey): GestureEffect {
        if (currentContent == contentKey) return currentOverscrollEffect!!

        currentOverscrollEffect?.apply { animationScope.launch { ensureApplyToFlingIsCalled() } }

        // We are wrapping the overscroll effect.
        val overscrollEffect = overscrollEffect(contentKey)
        currentContent = contentKey
        currentOverscrollEffect = overscrollEffect
        return overscrollEffect
    }
}
+11 −0
Original line number Diff line number Diff line
@@ -313,6 +313,17 @@ internal class SwipeAnimation<T : ContentKey>(

    fun isAnimatingOffset(): Boolean = offsetAnimation != null

    /** Get the [ContentKey] ([fromContent] or [toContent]) associated to the current [direction] */
    fun contentByDirection(direction: Float): T {
        require(direction != 0f) { "Cannot find a content in this direction: $direction" }
        val isDirectionToContent = (isUpOrLeft && direction < 0) || (!isUpOrLeft && direction > 0)
        return if (isDirectionToContent) {
            toContent
        } else {
            fromContent
        }
    }

    /**
     * Animate the offset to a [targetContent], using the [initialVelocity] and an optional [spec]
     *
+24 −0
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@
package com.android.compose.animation.scene.content

import android.annotation.SuppressLint
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
@@ -51,6 +52,9 @@ import com.android.compose.animation.scene.UserAction
import com.android.compose.animation.scene.UserActionResult
import com.android.compose.animation.scene.ValueKey
import com.android.compose.animation.scene.animateSharedValueAsState
import com.android.compose.animation.scene.effect.GestureEffect
import com.android.compose.animation.scene.effect.OffsetOverscrollEffect
import com.android.compose.animation.scene.effect.VisualEffect
import com.android.compose.animation.scene.element
import com.android.compose.animation.scene.modifiers.noResizeDuringTransitions
import com.android.compose.animation.scene.nestedScrollToScene
@@ -109,6 +113,26 @@ internal class ContentScopeImpl(

    override val layoutState: SceneTransitionLayoutState = layoutImpl.state

    private val _verticalOverscrollEffect =
        OffsetOverscrollEffect(
            orientation = Orientation.Vertical,
            animationScope = layoutImpl.animationScope,
        )

    private val _horizontalOverscrollEffect =
        OffsetOverscrollEffect(
            orientation = Orientation.Horizontal,
            animationScope = layoutImpl.animationScope,
        )

    val verticalOverscrollGestureEffect = GestureEffect(_verticalOverscrollEffect)

    val horizontalOverscrollGestureEffect = GestureEffect(_horizontalOverscrollEffect)

    override val verticalOverscrollEffect = VisualEffect(_verticalOverscrollEffect)

    override val horizontalOverscrollEffect = VisualEffect(_horizontalOverscrollEffect)

    override fun Modifier.element(key: ElementKey): Modifier {
        return element(layoutImpl, content, key)
    }
Loading