All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.gestures.Draggable2D.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

There is a newer version: 1.7.1
Show newest version
/*
 * Copyright 2023 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 androidx.compose.foundation.gestures

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.gestures.DragEvent.DragDelta
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

/**
 * State of Draggable2D. Allows for granular control of how deltas are consumed by the user as well
 * as to write custom drag methods using [drag] suspend function.
 */
@ExperimentalFoundationApi
interface Draggable2DState {
    /**
     * Call this function to take control of drag logic.
     *
     * All actions that change the logical drag position must be performed within a [drag]
     * block (even if they don't call any other methods on this object) in order to guarantee
     * that mutual exclusion is enforced.
     *
     * If [drag] is called from elsewhere with the [dragPriority] higher or equal to ongoing
     * drag, ongoing drag will be canceled.
     *
     * @param dragPriority of the drag operation
     * @param block to perform drag in
     */
    suspend fun drag(
        dragPriority: MutatePriority = MutatePriority.Default,
        block: suspend Drag2DScope.() -> Unit
    )

    /**
     * Dispatch drag delta in pixels avoiding all drag related priority mechanisms.
     *
     * **Note:** unlike [drag], dispatching any delta with this method will bypass scrolling of
     * any priority. This method will also ignore `reverseDirection` and other parameters set in
     * draggable2D.
     *
     * This method is used internally for low level operations, allowing implementers of
     * [Draggable2DState] influence the consumption as suits them.
     * Manually dispatching delta via this method will likely result in a bad user experience,
     * you must prefer [drag] method over this one.
     *
     * @param delta amount of scroll dispatched in the nested drag process
     */
    fun dispatchRawDelta(delta: Offset)
}

/**
 * Scope used for suspending drag blocks
 */
@ExperimentalFoundationApi
interface Drag2DScope {
    /**
     * Attempts to drag by [pixels] px.
     */
    fun dragBy(pixels: Offset)
}

/**
 * Default implementation of [Draggable2DState] interface that allows to pass a simple action that
 * will be invoked when the drag occurs.
 *
 * This is the simplest way to set up a draggable2D modifier. When constructing this
 * [Draggable2DState], you must provide a [onDelta] lambda, which will be invoked whenever
 * drag happens (by gesture input or a custom [Draggable2DState.drag] call) with the delta in
 * pixels.
 *
 * If you are creating [Draggable2DState] in composition, consider using [rememberDraggable2DState].
 *
 * @param onDelta callback invoked when drag occurs. The callback receives the delta in pixels.
 */
@ExperimentalFoundationApi
fun Draggable2DState(onDelta: (Offset) -> Unit): Draggable2DState =
    DefaultDraggable2DState(onDelta)

/**
 * Create and remember default implementation of [Draggable2DState] interface that allows to pass a
 * simple action that will be invoked when the drag occurs.
 *
 * This is the simplest way to set up a [draggable2D] modifier. When constructing this
 * [Draggable2DState], you must provide a [onDelta] lambda, which will be invoked whenever
 * drag happens (by gesture input or a custom [Draggable2DState.drag] call) with the delta in
 * pixels.
 *
 * @param onDelta callback invoked when drag occurs. The callback receives the delta in pixels.
 */
@ExperimentalFoundationApi
@Composable
fun rememberDraggable2DState(onDelta: (Offset) -> Unit): Draggable2DState {
    val onDeltaState = rememberUpdatedState(onDelta)
    return remember { Draggable2DState { onDeltaState.value.invoke(it) } }
}

/**
 * Configure touch dragging for the UI element in both orientations. The drag distance
 * reported to [Draggable2DState], allowing users to react to the drag delta and update their state.
 *
 * The common common usecase for this component is when you need to be able to drag something
 * inside the component on the screen and represent this state via one float value
 *
 * If you are implementing dragging in a single orientation, consider using [draggable].
 *
 * @sample androidx.compose.foundation.samples.Draggable2DSample
 *
 * @param state [Draggable2DState] state of the draggable2D. Defines how drag events will be
 * interpreted by the user land logic.
 * @param enabled whether or not drag is enabled
 * @param interactionSource [MutableInteractionSource] that will be used to emit
 * [DragInteraction.Start] when this draggable is being dragged.
 * @param startDragImmediately when set to true, draggable2D will start dragging immediately and
 * prevent other gesture detectors from reacting to "down" events (in order to block composed
 * press-based gestures). This is intended to allow end users to "catch" an animating widget by
 * pressing on it. It's useful to set it when value you're dragging is settling / animating.
 * @param onDragStarted callback that will be invoked when drag is about to start at the starting
 * position, allowing user to perform preparation for drag.
 * @param onDragStopped callback that will be invoked when drag is finished, allowing the
 * user to react on velocity and process it.
 * @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will
 * behave like bottom to top and left to right will behave like right to left.
 */
