From 95bfc4f8c595f772952d67f9c361e951089a73c4 Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Fri, 10 May 2024 22:31:20 +0800 Subject: [PATCH] Introduce `AnchoredHorizontalDraggable` to prevent unintentional drags --- .../gestures/AnchoredHorizontalDraggable.kt | 278 ++++++++++++++++++ .../gestures/HorizontalDragGestureDetector.kt | 113 +++++++ .../gestures/HorizontalDraggable.kt | 228 ++++++++++++++ .../androidx/compose/material3/SideDrawer.kt | 56 +--- .../material3/fork/SwipeToDismissBox.fork.kt | 255 ++++++++++++++++ .../ehviewer/ui/screen/DownloadsScreen.kt | 4 +- .../ehviewer/ui/screen/GalleryListScreen.kt | 4 +- .../hippo/ehviewer/ui/screen/HistoryScreen.kt | 4 +- 8 files changed, 889 insertions(+), 53 deletions(-) create mode 100644 app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt create mode 100644 app/src/main/kotlin/androidx/compose/foundation/gestures/HorizontalDragGestureDetector.kt create mode 100644 app/src/main/kotlin/androidx/compose/foundation/gestures/HorizontalDraggable.kt create mode 100644 app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt diff --git a/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt b/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt new file mode 100644 index 000000000..57e66a823 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt @@ -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 Modifier.anchoredHorizontalDraggable( + state: AnchoredDraggableState, + 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( + private val state: AnchoredDraggableState, + private val canDrag: (Offset) -> Boolean, + private val enabled: Boolean, + private val startDragImmediately: Boolean, +) : ModifierNodeElement>() { + override fun create() = AnchoredHorizontalDraggableNode( + state, + canDrag, + enabled, + startDragImmediately, + ) + + override fun update(node: AnchoredHorizontalDraggableNode) { + 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( + private var state: AnchoredDraggableState, + 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, + 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 AnchoredDraggableState.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 Modifier.draggableAnchors( + state: AnchoredDraggableState, + orientation: Orientation, + anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, +) = this then DraggableAnchorsElement(state, anchors, orientation) + +private class DraggableAnchorsElement( + private val state: AnchoredDraggableState, + private val anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, + private val orientation: Orientation, +) : ModifierNodeElement>() { + + override fun create() = DraggableAnchorsNode(state, anchors, orientation) + + override fun update(node: DraggableAnchorsNode) { + 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( + var state: AnchoredDraggableState, + var anchors: (size: IntSize, constraints: Constraints) -> Pair, 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()) + } + } +} diff --git a/app/src/main/kotlin/androidx/compose/foundation/gestures/HorizontalDragGestureDetector.kt b/app/src/main/kotlin/androidx/compose/foundation/gestures/HorizontalDragGestureDetector.kt new file mode 100644 index 000000000..a763916f6 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/foundation/gestures/HorizontalDragGestureDetector.kt @@ -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() + } + } + } +} diff --git a/app/src/main/kotlin/androidx/compose/foundation/gestures/HorizontalDraggable.kt b/app/src/main/kotlin/androidx/compose/foundation/gestures/HorizontalDraggable.kt new file mode 100644 index 000000000..2e59bd932 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/foundation/gestures/HorizontalDraggable.kt @@ -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? = 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() +} diff --git a/app/src/main/kotlin/androidx/compose/material3/SideDrawer.kt b/app/src/main/kotlin/androidx/compose/material3/SideDrawer.kt index 8b408c320..03c233658 100644 --- a/app/src/main/kotlin/androidx/compose/material3/SideDrawer.kt +++ b/app/src/main/kotlin/androidx/compose/material3/SideDrawer.kt @@ -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) diff --git a/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt b/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt new file mode 100644 index 000000000..1d59aa314 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt @@ -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() + +// TODO: Update to M3 spec once it's available +private val DecayAnimationSpec = exponentialDecay() + +/** + * 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( + 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 diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt index 6c9adbf6a..8a9160e9b 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt index 99767cce9..cc8b05e78 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt index 87c75038b..24a8750d9 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt @@ -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