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

Commit 936b76d3 authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "STL introduces OverscrollEffects [1/2]" into main

parents 7c08571b 9aca08b5
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