@ExperimentalFoundationApi
@Stable
fun Modifier.draggable2D(
    state: Draggable2DState,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    startDragImmediately: Boolean = false,
    onDragStarted: (startedPosition: Offset) -> Unit = NoOpOnDragStart,
    onDragStopped: (velocity: Velocity) -> Unit = NoOpOnDragStop,
    reverseDirection: Boolean = false
): Modifier = this then Draggable2DElement(
    state = state,
    enabled = enabled,
    interactionSource = interactionSource,
    startDragImmediately = startDragImmediately,
    onDragStarted = onDragStarted,
    onDragStopped = onDragStopped,
    reverseDirection = reverseDirection
)

/**
 * Configure touch dragging for the UI element in both orientations. The drag distance
 * reported to [Draggable2DState], allowing users to react to the drag delta and update their state.
 *
 * The common common usecase for this component is when you need to be able to drag something
 * inside the component on the screen and represent this state via one float value
 *
 * If you are implementing dragging in a single orientation, consider using [draggable].
 *
 * @sample androidx.compose.foundation.samples.Draggable2DSample
 *
 * @param state [Draggable2DState] state of the draggable2D. Defines how drag events will be
 * interpreted by the user land logic.
 * @param enabled whether or not drag is enabled
 * @param interactionSource [MutableInteractionSource] that will be used to emit
 * [DragInteraction.Start] when this draggable is being dragged.
 * @param startDragImmediately when set to true, draggable2D will start dragging immediately and
 * prevent other gesture detectors from reacting to "down" events (in order to block composed
 * press-based gestures). This is intended to allow end users to "catch" an animating widget by
 * pressing on it. It's useful to set it when value you're dragging is settling / animating.
 * @param onDragStarted callback that will be invoked when drag is about to start at the starting
 * position, allowing user to suspend and perform preparation for drag, if desired.This suspend
 * function is invoked with the draggable2D scope, allowing for async processing, if desired. Note
 * that the scope used here is the one provided by the draggable2D node, for long-running work that
 * needs to outlast the modifier being in the composition you should use a scope that fits the
 * lifecycle needed.
 * @param onDragStopped callback that will be invoked when drag is finished, allowing the
 * user to react on velocity and process it. This suspend function is invoked with the draggable2D
 * scope, allowing for async processing, if desired. Note that the scope used here is the one
 * provided by the draggable2D scope, for long-running work that needs to outlast the modifier being
 * in the composition you should use a scope that fits the lifecycle needed.
 * @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will
 * behave like bottom to top and left to right will behave like right to left.
 */
@Deprecated(
    "Please use overload without the suspend onDragStarted onDragStopped and callbacks",
    level = DeprecationLevel.HIDDEN
)
@ExperimentalFoundationApi
@Stable
fun Modifier.draggable2D(
    state: Draggable2DState,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    startDragImmediately: Boolean = false,
    onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = NoOpOnDragStarted,
    onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit = NoOpOnDragStopped,
    reverseDirection: Boolean = false
): Modifier = this then Draggable2DCompatElement(
    state = state,
    enabled = enabled,
    interactionSource = interactionSource,
    startDragImmediately = startDragImmediately,
    onDragStarted = onDragStarted,
    onDragStopped = onDragStopped,
    reverseDirection = reverseDirection
)

