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

Commit 92254159 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Mike Schneider
Browse files

Add deferTransitionProgress flag to STL (1/2)

This CL adds a new deferTransitionProgress flag to STL to reduce
perceivable jank by waiting for the first expensive composition frame of
a transition to be done before changing the progress of the transition.
This works with gesture-based transitions and one-off transitions.

See b/400688335#comment2 for details.

Bug: 400688335
Test: atest NestedDraggableTest
Flag: EXEMPT new behavior disabled by default
Change-Id: I839529e2bc1d792ae8a37d5ddf3e7c61a12fe3cb
parent cd46292a
Loading
Loading
Loading
Loading
+33 −0
Original line number Diff line number Diff line
@@ -111,6 +111,16 @@ interface NestedDraggable {
    fun shouldConsumeNestedPreScroll(sign: Float): Boolean = false

    interface Controller {
        /**
         * Whether this controller is ready to drag. [onDrag] will be called only if this returns
         * `true`, and any drag event will be ignored until then.
         *
         * This can for instance be used to wait for the content we are dragging to to be composed
         * before actually dragging, reducing perceivable jank at the beginning of a drag.
         */
        val isReadyToDrag: Boolean
            get() = true

        /**
         * Whether drags that were started from nested scrolls should be automatically
         * [stopped][onDragStopped] as soon as they don't consume the entire `delta` passed to
@@ -274,6 +284,9 @@ private class NestedDraggableNode(
    /** The pointers currently down, in order of which they were done and mapping to their type. */
    private val pointersDown = linkedMapOf<PointerId, PointerType>()

    /** Whether the next drag event should be ignored. */
    private var ignoreNextDrag = false

    init {
        delegate(nestedScrollModifierNode(this, nestedScrollDispatcher))
    }
@@ -426,6 +439,7 @@ private class NestedDraggableNode(
        velocityTracker: VelocityTracker,
    ) {
        velocityTracker.addPointerInputChange(change)
        if (shouldIgnoreDrag(controller)) return

        scrollWithOverscroll(delta.toOffset()) { deltaFromOverscroll ->
            scrollWithNestedScroll(deltaFromOverscroll) { deltaFromNestedScroll ->
@@ -434,6 +448,23 @@ private class NestedDraggableNode(
        }
    }

    private fun shouldIgnoreDrag(controller: NestedDraggable.Controller): Boolean {
        return when {
            !controller.isReadyToDrag -> {
                // The controller is not ready yet, so we are waiting for an expensive frame to be
                // composed. We should ignore this drag and the next one, given that the first delta
                // after an expensive frame will be large.
                ignoreNextDrag = true
                true
            }
            ignoreNextDrag -> {
                ignoreNextDrag = false
                true
            }
            else -> false
        }
    }

    private fun onDragStopped(controller: NestedDraggable.Controller, velocity: Velocity) {
        // We launch in the scope of the dispatcher so that the fling is not cancelled if this node
        // is removed right after onDragStopped() is called.
@@ -617,6 +648,8 @@ private class NestedDraggableNode(
    }

    private fun scrollWithOverscroll(controller: NestedScrollController, offset: Offset): Offset {
        if (shouldIgnoreDrag(controller.controller)) return offset

        return scrollWithOverscroll(offset) { delta ->
            val available = delta.toFloat()
            val consumed = controller.controller.onDrag(available)
+34 −0
Original line number Diff line number Diff line
@@ -971,6 +971,36 @@ class NestedDraggableTest(override val orientation: Orientation) : OrientationAw
        assertThat(availableToEffectPostFling).isWithin(1f).of(100f)
    }

    @Test
    fun isReadyToDrag() {
        var isReadyToDrag by mutableStateOf(false)
        val draggable = TestDraggable(isReadyToDrag = { isReadyToDrag })
        val touchSlop =
            rule.setContentWithTouchSlop {
                Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation))
            }

        rule.onRoot().performTouchInput {
            down(center)
            moveBy((touchSlop + 10f).toOffset())
        }

        assertThat(draggable.onDragStartedCalled).isTrue()
        assertThat(draggable.onDragDelta).isEqualTo(0f)

        rule.onRoot().performTouchInput { moveBy(20f.toOffset()) }
        assertThat(draggable.onDragDelta).isEqualTo(0f)

        // Flag as ready to drag. We still ignore the next drag after that.
        isReadyToDrag = true
        rule.onRoot().performTouchInput { moveBy(30f.toOffset()) }
        assertThat(draggable.onDragDelta).isEqualTo(0f)

        // Now we drag.
        rule.onRoot().performTouchInput { moveBy(40f.toOffset()) }
        assertThat(draggable.onDragDelta).isEqualTo(40f)
    }

    @Test
    fun consumeNestedPreScroll() {
        var consumeNestedPreScroll by mutableStateOf(false)
@@ -1060,6 +1090,7 @@ class NestedDraggableTest(override val orientation: Orientation) : OrientationAw
            },
        private val shouldConsumeNestedPostScroll: (Float) -> Boolean = { true },
        private val shouldConsumeNestedPreScroll: (Float) -> Boolean = { false },
        private val isReadyToDrag: () -> Boolean = { true },
        private val autoStopNestedDrags: Boolean = false,
    ) : NestedDraggable {
        var shouldStartDrag = true
@@ -1092,6 +1123,9 @@ class NestedDraggableTest(override val orientation: Orientation) : OrientationAw
            return object : NestedDraggable.Controller {
                override val autoStopNestedDrags: Boolean = this@TestDraggable.autoStopNestedDrags

                override val isReadyToDrag: Boolean
                    get() = isReadyToDrag()

                override fun onDrag(delta: Float): Float {
                    onDragCalled = true
                    onDragDelta += delta
+6 −0
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.SpringSpec
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.withFrameNanos
import com.android.compose.animation.scene.content.state.TransitionState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -47,6 +48,11 @@ internal fun CoroutineScope.animateContent(
                oneOffAnimation.animatable = it
            }

        if (layoutState.deferTransitionProgress) {
            // Defer the animation by one frame so that the transition progress is changed only when
            // the expensive first composition frame is done.
            withFrameNanos {}
        }
        animatable.animateTo(targetProgress, animationSpec, initialVelocity)
    }

+9 −0
Original line number Diff line number Diff line
@@ -290,6 +290,15 @@ private class DragControllerImpl(
    val isDrivingTransition: Boolean
        get() = layoutState.transitionState == swipeAnimation.contentTransition

    override val isReadyToDrag: Boolean
        get() {
            return !layoutState.deferTransitionProgress ||
                with(draggableHandler.layoutImpl.elementStateScope) {
                    swipeAnimation.fromContent.targetSize() != null &&
                        swipeAnimation.toContent.targetSize() != null
                }
        }

    init {
        check(!isDrivingTransition) { "Multiple controllers with the same SwipeTransition" }
    }
+18 −9
Original line number Diff line number Diff line
@@ -462,7 +462,9 @@ internal class SceneTransitionLayoutImpl(
                // swipes.
                .swipeToScene(horizontalDraggableHandler)
                .swipeToScene(verticalDraggableHandler)
                .then(LayoutElement(layoutImpl = this))
                .then(
                    LayoutElement(layoutImpl = this, transitionState = this.state.transitionState)
                )
        ) {
            LookaheadScope {
                if (_lookaheadScope == null) {
@@ -623,23 +625,28 @@ internal class SceneTransitionLayoutImpl(
    @VisibleForTesting internal fun overlaysOrNullForTest(): Map<OverlayKey, Overlay>? = _overlays
}

private data class LayoutElement(private val layoutImpl: SceneTransitionLayoutImpl) :
    ModifierNodeElement<LayoutNode>() {
    override fun create(): LayoutNode = LayoutNode(layoutImpl)
private data class LayoutElement(
    private val layoutImpl: SceneTransitionLayoutImpl,
    private val transitionState: TransitionState,
) : ModifierNodeElement<LayoutNode>() {
    override fun create(): LayoutNode = LayoutNode(layoutImpl, transitionState)

    override fun update(node: LayoutNode) {
        node.layoutImpl = layoutImpl
        node.transitionState = transitionState
    }
}

private class LayoutNode(var layoutImpl: SceneTransitionLayoutImpl) :
    Modifier.Node(), ApproachLayoutModifierNode, LayoutAwareModifierNode {
private class LayoutNode(
    var layoutImpl: SceneTransitionLayoutImpl,
    var transitionState: TransitionState,
) : Modifier.Node(), ApproachLayoutModifierNode, LayoutAwareModifierNode {
    override fun onRemeasured(size: IntSize) {
        layoutImpl.lastSize = size
    }

    override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean {
        return layoutImpl.state.isTransitioning()
        return transitionState is TransitionState.Transition.ChangeScene
    }

    @ExperimentalComposeUiApi
@@ -652,8 +659,7 @@ private class LayoutNode(var layoutImpl: SceneTransitionLayoutImpl) :

        val width: Int
        val height: Int
        val transition =
            layoutImpl.state.currentTransition as? TransitionState.Transition.ChangeScene
        val transition = transitionState as? TransitionState.Transition.ChangeScene
        if (transition == null) {
            width = placeable.width
            height = placeable.height
@@ -662,6 +668,9 @@ private class LayoutNode(var layoutImpl: SceneTransitionLayoutImpl) :
            val fromSize = layoutImpl.scene(transition.fromScene).targetSize
            val toSize = layoutImpl.scene(transition.toScene).targetSize

            check(fromSize != Element.SizeUnspecified) { "fromSize is unspecified " }
            check(toSize != Element.SizeUnspecified) { "toSize is unspecified" }

            // Optimization: make sure we don't read state.progress if fromSize ==
            // toSize to avoid running this code every frame when the layout size does
            // not change.
Loading