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

Commit 6bb2f26c authored by Mike Schneider's avatar Mike Schneider
Browse files

SwipeToDismiss with MotionValue

- Extract code into `overscrollToDismiss` Modifier
- set `beyondViewportPageCount` to 1 to avoid recomposition during
  animation

Tests:
- Add dependencies for motion tests in `PlatformComposeCore`
- Change test activity theme to `NoActionBar` to ensure composable is
  visible in recordings

Test: OverscrollToDismissTest
Flag: EXEMPT code not in use in production
Change-Id: I9dc94966de7c288e9919680a60a6b2be6bc592ca
parent 13f26f5a
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -40,6 +40,7 @@ android_library {
        "androidx.savedstate_savedstate",
        "androidx.window_window",
        "//frameworks/libs/systemui:tracinglib-platform",
        "//frameworks/libs/systemui:mechanics",
    ],

    kotlincflags: [
+1 −1
Original line number Diff line number Diff line
@@ -205,7 +205,7 @@ private data class NestedDraggableElement(
 * A root node on top of [NestedDraggableNode] so that no [PointerInputModifierNode] is installed
 * when this draggable is disabled.
 */
private class NestedDraggableRootNode(
internal class NestedDraggableRootNode(
    draggable: NestedDraggable,
    orientation: Orientation,
    overscrollEffect: OverscrollEffect?,
+221 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.gesture

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireDensity
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.android.mechanics.DistanceGestureContext
import com.android.mechanics.MotionValue
import com.android.mechanics.debug.findMotionValueDebugger
import com.android.mechanics.effects.MagneticDetach
import com.android.mechanics.effects.MagneticDetach.Companion.Defaults.AttachDetachState
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.SemanticKey
import com.android.mechanics.spec.builder.MotionBuilderContext
import com.android.mechanics.spec.builder.fixedSpatialValueSpec
import com.android.mechanics.spec.builder.spatialMotionSpec
import com.android.mechanics.spec.with
import com.android.mechanics.spring.SpringParameters
import kotlin.math.abs
import kotlin.math.sign
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

/**
 * "Swipe to dismiss" effect that supports nested scrolling.
 *
 * TODO: Once b/413283893 is done, motionBuilderContext can be read internally via
 *   CompositionLocalConsumerModifierNode, instead of passing it.
 */
fun Modifier.overscrollToDismiss(
    motionBuilderContext: MotionBuilderContext,
    orientation: Orientation = Orientation.Horizontal,
    enabled: Boolean = true,
    onDismissed: () -> Unit,
) = this.then(OverscrollToDismissElement(motionBuilderContext, orientation, enabled, onDismissed))

private data class OverscrollToDismissElement(
    val motionBuilderContext: MotionBuilderContext,
    val orientation: Orientation,
    val enabled: Boolean,
    val onDismissed: () -> Unit,
) : ModifierNodeElement<OverscrollToDismissNode>() {
    override fun create(): OverscrollToDismissNode {
        return OverscrollToDismissNode(orientation, enabled, motionBuilderContext, onDismissed)
    }

    override fun update(node: OverscrollToDismissNode) {
        node.update(orientation, enabled, onDismissed)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "overscrollToDismiss"
        properties["enabled"] = enabled
        properties["orientation"] = orientation
    }
}

private class OverscrollToDismissNode(
    orientation: Orientation,
    enabled: Boolean,
    var motionBuilderContext: MotionBuilderContext,
    var onDismissed: () -> Unit,
) :
    DelegatingNode(),
    LayoutModifierNode,
    CompositionLocalConsumerModifierNode,
    ObserverModifierNode,
    NestedDraggable,
    NestedDraggable.Controller {

    // This implementation always tracks the gesture from 0..x, independent of whether the
    // overscroll is positive or negative. Thus, the output needs to be multiplied by this sign to
    // compute the actual value.
    private var overscrollSign: Float = 0f
    private val gestureContext =
        DistanceGestureContext(0f, InputDirection.Max, directionChangeSlop = 1f)

    private val motionValue =
        MotionValue(
            gestureContext::dragOffset,
            gestureContext,
            motionBuilderContext.fixedSpatialValueSpec(0f),
        )

    private var delegateNode =
        delegate(NestedDraggableRootNode(this, orientation, null, enabled, true))

    fun update(orientation: Orientation, enabled: Boolean, onDismissed: () -> Unit) {
        this.onDismissed = onDismissed
        delegateNode.update(this, orientation, null, enabled, true)
    }

    private var contentBoxWidth = 0
    private var motionValueJob: Job? = null

    override fun onAttach() {
        onObservedReadsChanged()
        motionValueJob = coroutineScope.launch { keepRunningUntilDismissed() }
    }

    override fun onObservedReadsChanged() {
        observeReads {
            gestureContext.directionChangeSlop = currentValueOf(LocalViewConfiguration).touchSlop
        }
    }

    override fun onDetach() {
        motionValueJob?.cancel()
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        contentBoxWidth = placeable.measuredWidth
        return layout(placeable.measuredWidth, placeable.measuredHeight) {
            placeable.place((motionValue.output * overscrollSign).toInt(), 0)
        }
    }

    override val autoStopNestedDrags: Boolean
        get() = true

    override fun onDragStarted(
        position: Offset,
        sign: Float,
        pointersDown: Int,
        pointerType: PointerType?,
    ): NestedDraggable.Controller {
        overscrollSign = sign
        gestureContext.reset(dragOffset = motionValue.output, direction = InputDirection.Max)
        motionValue.spec = motionBuilderContext.spatialMotionSpec { at(0f, MagneticDetach()) }
        return this
    }

    override fun shouldConsumeNestedPreScroll(sign: Float): Boolean {
        return motionValue[isDismissedState] ?: false
    }

    override fun onDrag(delta: Float): Float {
        val previousOffset = gestureContext.dragOffset
        val currentOffset = (gestureContext.dragOffset + delta * overscrollSign).coerceAtLeast(0f)
        gestureContext.dragOffset = currentOffset
        return (currentOffset - previousOffset) * overscrollSign
    }

    override suspend fun onDragStopped(velocity: Float, awaitFling: suspend () -> Unit): Float {
        val currentState = motionValue[AttachDetachState]
        with(requireDensity()) {
            val isFlingInOppositeDirection =
                abs(velocity) > AbortVelocity.toPx() && velocity.sign != overscrollSign

            val settleAttached =
                currentState == MagneticDetach.State.Attached ||
                    (currentState == MagneticDetach.State.Detached && isFlingInOppositeDirection)

            motionValue.spec =
                if (settleAttached) {
                    motionBuilderContext.fixedSpatialValueSpec(0f, SnapBackSpring)
                } else {
                    motionBuilderContext.fixedSpatialValueSpec(
                        contentBoxWidth.toFloat(),
                        SnapBackSpring,
                        listOf(isDismissedState with true),
                    )
                }
        }
        return velocity
    }

    private suspend fun keepRunningUntilDismissed() {
        val debuggerHandle = findMotionValueDebugger()?.register(motionValue)
        try {
            motionValue.keepRunningWhile {
                val isDismissed = get(isDismissedState) ?: false
                !(isDismissed && isStable)
            }
            onDismissed()
        } finally {
            debuggerHandle?.dispose()
        }
    }

    companion object {
        val isDismissedState = SemanticKey<Boolean>("isDismissed")
        val AbortVelocity = 100.dp // dp/s
        val SnapBackSpring = SpringParameters(stiffness = 550f, dampingRatio = 0.95f)
    }
}
+3 −0
Original line number Diff line number Diff line
@@ -35,6 +35,8 @@ android_test {

    static_libs: [
        "PlatformComposeCore",
        "PlatformMotionTestingCompose",
        "platform-test-annotations",

        "androidx.test.runner",
        "androidx.test.ext.junit",
@@ -45,6 +47,7 @@ android_test {

        "truth",
    ],
    asset_dirs: ["goldens"],

    kotlincflags: ["-Xjvm-default=all"],
    use_resource_processor: true,
+1 −1
Original line number Diff line number Diff line
@@ -23,7 +23,7 @@

        <activity
            android:name="androidx.activity.ComponentActivity"
            android:theme="@android:style/Theme.DeviceDefault.DayNight"
            android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
            android:exported="true"
            tools:replace="android:theme" />
    </application>
Loading