@OptIn(ExperimentalFoundationApi::class)
internal class Draggable2DCompatElement(
    private val state: Draggable2DState,
    private val enabled: Boolean,
    private val interactionSource: MutableInteractionSource?,
    private val startDragImmediately: Boolean,
    private val onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit,
    private val onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit,
    private val reverseDirection: Boolean
) : ModifierNodeElement() {
    override fun create(): Draggable2DNode = Draggable2DNode(
        state,
        CanDrag,
        enabled,
        interactionSource,
        startDragImmediately,
        reverseDirection,
        onDragStarted = onDragStarted,
        onDragStopped = onDragStopped,
    )

    override fun update(node: Draggable2DNode) {
        node.update(
            state,
            CanDrag,
            enabled,
            interactionSource,
            startDragImmediately,
            reverseDirection,
            onDragStarted = onDragStarted,
            onDragStopped = onDragStopped,
        )
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other === null) return false
        if (this::class != other::class) return false

        other as Draggable2DCompatElement

        if (state != other.state) return false
        if (enabled != other.enabled) return false
        if (interactionSource != other.interactionSource) return false
        if (startDragImmediately != other.startDragImmediately) return false
        if (onDragStarted !== other.onDragStarted) return false
        if (onDragStopped !== other.onDragStopped) return false
        if (reverseDirection != other.reverseDirection) return false

        return true
    }

    override fun hashCode(): Int {
        var result = state.hashCode()
        result = 31 * result + enabled.hashCode()
        result = 31 * result + (interactionSource?.hashCode() ?: 0)
        result = 31 * result + startDragImmediately.hashCode()
        result = 31 * result + onDragStarted.hashCode()
        result = 31 * result + onDragStopped.hashCode()
        result = 31 * result + reverseDirection.hashCode()
        return result
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "draggable2D"
        properties["enabled"] = enabled
        properties["interactionSource"] = interactionSource
        properties["startDragImmediately"] = startDragImmediately
        properties["onDragStarted"] = onDragStarted
        properties["onDragStopped"] = onDragStopped
        properties["reverseDirection"] = reverseDirection
        properties["state"] = state
    }

    companion object {
        val CanDrag: (PointerInputChange) -> Boolean = { true }
    }
}

@OptIn(ExperimentalFoundationApi::class)
internal class Draggable2DElement(
    private val state: Draggable2DState,
    private val enabled: Boolean,
    private val interactionSource: MutableInteractionSource?,
    private val startDragImmediately: Boolean,
    private val onDragStarted: (startedPosition: Offset) -> Unit,
    private val onDragStopped: (velocity: Velocity) -> Unit,
    private val reverseDirection: Boolean
) : ModifierNodeElement() {
    override fun create(): Draggable2DNode = Draggable2DNode(
        state,
        CanDrag,
        enabled,
        interactionSource,
        startDragImmediately,
        reverseDirection,
        onDragStart = onDragStarted,
        onDragStop = onDragStopped,
    )

    override fun update(node: Draggable2DNode) {
        node.update(
            state,
            CanDrag,
            enabled,
            interactionSource,
            startDragImmediately,
            reverseDirection,
            onDragStart = onDragStarted,
            onDragStop = onDragStopped,
        )
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other === null) return false
        if (this::class != other::class) return false

        other as Draggable2DElement

        if (state != other.state) return false
        if (enabled != other.enabled) return false
        if (interactionSource != other.interactionSource) return false
        if (startDragImmediately != other.startDragImmediately) return false
        if (onDragStarted !== other.onDragStarted) return false
        if (onDragStopped !== other.onDragStopped) return false
        if (reverseDirection != other.reverseDirection) return false

        return true
    }

    override fun hashCode(): Int {
        var result = state.hashCode()
        result = 31 * result + enabled.hashCode()
        result = 31 * result + (interactionSource?.hashCode() ?: 0)
        result = 31 * result + startDragImmediately.hashCode()
        result = 31 * result + onDragStarted.hashCode()
        result = 31 * result + onDragStopped.hashCode()
        result = 31 * result + reverseDirection.hashCode()
        return result
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "draggable2D"
        properties["enabled"] = enabled
        properties["interactionSource"] = interactionSource
        properties["startDragImmediately"] = startDragImmediately
        properties["onDragStarted"] = onDragStarted
        properties["onDragStopped"] = onDragStopped
        properties["reverseDirection"] = reverseDirection
        properties["state"] = state
    }

    companion object {
        val CanDrag: (PointerInputChange) -> Boolean = { true }
    }
}

