| /* |
| * Copyright 2019 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.Interaction |
| import androidx.compose.foundation.InteractionState |
| import androidx.compose.foundation.MutatePriority |
| import androidx.compose.foundation.MutatorMutex |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.DisposableEffect |
| import androidx.compose.runtime.State |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.rememberUpdatedState |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.composed |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.input.pointer.PointerInputChange |
| import androidx.compose.ui.input.pointer.PointerInputScope |
| import androidx.compose.ui.input.pointer.consumePositionChange |
| import androidx.compose.ui.input.pointer.pointerInput |
| import androidx.compose.ui.input.pointer.positionChange |
| import androidx.compose.ui.input.pointer.util.VelocityTracker |
| import androidx.compose.ui.platform.debugInspectorInfo |
| import kotlinx.coroutines.CoroutineScope |
| import kotlinx.coroutines.coroutineScope |
| import kotlin.coroutines.cancellation.CancellationException |
| import kotlin.math.sign |
| |
| /** |
| * State of [draggable]. Allows for a granular control of how deltas are consumed by the user as |
| * well as to write custom drag methods using [drag] suspend function. |
| */ |
| interface DraggableState { |
| /** |
| * 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 DragScope.() -> 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 |
| * [draggable]. |
| * |
| * This method is used internally for low level operations, allowing implementers of |
| * [DraggableState] influence the consumption as suits them, e.g introduce nested scrolling. |
| * 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: Float) |
| } |
| |
| /** |
| * Scope used for suspending drag blocks |
| */ |
| interface DragScope { |
| /** |
| * Attempts to drag by [pixels] px. |
| */ |
| fun dragBy(pixels: Float) |
| } |
| |
| /** |
| * Default implementation of [DraggableState] 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 [draggable] modifier. When constructing this |
| * [DraggableState], you must provide a [onDelta] lambda, which will be invoked whenever |
| * drag happens (by gesture input or a custom [DraggableState.drag] call) with the delta in |
| * pixels. |
| * |
| * If you are creating [DraggableState] in composition, consider using [rememberDraggableState]. |
| * |
| * @param onDelta callback invoked when drag occurs. The callback receives the delta in pixels. |
| */ |
| fun DraggableState(onDelta: (Float) -> Unit): DraggableState = |
| DefaultDraggableState(onDelta) |
| |
| /** |
| * Create and remember default implementation of [DraggableState] 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 [draggable] modifier. When constructing this |
| * [DraggableState], you must provide a [onDelta] lambda, which will be invoked whenever |
| * drag happens (by gesture input or a custom [DraggableState.drag] call) with the delta in |
| * pixels. |
| * |
| * @param onDelta callback invoked when drag occurs. The callback receives the delta in pixels. |
| */ |
| @Composable |
| fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState { |
| val onDeltaState = rememberUpdatedState(onDelta) |
| return remember { DraggableState { onDeltaState.value.invoke(it) } } |
| } |
| |
| /** |
| * Configure touch dragging for the UI element in a single [Orientation]. The drag distance |
| * reported to [DraggableState], allowing users to react on the drag delta and update their state. |
| * |
| * The 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 need to control the whole dragging flow, consider using [pointerInput] instead with the |
| * helper functions like [detectDragGestures]. |
| * |
| * If you are implementing scroll/fling behavior, consider using [scrollable]. |
| * |
| * @sample androidx.compose.foundation.samples.DraggableSample |
| * |
| * @param state [DraggableState] state of the draggable. Defines how drag events will be |
| * interpreted by the user land logic. |
| * @param orientation orientation of the drag |
| * @param enabled whether or not drag is enabled |
| * @param interactionState [InteractionState] that will be updated when this draggable is |
| * being dragged, using [Interaction.Dragged]. |
| * @param startDragImmediately when set to true, draggable 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 draggable scope, allowing for async processing, if desired |
| * @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 draggable |
| * scope, allowing for async processing, if desired |
| * @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. |
| */ |
| fun Modifier.draggable( |
| state: DraggableState, |
| orientation: Orientation, |
| enabled: Boolean = true, |
| interactionState: InteractionState? = null, |
| startDragImmediately: Boolean = false, |
| onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, |
| onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, |
| reverseDirection: Boolean = false |
| ): Modifier = composed( |
| inspectorInfo = debugInspectorInfo { |
| name = "draggable" |
| properties["orientation"] = orientation |
| properties["enabled"] = enabled |
| properties["reverseDirection"] = reverseDirection |
| properties["interactionState"] = interactionState |
| properties["startDragImmediately"] = startDragImmediately |
| properties["onDragStarted"] = onDragStarted |
| properties["onDragStopped"] = onDragStopped |
| properties["state"] = state |
| } |
| ) { |
| DisposableEffect(interactionState) { |
| onDispose { |
| interactionState?.removeInteraction(Interaction.Dragged) |
| } |
| } |
| val orientationState = rememberUpdatedState(orientation) |
| val enabledState = rememberUpdatedState(enabled) |
| val reverseDirectionState = rememberUpdatedState(reverseDirection) |
| val startImmediatelyState = rememberUpdatedState(startDragImmediately) |
| val interactionStateState = rememberUpdatedState(interactionState) |
| val onDragStartedState = rememberUpdatedState(onDragStarted) |
| val updatedDraggableState = rememberUpdatedState(state) |
| val onDragStoppedState = rememberUpdatedState(onDragStopped) |
| val dragBlock: suspend PointerInputScope.() -> Unit = remember { |
| { |
| dragForEachGesture( |
| orientation = orientationState, |
| enabled = enabledState, |
| interactionState = interactionStateState, |
| reverseDirection = reverseDirectionState, |
| startDragImmediately = startImmediatelyState, |
| onDragStarted = onDragStartedState, |
| onDragStopped = onDragStoppedState, |
| dragState = updatedDraggableState |
| ) |
| } |
| } |
| Modifier.pointerInput(Unit, dragBlock) |
| } |
| |
| private suspend fun PointerInputScope.dragForEachGesture( |
| orientation: State<Orientation>, |
| enabled: State<Boolean>, |
| reverseDirection: State<Boolean>, |
| interactionState: State<InteractionState?>, |
| startDragImmediately: State<Boolean>, |
| onDragStarted: State<suspend CoroutineScope.(startedPosition: Offset) -> Unit>, |
| onDragStopped: State<suspend CoroutineScope.(velocity: Float) -> Unit>, |
| dragState: State<DraggableState> |
| ) { |
| coroutineScope { |
| forEachGesture { |
| |
| fun isVertical() = orientation.value == Orientation.Vertical |
| fun PointerInputChange.consume(amount: Float) = this.consumePositionChange( |
| consumedDx = if (isVertical()) 0f else amount, |
| consumedDy = if (isVertical()) amount else 0f |
| ) |
| |
| suspend fun DragScope.performDrag( |
| initialDelta: Float, |
| dragStart: PointerInputChange, |
| velocityTracker: VelocityTracker |
| ): Boolean { |
| return awaitPointerEventScope { |
| dragBy(if (reverseDirection.value) initialDelta * -1 else initialDelta) |
| velocityTracker.addPosition(dragStart.uptimeMillis, dragStart.position) |
| val dragTick = { event: PointerInputChange -> |
| velocityTracker.addPosition(event.uptimeMillis, event.position) |
| val delta = |
| event.positionChange().run { if (isVertical()) y else x } |
| event.consume(delta) |
| if (enabled.value) { |
| dragBy(if (reverseDirection.value) delta * -1 else delta) |
| } |
| } |
| if (isVertical()) { |
| verticalDrag(dragStart.id, dragTick) |
| } else { |
| horizontalDrag(dragStart.id, dragTick) |
| } |
| } |
| } |
| |
| var initialDelta = 0f |
| val startEvent = awaitPointerEventScope { |
| val down = awaitFirstDown(requireUnconsumed = false) |
| if (!enabled.value) { |
| null |
| } else if (startDragImmediately.value) { |
| // since we start immediately we don't wait for slop and set initial delta to 0 |
| initialDelta = 0f |
| down |
| } else { |
| val postTouchSlop = { event: PointerInputChange, offset: Float -> |
| event.consume(event.position.run { if (isVertical()) y else x }) |
| initialDelta = offset |
| } |
| val afterSlopResult = if (isVertical()) { |
| awaitVerticalTouchSlopOrCancellation(down.id, postTouchSlop) |
| } else { |
| awaitHorizontalTouchSlopOrCancellation(down.id, postTouchSlop) |
| } |
| if (enabled.value) afterSlopResult else null |
| } |
| } |
| startEvent?.let { drag -> |
| var isDragSuccessful = false |
| val velocityTracker = VelocityTracker() |
| var enabledWhenInteractionAdded = false |
| try { |
| // remember enabled state when we add interaction to remove later if needed |
| enabledWhenInteractionAdded = enabled.value |
| val overSlopOffset = |
| if (isVertical()) Offset(0f, initialDelta) |
| else Offset(initialDelta, 0f) |
| val adjustedStart = drag.position - |
| overSlopOffset * sign(drag.position.run { if (isVertical()) y else x }) |
| if (enabledWhenInteractionAdded) { |
| onDragStarted.value.invoke(this@coroutineScope, adjustedStart) |
| interactionState.value?.addInteraction(Interaction.Dragged) |
| } |
| dragState.value.drag(dragPriority = MutatePriority.UserInput) { |
| isDragSuccessful = performDrag(initialDelta, drag, velocityTracker) |
| } |
| } catch (cancellation: CancellationException) { |
| isDragSuccessful = false |
| } finally { |
| if (enabledWhenInteractionAdded) { |
| interactionState.value?.removeInteraction(Interaction.Dragged) |
| } |
| val velocity = |
| if (isDragSuccessful) { |
| velocityTracker.calculateVelocity().run { if (isVertical()) y else x } |
| } else { |
| 0f |
| } |
| onDragStopped.value.invoke( |
| this@coroutineScope, |
| if (reverseDirection.value) velocity * -1 else velocity |
| ) |
| } |
| } |
| } |
| } |
| } |
| |
| private class DefaultDraggableState(val onDelta: (Float) -> Unit) : DraggableState { |
| |
| private val dragScope: DragScope = object : DragScope { |
| override fun dragBy(pixels: Float): Unit = onDelta(pixels) |
| } |
| |
| private val scrollMutex = MutatorMutex() |
| |
| override suspend fun drag( |
| dragPriority: MutatePriority, |
| block: suspend DragScope.() -> Unit |
| ): Unit = coroutineScope { |
| scrollMutex.mutateWith(dragScope, dragPriority, block) |
| } |
| |
| override fun dispatchRawDelta(delta: Float) { |
| return onDelta(delta) |
| } |
| } |