Introduce AnchoredHorizontalDraggable to prevent unintentional drags
This commit is contained in:
1 parent
c750016034
commit
95bfc4f8c5
8 files changed
+889
-53
No files matched your search
+278
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Copyright 2022 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.gestures.DragEvent.DragDelta
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.node.LayoutModifierNode
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.node.requireLayoutDirection
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
fun <T> Modifier.anchoredHorizontalDraggable(
|
||||
state: AnchoredDraggableState<T>,
|
||||
enableDragFromStartToEnd: Boolean = true,
|
||||
enableDragFromEndToStart: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
startDragImmediately: Boolean = state.isAnimationRunning,
|
||||
): Modifier = this then AnchoredHorizontalDraggableElement(
|
||||
state = state,
|
||||
canDrag = { (x, y) ->
|
||||
if (x > 0) {
|
||||
enableDragFromStartToEnd
|
||||
} else {
|
||||
enableDragFromEndToStart
|
||||
} && x.absoluteValue > y.absoluteValue * 2f
|
||||
},
|
||||
enabled = enabled,
|
||||
startDragImmediately = startDragImmediately,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private class AnchoredHorizontalDraggableElement<T>(
|
||||
private val state: AnchoredDraggableState<T>,
|
||||
private val canDrag: (Offset) -> Boolean,
|
||||
private val enabled: Boolean,
|
||||
private val startDragImmediately: Boolean,
|
||||
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
|
||||
override fun create() = AnchoredHorizontalDraggableNode(
|
||||
state,
|
||||
canDrag,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
)
|
||||
|
||||
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
|
||||
node.update(
|
||||
state,
|
||||
canDrag,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + canDrag.hashCode()
|
||||
result = 31 * result + enabled.hashCode()
|
||||
result = 31 * result + startDragImmediately.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
if (other !is AnchoredHorizontalDraggableElement<*>) return false
|
||||
|
||||
if (state != other.state) return false
|
||||
if (canDrag != other.canDrag) return false
|
||||
if (enabled != other.enabled) return false
|
||||
if (startDragImmediately != other.startDragImmediately) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
name = "anchoredDraggable"
|
||||
properties["state"] = state
|
||||
properties["canDrag"] = canDrag
|
||||
properties["enabled"] = enabled
|
||||
properties["startDragImmediately"] = startDragImmediately
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private class AnchoredHorizontalDraggableNode<T>(
|
||||
private var state: AnchoredDraggableState<T>,
|
||||
private var canDrag: (Offset) -> Boolean,
|
||||
enabled: Boolean,
|
||||
private var startDragImmediately: Boolean,
|
||||
) : HorizontalDragGestureNode(
|
||||
enabled = enabled,
|
||||
) {
|
||||
|
||||
private val isReverseDirection: Boolean
|
||||
get() = requireLayoutDirection() == LayoutDirection.Rtl
|
||||
|
||||
override fun canDrag(change: PointerInputChange): Boolean {
|
||||
return canDrag(change.positionChange().reverseIfNeeded())
|
||||
}
|
||||
|
||||
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
|
||||
state.anchoredDrag {
|
||||
forEachDelta { dragDelta ->
|
||||
dragTo(state.newOffsetForDelta(dragDelta.delta.reverseIfNeeded().x))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDragStarted(startedPosition: Offset) { }
|
||||
|
||||
override fun onDragStopped(velocity: Velocity) {
|
||||
if (!isAttached) return
|
||||
coroutineScope.launch {
|
||||
state.settle(velocity.reverseIfNeeded().x)
|
||||
}
|
||||
}
|
||||
|
||||
override fun startDragImmediately(): Boolean = startDragImmediately
|
||||
|
||||
fun update(
|
||||
state: AnchoredDraggableState<T>,
|
||||
canDrag: (Offset) -> Boolean,
|
||||
enabled: Boolean,
|
||||
startDragImmediately: Boolean,
|
||||
) {
|
||||
var resetPointerInputHandling = false
|
||||
|
||||
if (this.state != state) {
|
||||
this.state = state
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
|
||||
this.canDrag = canDrag
|
||||
this.startDragImmediately = startDragImmediately
|
||||
|
||||
update(
|
||||
enabled = enabled,
|
||||
shouldResetPointerInputHandling = resetPointerInputHandling,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Velocity.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
|
||||
private fun Offset.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
|
||||
}
|
||||
|
||||
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
|
||||
((if (offset.isNaN()) 0f else offset) + delta)
|
||||
.coerceIn(anchors.minAnchor(), anchors.maxAnchor())
|
||||
|
||||
/**
|
||||
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
|
||||
* node's size and offsetting it.
|
||||
* It considers lookahead and reports the appropriate size and measurement for the appropriate
|
||||
* phase.
|
||||
*
|
||||
* @param state The state the anchors should be attached to
|
||||
* @param orientation The orientation the component should be offset in
|
||||
* @param anchors Lambda to calculate the anchors based on this layout's size and the incoming
|
||||
* constraints. These can be useful to avoid subcomposition.
|
||||
*/
|
||||
internal fun <T> Modifier.draggableAnchors(
|
||||
state: AnchoredDraggableState<T>,
|
||||
orientation: Orientation,
|
||||
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
) = this then DraggableAnchorsElement(state, anchors, orientation)
|
||||
|
||||
private class DraggableAnchorsElement<T>(
|
||||
private val state: AnchoredDraggableState<T>,
|
||||
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
private val orientation: Orientation,
|
||||
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
|
||||
|
||||
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
|
||||
|
||||
override fun update(node: DraggableAnchorsNode<T>) {
|
||||
node.state = state
|
||||
node.anchors = anchors
|
||||
node.orientation = orientation
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
if (other !is DraggableAnchorsElement<*>) return false
|
||||
|
||||
if (state != other.state) return false
|
||||
if (anchors !== other.anchors) return false
|
||||
if (orientation != other.orientation) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + anchors.hashCode()
|
||||
result = 31 * result + orientation.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
debugInspectorInfo {
|
||||
properties["state"] = state
|
||||
properties["anchors"] = anchors
|
||||
properties["orientation"] = orientation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DraggableAnchorsNode<T>(
|
||||
var state: AnchoredDraggableState<T>,
|
||||
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
var orientation: Orientation,
|
||||
) : Modifier.Node(), LayoutModifierNode {
|
||||
private var didLookahead: Boolean = false
|
||||
|
||||
override fun onDetach() {
|
||||
didLookahead = false
|
||||
}
|
||||
|
||||
override fun MeasureScope.measure(
|
||||
measurable: Measurable,
|
||||
constraints: Constraints,
|
||||
): MeasureResult {
|
||||
val placeable = measurable.measure(constraints)
|
||||
// If we are in a lookahead pass, we only want to update the anchors here and not in
|
||||
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
|
||||
// update the anchors in the main pass.
|
||||
if (!isLookingAhead || !didLookahead) {
|
||||
val size = IntSize(placeable.width, placeable.height)
|
||||
val newAnchorResult = anchors(size, constraints)
|
||||
state.updateAnchors(newAnchorResult.first, newAnchorResult.second)
|
||||
}
|
||||
didLookahead = isLookingAhead || didLookahead
|
||||
return layout(placeable.width, placeable.height) {
|
||||
// In a lookahead pass, we use the position of the current target as this is where any
|
||||
// ongoing animations would move. If the component is in a settled state, lookahead
|
||||
// and post-lookahead will converge.
|
||||
val offset = if (isLookingAhead) {
|
||||
state.anchors.positionOf(state.targetValue)
|
||||
} else {
|
||||
state.requireOffset()
|
||||
}
|
||||
val xOffset = if (orientation == Orientation.Horizontal) offset else 0f
|
||||
val yOffset = if (orientation == Orientation.Vertical) offset else 0f
|
||||
placeable.placeRelative(xOffset.roundToInt(), yOffset.roundToInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2020 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.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* A Gesture detector that waits for pointer down and touch slop in the horizontal direction and
|
||||
* then calls [onDrag] for each drag event.
|
||||
* It follows the touch slop detection of [awaitHorizontalTouchSlopOrCancellation] but will consume
|
||||
* the position change automatically once the touch slop has been crossed, the amount of drag over
|
||||
* the touch slop is reported as the first drag event [onDrag] after the slop is crossed.
|
||||
* If [shouldAwaitTouchSlop] returns true the touch slop recognition phase will be ignored
|
||||
* and the drag gesture will be recognized immediately.The first [onDrag] in this case will report
|
||||
* an [Offset.Zero].
|
||||
*
|
||||
* [onDragStart] is called when the touch slop has been passed and includes an [Offset] representing
|
||||
* the last known pointer position relative to the containing element as well as the initial
|
||||
* down event that triggered this gesture detection cycle. The [Offset] can be outside
|
||||
* the actual bounds of the element itself meaning the numbers can be negative or larger than the
|
||||
* element bounds if the touch target is smaller than the
|
||||
* [ViewConfiguration.minimumTouchTargetSize].
|
||||
*
|
||||
* [onDragEnd] is called after all pointers are up with the event change of the up event
|
||||
* and [onDragCancel] is called if another gesture has consumed pointer input,
|
||||
* canceling this gesture.
|
||||
*
|
||||
* @param onDragStart A lambda to be called when the drag gesture starts, it contains information
|
||||
* about the last known [PointerInputChange] relative to the containing element and the post slop
|
||||
* delta.
|
||||
* @param onDragEnd A lambda to be called when the gesture ends. It contains information about the
|
||||
* up [PointerInputChange] that finished the gesture.
|
||||
* @param onDragCancel A lambda to be called when the gesture is cancelled either by an error or
|
||||
* when it was consumed.
|
||||
* @param shouldAwaitTouchSlop Indicates if touch slop detection should be skipped.
|
||||
* @param onDrag A lambda to be called for each delta event in the gesture. It contains information
|
||||
* about the [PointerInputChange] and the movement offset.
|
||||
*/
|
||||
internal suspend fun PointerInputScope.detectHorizontalDragGestures(
|
||||
canDrag: (PointerInputChange) -> Boolean,
|
||||
onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onDragCancel: () -> Unit,
|
||||
shouldAwaitTouchSlop: () -> Boolean,
|
||||
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val initialDown =
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
val awaitTouchSlop = shouldAwaitTouchSlop()
|
||||
|
||||
if (!awaitTouchSlop) {
|
||||
initialDown.consume()
|
||||
}
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var drag: PointerInputChange?
|
||||
var overSlop = Offset.Zero
|
||||
|
||||
if (awaitTouchSlop) {
|
||||
do {
|
||||
drag = awaitHorizontalTouchSlopOrCancellation(
|
||||
down.id,
|
||||
) { change, over ->
|
||||
if (canDrag.invoke(change)) {
|
||||
change.consume()
|
||||
overSlop = Offset(over, 0f)
|
||||
} else {
|
||||
throw CancellationException()
|
||||
}
|
||||
}
|
||||
} while (drag != null && !drag.isConsumed)
|
||||
} else {
|
||||
drag = initialDown
|
||||
}
|
||||
|
||||
if (drag != null) {
|
||||
onDragStart.invoke(drag, overSlop)
|
||||
onDrag(drag, overSlop)
|
||||
val dragEnd = horizontalDrag(
|
||||
pointerId = drag.id,
|
||||
onDrag = {
|
||||
onDrag(it, it.positionChange())
|
||||
it.consume()
|
||||
},
|
||||
)
|
||||
if (dragEnd) {
|
||||
onDragEnd()
|
||||
} else {
|
||||
onDragCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 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.gestures.DragEvent.DragCancelled
|
||||
import androidx.compose.foundation.gestures.DragEvent.DragDelta
|
||||
import androidx.compose.foundation.gestures.DragEvent.DragStarted
|
||||
import androidx.compose.foundation.gestures.DragEvent.DragStopped
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
|
||||
import androidx.compose.ui.node.DelegatingNode
|
||||
import androidx.compose.ui.node.PointerInputModifierNode
|
||||
import androidx.compose.ui.node.currentValueOf
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A node that performs drag gesture recognition and event propagation.
|
||||
*/
|
||||
internal abstract class HorizontalDragGestureNode(
|
||||
enabled: Boolean,
|
||||
) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode {
|
||||
|
||||
protected var enabled = enabled
|
||||
private set
|
||||
|
||||
private var channel: Channel<DragEvent>? = null
|
||||
private var isListeningForEvents = false
|
||||
|
||||
abstract fun canDrag(change: PointerInputChange): Boolean
|
||||
|
||||
/**
|
||||
* Responsible for the dragging behavior between the start and the end of the drag. It
|
||||
* continually invokes `forEachDelta` to process incoming events. In return, `forEachDelta`
|
||||
* calls `dragBy` method to process each individual delta.
|
||||
*/
|
||||
abstract suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit)
|
||||
|
||||
/**
|
||||
* Passes the action needed when a drag starts. This gives the ability to pass the desired
|
||||
* behavior from other nodes implementing AbstractDraggableNode
|
||||
*/
|
||||
abstract fun onDragStarted(startedPosition: Offset)
|
||||
|
||||
/**
|
||||
* Passes the action needed when a drag stops. This gives the ability to pass the desired
|
||||
* behavior from other nodes implementing AbstractDraggableNode
|
||||
*/
|
||||
abstract fun onDragStopped(velocity: Velocity)
|
||||
|
||||
/**
|
||||
* If touch slop recognition should be skipped. If this is true, this node will start
|
||||
* recognizing drag events immediately without waiting for touch slop.
|
||||
*/
|
||||
abstract fun startDragImmediately(): Boolean
|
||||
|
||||
private fun startListeningForEvents() {
|
||||
isListeningForEvents = true
|
||||
|
||||
/**
|
||||
* To preserve the original behavior we had (before the Modifier.Node migration) we need to
|
||||
* scope the DragStopped and DragCancel methods to the node's coroutine scope instead of using
|
||||
* the one provided by the pointer input modifier, this is to ensure that even when the pointer
|
||||
* input scope is reset we will continue any coroutine scope scope that we started from these
|
||||
* methods while the pointer input scope was active.
|
||||
*/
|
||||
coroutineScope.launch {
|
||||
while (isActive) {
|
||||
var event = channel?.receive()
|
||||
if (event !is DragStarted) continue
|
||||
onDragStarted(event.startPoint)
|
||||
try {
|
||||
drag { processDelta ->
|
||||
while (event !is DragStopped && event !is DragCancelled) {
|
||||
(event as? DragDelta)?.let(processDelta)
|
||||
event = channel?.receive()
|
||||
}
|
||||
}
|
||||
if (event is DragStopped) {
|
||||
onDragStopped((event as DragStopped).velocity)
|
||||
} else if (event is DragCancelled) {
|
||||
onDragStopped(Velocity.Zero)
|
||||
}
|
||||
} catch (c: CancellationException) {
|
||||
onDragStopped(Velocity.Zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pointerInputNode: SuspendingPointerInputModifierNode? = null
|
||||
|
||||
override fun onDetach() {
|
||||
isListeningForEvents = false
|
||||
}
|
||||
|
||||
override fun onPointerEvent(
|
||||
pointerEvent: PointerEvent,
|
||||
pass: PointerEventPass,
|
||||
bounds: IntSize,
|
||||
) {
|
||||
if (enabled && pointerInputNode == null) {
|
||||
pointerInputNode = delegate(initializePointerInputNode())
|
||||
}
|
||||
pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
|
||||
}
|
||||
|
||||
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode {
|
||||
return SuspendingPointerInputModifierNode {
|
||||
// re-create tracker when pointer input block restarts. This lazily creates the tracker
|
||||
// only when it is need.
|
||||
val velocityTracker = VelocityTracker()
|
||||
val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit =
|
||||
{ startEvent, initialDelta ->
|
||||
if (!isListeningForEvents) {
|
||||
if (channel == null) {
|
||||
channel = Channel(capacity = Channel.UNLIMITED)
|
||||
}
|
||||
startListeningForEvents()
|
||||
}
|
||||
val xSign = sign(startEvent.position.x)
|
||||
val ySign = sign(startEvent.position.y)
|
||||
val adjustedStart = startEvent.position -
|
||||
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
|
||||
|
||||
channel?.trySend(DragStarted(adjustedStart))
|
||||
}
|
||||
|
||||
val onDragEnd: () -> Unit = {
|
||||
val maximumVelocity = currentValueOf(LocalViewConfiguration)
|
||||
.maximumFlingVelocity
|
||||
val velocity = velocityTracker.calculateVelocity(
|
||||
Velocity(maximumVelocity, maximumVelocity),
|
||||
)
|
||||
velocityTracker.resetTracking()
|
||||
channel?.trySend(DragStopped(velocity))
|
||||
}
|
||||
|
||||
val onDragCancel: () -> Unit = {
|
||||
channel?.trySend(DragCancelled)
|
||||
}
|
||||
|
||||
val shouldAwaitTouchSlop: () -> Boolean = {
|
||||
!startDragImmediately()
|
||||
}
|
||||
|
||||
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
|
||||
{ change, delta ->
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
channel?.trySend(DragDelta(delta))
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
try {
|
||||
detectHorizontalDragGestures(
|
||||
canDrag = ::canDrag,
|
||||
onDragStart = onDragStart,
|
||||
onDragEnd = onDragEnd,
|
||||
onDragCancel = onDragCancel,
|
||||
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
|
||||
onDrag = onDrag,
|
||||
)
|
||||
} catch (cancellation: CancellationException) {
|
||||
channel?.trySend(DragCancelled)
|
||||
if (!isActive) throw cancellation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelPointerInput() {
|
||||
pointerInputNode?.onCancelPointerInput()
|
||||
}
|
||||
|
||||
fun update(
|
||||
enabled: Boolean = this.enabled,
|
||||
shouldResetPointerInputHandling: Boolean = false,
|
||||
) {
|
||||
var resetPointerInputHandling = shouldResetPointerInputHandling
|
||||
|
||||
if (this.enabled != enabled) {
|
||||
this.enabled = enabled
|
||||
if (!enabled) {
|
||||
pointerInputNode?.let { undelegate(it) }
|
||||
pointerInputNode = null
|
||||
}
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
|
||||
if (resetPointerInputHandling) {
|
||||
pointerInputNode?.resetPointerInputHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DragEvent {
|
||||
class DragStarted(val startPoint: Offset) : DragEvent()
|
||||
class DragStopped(val velocity: Velocity) : DragEvent()
|
||||
object DragCancelled : DragEvent()
|
||||
class DragDelta(val delta: Offset) : DragEvent()
|
||||
}
|
||||
@@ -10,11 +10,8 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
|
||||
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.horizontalDrag
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -42,17 +39,12 @@ import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
|
||||
@@ -268,44 +260,14 @@ fun ModalSideDrawer(
|
||||
)
|
||||
}
|
||||
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val velocityTracker = remember { VelocityTracker() }
|
||||
val dragModifier = if (gesturesEnabled) {
|
||||
Modifier.pointerInput(isRtl) {
|
||||
val multiplier = if (isRtl) -1 else 1
|
||||
awaitEachGesture {
|
||||
fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var ignore = false
|
||||
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
|
||||
val overSlop = over * multiplier
|
||||
val canConsume = canConsume(overSlop)
|
||||
if (canConsume && !ignore) {
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
change.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
|
||||
} else {
|
||||
ignore = true
|
||||
}
|
||||
}
|
||||
if (drag != null) {
|
||||
horizontalDrag(drag.id) {
|
||||
velocityTracker.addPointerInputChange(it)
|
||||
drag.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
|
||||
}
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
velocityTracker.resetTracking()
|
||||
scope.launch {
|
||||
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier.fillMaxSize().then(dragModifier)) {
|
||||
Box(
|
||||
modifier.fillMaxSize().anchoredHorizontalDraggable(
|
||||
state = drawerState.anchoredDraggableState,
|
||||
enableDragFromStartToEnd = drawerState.isOpen,
|
||||
enableDragFromEndToStart = drawerState.isClosed,
|
||||
enabled = gesturesEnabled,
|
||||
),
|
||||
) {
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(maxValue, minValue, drawerState.currentOffset)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* Copyright 2022 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.material3.fork
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.exponentialDecay
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.gestures.draggableAnchors
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.SwipeToDismissBoxDefaults
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
private val SnapAnimationSpec = SpringSpec<Float>()
|
||||
|
||||
// TODO: Update to M3 spec once it's available
|
||||
private val DecayAnimationSpec = exponentialDecay<Float>()
|
||||
|
||||
/**
|
||||
* State of the [SwipeToDismissBox] composable.
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param density The density that this state can use to convert values to and from dp.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param positionalThreshold The positional threshold to be used when calculating the target state
|
||||
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
|
||||
* the start of a transition. It will be, depending on the direction of the interaction, added or
|
||||
* subtracted from/to the origin offset. It should always be a positive value.
|
||||
*/
|
||||
class SwipeToDismissBoxState(
|
||||
initialValue: SwipeToDismissBoxValue,
|
||||
internal val density: Density,
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
) {
|
||||
internal val anchoredDraggableState =
|
||||
AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
snapAnimationSpec = SnapAnimationSpec,
|
||||
decayAnimationSpec = DecayAnimationSpec,
|
||||
confirmValueChange = confirmValueChange,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = { with(density) { DismissVelocityThreshold.toPx() } },
|
||||
)
|
||||
|
||||
internal val offset: Float get() = anchoredDraggableState.offset
|
||||
|
||||
/**
|
||||
* Require the current offset.
|
||||
*
|
||||
* @throws IllegalStateException If the offset has not been initialized yet
|
||||
*/
|
||||
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
/**
|
||||
* The current state value of the [SwipeToDismissBoxState].
|
||||
*/
|
||||
val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue
|
||||
|
||||
/**
|
||||
* The target state. This is the closest state to the current offset (taking into account
|
||||
* positional thresholds). If no interactions like animations or drags are in progress, this
|
||||
* will be the current state.
|
||||
*/
|
||||
val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds.
|
||||
*/
|
||||
@get:FloatRange(from = 0.0, to = 1.0)
|
||||
val progress: Float get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue)
|
||||
|
||||
/**
|
||||
* The direction (if any) in which the composable has been or is being dismissed.
|
||||
*
|
||||
* Use this to change the background of the [SwipeToDismissBox] if you want different actions
|
||||
* on each side.
|
||||
*/
|
||||
val dismissDirection: SwipeToDismissBoxValue
|
||||
get() = when {
|
||||
offset == 0f || offset.isNaN() -> SwipeToDismissBoxValue.Settled
|
||||
offset > 0f -> SwipeToDismissBoxValue.StartToEnd
|
||||
else -> SwipeToDismissBoxValue.EndToStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state without any animation and suspend until it's set
|
||||
*
|
||||
* @param targetValue The new target value
|
||||
*/
|
||||
suspend fun snapTo(targetValue: SwipeToDismissBoxValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the component to the default position with animation and suspend until it if fully
|
||||
* reset or animation has been cancelled. This method will throw [CancellationException] if
|
||||
* the animation is interrupted
|
||||
*
|
||||
* @return the reason the reset animation ended
|
||||
*/
|
||||
suspend fun reset() = anchoredDraggableState.animateTo(
|
||||
targetValue = SwipeToDismissBoxValue.Settled,
|
||||
)
|
||||
|
||||
/**
|
||||
* Dismiss the component in the given [direction], with an animation and suspend. This method
|
||||
* will throw [CancellationException] if the animation is interrupted
|
||||
*
|
||||
* @param direction The dismiss direction.
|
||||
*/
|
||||
suspend fun dismiss(direction: SwipeToDismissBoxValue) {
|
||||
anchoredDraggableState.animateTo(targetValue = direction)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [SwipeToDismissBoxState].
|
||||
*/
|
||||
fun Saver(
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
density: Density,
|
||||
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
|
||||
save = { it.currentValue },
|
||||
restore = {
|
||||
SwipeToDismissBoxState(
|
||||
it,
|
||||
density,
|
||||
confirmValueChange,
|
||||
positionalThreshold,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SwipeToDismissBoxState].
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param positionalThreshold The positional threshold to be used when calculating the target state
|
||||
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
|
||||
* the start of a transition. It will be, depending on the direction of the interaction, added or
|
||||
* subtracted from/to the origin offset. It should always be a positive value.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSwipeToDismissBoxState(
|
||||
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
|
||||
positionalThreshold: (totalDistance: Float) -> Float =
|
||||
SwipeToDismissBoxDefaults.positionalThreshold,
|
||||
): SwipeToDismissBoxState {
|
||||
val density = LocalDensity.current
|
||||
return rememberSaveable(
|
||||
saver = SwipeToDismissBoxState.Saver(
|
||||
confirmValueChange = confirmValueChange,
|
||||
density = density,
|
||||
positionalThreshold = positionalThreshold,
|
||||
),
|
||||
) {
|
||||
SwipeToDismissBoxState(initialValue, density, confirmValueChange, positionalThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that can be dismissed by swiping left or right.
|
||||
*
|
||||
* @param state The state of this component.
|
||||
* @param backgroundContent A composable that is stacked behind the [content] and is exposed when
|
||||
* the content is swiped. You can/should use the [state] to have different backgrounds on each side.
|
||||
* @param modifier Optional [Modifier] for this component.
|
||||
* @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end.
|
||||
* @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start.
|
||||
* @param gesturesEnabled Whether swipe-to-dismiss can be interacted by gestures.
|
||||
* @param content The content that can be dismissed.
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeToDismissBox(
|
||||
state: SwipeToDismissBoxState,
|
||||
backgroundContent: @Composable RowScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enableDismissFromStartToEnd: Boolean = true,
|
||||
enableDismissFromEndToStart: Boolean = true,
|
||||
gesturesEnabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.anchoredHorizontalDraggable(
|
||||
state = state.anchoredDraggableState,
|
||||
enableDragFromStartToEnd = enableDismissFromStartToEnd,
|
||||
enableDragFromEndToStart = enableDismissFromEndToStart,
|
||||
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
|
||||
),
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
Row(
|
||||
content = backgroundContent,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
Row(
|
||||
content = content,
|
||||
modifier = Modifier.draggableAnchors(
|
||||
state.anchoredDraggableState,
|
||||
Orientation.Horizontal,
|
||||
) { size, _ ->
|
||||
val width = size.width.toFloat()
|
||||
return@draggableAnchors DraggableAnchors {
|
||||
SwipeToDismissBoxValue.Settled at 0f
|
||||
if (enableDismissFromStartToEnd) {
|
||||
SwipeToDismissBoxValue.StartToEnd at width
|
||||
}
|
||||
if (enableDismissFromEndToStart) {
|
||||
SwipeToDismissBoxValue.EndToStart at -width
|
||||
}
|
||||
} to state.targetValue
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val DismissVelocityThreshold = 125.dp
|
||||
@@ -48,12 +48,12 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxDefaults
|
||||
import androidx.compose.material3.SwipeToDismissBoxState
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.fork.SwipeToDismissBox
|
||||
import androidx.compose.material3.fork.SwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
||||
@@ -44,12 +44,12 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxDefaults
|
||||
import androidx.compose.material3.SwipeToDismissBoxState
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.fork.SwipeToDismissBox
|
||||
import androidx.compose.material3.fork.SwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
|
||||
@@ -13,10 +13,10 @@ import androidx.compose.material.icons.filled.ClearAll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.material3.fork.SwipeToDismissBox
|
||||
import androidx.compose.material3.fork.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
||||
Reference in new issue
Block a user