@OptIn(ExperimentalFoundationApi::class)
internal class Draggable2DNode(
    private var state: Draggable2DState,
    canDrag: (PointerInputChange) -> Boolean,
    enabled: Boolean,
    interactionSource: MutableInteractionSource?,
    private var startDragImmediately: Boolean,
    private var reverseDirection: Boolean,
    // keeping both lambdas for compatibility
    private var onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit =
        NoOpOnDragStarted,
    private var onDragStart: (startedPosition: Offset) -> Unit =
        NoOpOnDragStart,
    private var onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit =
        NoOpOnDragStopped,
    private var onDragStop: (velocity: Velocity) -> Unit = NoOpOnDragStop,
) : DragGestureNode(
    canDrag = canDrag,
    enabled = enabled,
    interactionSource = interactionSource,
    orientationLock = null
) {

    override suspend fun drag(
        forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit
    ) {
        state.drag(MutatePriority.UserInput) {
            forEachDelta { dragDelta ->
                dragBy(dragDelta.delta.reverseIfNeeded())
            }
        }
    }

    override fun onDragStarted(startedPosition: Offset) {
        onDragStart.invoke(startedPosition)
        // do not launch if callback is no no-op
        if (!isAttached || onDragStarted === NoOpOnDragStarted) return
        coroutineScope.launch {
            [email protected](this, startedPosition)
        }
    }

    override fun onDragStopped(velocity: Velocity) {
        onDragStop.invoke(velocity)
        // do not launch if callback is no no-op
        if (!isAttached || onDragStopped === NoOpOnDragStopped) return
        coroutineScope.launch {
            [email protected](this, velocity.reverseIfNeeded())
        }
    }

    override fun startDragImmediately(): Boolean = startDragImmediately

    fun update(
        state: Draggable2DState,
        canDrag: (PointerInputChange) -> Boolean,
        enabled: Boolean,
        interactionSource: MutableInteractionSource?,
        startDragImmediately: Boolean,
        reverseDirection: Boolean,
        onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit =
            this.onDragStarted,
        onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit =
            this.onDragStopped,
        onDragStart: (startedPosition: Offset) -> Unit = this.onDragStart,
        onDragStop: (velocity: Velocity) -> Unit = this.onDragStop,
    ) {
        var resetPointerInputHandling = false
        if (this.state != state) {
            this.state = state
            resetPointerInputHandling = true
        }
        if (this.reverseDirection != reverseDirection) {
            this.reverseDirection = reverseDirection
            resetPointerInputHandling = true
        }

        this.onDragStarted = onDragStarted
        this.onDragStopped = onDragStopped
        this.onDragStart = onDragStart
        this.onDragStop = onDragStop
        this.startDragImmediately = startDragImmediately

        update(
            canDrag = canDrag,
            enabled = enabled,
            interactionSource = interactionSource,
            orientationLock = null,
            shouldResetPointerInputHandling = resetPointerInputHandling
        )
    }

    private fun Velocity.reverseIfNeeded() = if (reverseDirection) this * -1f else this * 1f
    private fun Offset.reverseIfNeeded() = if (reverseDirection) this * -1f else this * 1f
}

@OptIn(ExperimentalFoundationApi::class)
private class DefaultDraggable2DState(val onDelta: (Offset) -> Unit) : Draggable2DState {
    private val drag2DScope: Drag2DScope = object : Drag2DScope {
        override fun dragBy(pixels: Offset) = onDelta(pixels)
    }

    private val drag2DMutex = MutatorMutex()

    override suspend fun drag(
        dragPriority: MutatePriority,
        block: suspend Drag2DScope.() -> Unit
    ): Unit = coroutineScope {
        drag2DMutex.mutateWith(drag2DScope, dragPriority, block)
    }

    override fun dispatchRawDelta(delta: Offset) {
        return onDelta(delta)
    }
}

private val NoOpOnDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}
private val NoOpOnDragStart: (startedPosition: Offset) -> Unit = {}
private val NoOpOnDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit = {}
private val NoOpOnDragStop: (velocity: Velocity) -> Unit = {}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy