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

Commit 3f6fd4ab authored by Ale Nijamkin's avatar Ale Nijamkin
Browse files

[Media] Fixes swipe to dismiss in RTL.

SwipeToDismiss reimplemented more simply with a nestedDraggable,
informed by the SwipeToReveal implementation.

Bug: 403558944
Test: manually verified in the Compose Gallery app that the
dismissible media card carousel, in RTL, can be dismissed correctly. Also made sure that the LTR case isn't harmed
Flag: EXEMPT code not yet used in production

Change-Id: Ibd3839d5d8981006723c1cd38cf678d5fe608768
parent 9c672395
Loading
Loading
Loading
Loading
+5 −1
Original line number Diff line number Diff line
@@ -268,7 +268,11 @@ private fun CardCarouselContent(
        }

        if (behavior.isCarouselDismissible) {
            SwipeToDismiss(content = { PagerContent() }, onDismissed = onDismissed)
            SwipeToDismiss(
                content = { PagerContent() },
                isSwipingEnabled = isSwipingEnabled,
                onDismissed = onDismissed,
            )
        } else {
            val overscrollEffect = rememberOffsetOverscrollEffect(placeRelatively = false)
            SwipeToReveal(
+154 −66
Original line number Diff line number Diff line
@@ -17,99 +17,187 @@
package com.android.systemui.media.remedia.ui.compose

import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.overscroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
import com.android.compose.gesture.NestedDraggable
import com.android.compose.gesture.effect.rememberOffsetOverscrollEffect
import com.android.compose.gesture.nestedDraggable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

/** Swipe to dismiss that supports nested scrolling. */
@Composable
fun SwipeToDismiss(
    content: @Composable (overscrollEffect: OverscrollEffect?) -> Unit,
    content: @Composable () -> Unit,
    isSwipingEnabled: Boolean,
    onDismissed: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val scope = rememberCoroutineScope()
    val offsetAnimatable = remember { Animatable(0f) }
    val overscrollEffect = rememberOffsetOverscrollEffect(placeRelatively = false)

    // This is the width of the revealed content UI box. It's not a state because it's not
    // observed in any composition and is an object with a value to avoid the extra cost
    // associated with boxing and unboxing an int.
    val revealedContentBoxWidth = remember {
    // This is the width of the content UI box. It's not a state because it's not observed in any
    // composition and is an object with a value to avoid the extra cost associated with boxing and
    // unboxing an int.
    val contentBoxWidth = remember {
        object {
            var value = 0
        }
    }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                return if (offsetAnimatable.value > 0f && available.x < 0f) {
                    scope.launch { offsetAnimatable.snapTo(offsetAnimatable.value + available.x) }
                    Offset(available.x, 0f)
                } else if (offsetAnimatable.value < 0f && available.x > 0f) {
                    scope.launch { offsetAnimatable.snapTo(offsetAnimatable.value + available.x) }
                    Offset(available.x, 0f)
                } else {
                    Offset.Zero
    // In order to support the drag to dismiss, infrastructure has to be put in place where a
    // NestedDraggable helps by consuming the unconsumed drags and flings and applying the offset.
    //
    // This is the NestedDraggalbe controller.
    val dragController =
        rememberDismissibleContentDragController(
            maxBound = { contentBoxWidth.value.toFloat() },
            onDismissed = onDismissed,
        )

    Box(
        modifier =
            modifier
                .layout { measurable, constraints ->
                    val placeable = measurable.measure(constraints)
                    contentBoxWidth.value = placeable.measuredWidth
                    layout(placeable.measuredWidth, placeable.measuredHeight) {
                        placeable.place(0, 0)
                    }
                }

            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource,
            ): Offset {
                return if (available.x > 0f) {
                    scope.launch { offsetAnimatable.snapTo(offsetAnimatable.value + available.x) }
                    Offset(available.x, 0f)
                } else if (available.x < 0f) {
                    scope.launch { offsetAnimatable.snapTo(offsetAnimatable.value + available.x) }
                    Offset(available.x, 0f)
                } else {
                    Offset.Zero
                .nestedDraggable(
                    enabled = isSwipingEnabled,
                    draggable =
                        remember {
                            object : NestedDraggable {
                                override fun onDragStarted(
                                    position: Offset,
                                    sign: Float,
                                    pointersDown: Int,
                                    pointerType: PointerType?,
                                ): NestedDraggable.Controller {
                                    return dragController
                                }

                                override fun shouldConsumeNestedPostScroll(sign: Float): Boolean {
                                    return dragController.shouldConsumePostScrolls(sign)
                                }

            override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
                scope.launch {
                    offsetAnimatable.animateTo(
                        if (offsetAnimatable.value >= revealedContentBoxWidth.value / 2f) {
                            revealedContentBoxWidth.value * 2f
                        } else if (offsetAnimatable.value <= -revealedContentBoxWidth.value / 2f) {
                            -revealedContentBoxWidth.value * 2f
                        } else {
                            0f
                                override fun shouldConsumeNestedPreScroll(sign: Float): Boolean {
                                    return dragController.shouldConsumePreScrolls(sign)
                                }
                            }
                        },
                    orientation = Orientation.Horizontal,
                )
                    if (offsetAnimatable.value != 0f) {
                        onDismissed()
                .overscroll(overscrollEffect)
                .absoluteOffset { IntOffset(dragController.offset.fastRoundToInt(), y = 0) }
    ) {
        content()
    }
}
                return super.onPostFling(consumed, available)

@Composable
private fun rememberDismissibleContentDragController(
    maxBound: () -> Float,
    onDismissed: () -> Unit,
): DismissibleContentDragController {
    val scope = rememberCoroutineScope()
    return remember {
        DismissibleContentDragController(
            scope = scope,
            maxBound = maxBound,
            onDismissed = onDismissed,
        )
    }
}

private class DismissibleContentDragController(
    private val scope: CoroutineScope,
    private val maxBound: () -> Float,
    private val onDismissed: () -> Unit,
) : NestedDraggable.Controller {
    private val offsetAnimatable = Animatable(0f)
    private var lastTarget = 0f
    private var range = 0f..1f
    private var shouldConsumePreScrolls by mutableStateOf(false)

    override val autoStopNestedDrags: Boolean
        get() = true

    val offset: Float
        get() = offsetAnimatable.value

    fun shouldConsumePreScrolls(sign: Float): Boolean {
        if (!shouldConsumePreScrolls) return false

        if (lastTarget > 0f && sign < 0f) {
            range = 0f..maxBound()
            return true
        }

    Box(
        modifier =
            modifier
                .onSizeChanged { revealedContentBoxWidth.value = it.width }
                .nestedScroll(nestedScrollConnection)
                .offset { IntOffset(x = offsetAnimatable.value.fastRoundToInt(), y = 0) }
    ) {
        content(null)
        if (lastTarget < 0f && sign > 0f) {
            range = -maxBound()..0f
            return true
        }

        return false
    }

    fun shouldConsumePostScrolls(sign: Float): Boolean {
        val max = maxBound()
        if (sign > 0f && lastTarget < max) {
            range = 0f..maxBound()
            return true
        }

        if (sign < 0f && lastTarget > -max) {
            range = -maxBound()..0f
            return true
        }

        return false
    }

    override fun onDrag(delta: Float): Float {
        val previousTarget = lastTarget
        lastTarget = (lastTarget + delta).fastCoerceIn(range.start, range.endInclusive)
        val newTarget = lastTarget
        scope.launch { offsetAnimatable.snapTo(newTarget) }
        return lastTarget - previousTarget
    }

    override suspend fun onDragStopped(velocity: Float, awaitFling: suspend () -> Unit): Float {
        val rangeMiddle = range.start + (range.endInclusive - range.start) / 2f
        lastTarget =
            when {
                lastTarget >= rangeMiddle -> range.endInclusive
                else -> range.start
            }

        shouldConsumePreScrolls = lastTarget != 0f
        val newTarget = lastTarget

        scope.launch {
            offsetAnimatable.animateTo(newTarget)
            if (newTarget != 0f) {
                onDismissed()
            }
        }
        return velocity
    }
}
+2 −3
Original line number Diff line number Diff line
@@ -22,7 +22,6 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.withoutVisualEffect
import androidx.compose.runtime.Composable
@@ -82,7 +81,7 @@ fun SwipeToReveal(
    // overscroll visual effect.
    //
    // This is the NestedDraggalbe controller.
    val revealedContentDragController = rememberRevealedContentDragController {
    val revealedContentDragController = rememberDismissibleContentDragController {
        revealedContentBoxWidth.value.toFloat()
    }

@@ -186,7 +185,7 @@ fun SwipeToReveal(
}

@Composable
private fun rememberRevealedContentDragController(
private fun rememberDismissibleContentDragController(
    maxBound: () -> Float
): RevealedContentDragController {
    val scope = rememberCoroutineScope()