From ed7c2721933863ed5ea9faad7d3b8078ac7073c0 Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Fri, 8 Nov 2024 00:51:04 +0800 Subject: [PATCH] fix(ui): RTL in swipe actions --- app/build.gradle.kts | 1 - .../reader/ui/component/swipe/ActionFinder.kt | 58 ++++ .../reader/ui/component/swipe/SwipeAction.kt | 77 +++++ .../reader/ui/component/swipe/SwipeRipple.kt | 91 ++++++ .../ui/component/swipe/SwipeableActionsBox.kt | 144 +++++++++ .../component/swipe/SwipeableActionsState.kt | 95 ++++++ .../ash/reader/ui/component/swipe/defaults.kt | 3 + .../ui/component/swipe/horizontalDraggable.kt | 282 ++++++++++++++++++ .../reader/ui/page/home/flow/ArticleItem.kt | 18 +- 9 files changed, 752 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/me/ash/reader/ui/component/swipe/ActionFinder.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/swipe/SwipeAction.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/swipe/SwipeRipple.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/swipe/SwipeableActionsBox.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/swipe/SwipeableActionsState.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/swipe/defaults.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/swipe/horizontalDraggable.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b8e0009..ef3ad339 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -154,7 +154,6 @@ dependencies { implementation(libs.readability4j) implementation(libs.rome) implementation(libs.telephoto) - implementation(libs.swipe) implementation(libs.okhttp) implementation(libs.okhttp.coroutines) implementation(libs.retrofit) diff --git a/app/src/main/java/me/ash/reader/ui/component/swipe/ActionFinder.kt b/app/src/main/java/me/ash/reader/ui/component/swipe/ActionFinder.kt new file mode 100644 index 00000000..9c2ed980 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/swipe/ActionFinder.kt @@ -0,0 +1,58 @@ +package me.ash.reader.ui.component.swipe + +import kotlin.math.abs + +internal data class SwipeActionMeta( + val value: SwipeAction, + val isOnRightSide: Boolean, +) + +internal data class ActionFinder( + val left: List, + val right: List +) { + + fun actionAt(offset: Float, totalWidth: Int): SwipeActionMeta? { + if (offset == 0f) { + return null + } + + val isOnRightSide = offset < 0f + val actions = if (isOnRightSide) right else left + + val actionAtOffset = actions.actionAt( + offset = abs(offset).coerceAtMost(totalWidth.toFloat()), + totalWidth = totalWidth + ) + return actionAtOffset?.let { + SwipeActionMeta( + value = actionAtOffset, + isOnRightSide = isOnRightSide + ) + } + } + + private fun List.actionAt(offset: Float, totalWidth: Int): SwipeAction? { + if (isEmpty()) { + return null + } + + val totalWeights = this.sumOf { it.weight } + var offsetSoFar = 0.0 + + @Suppress("ReplaceManualRangeWithIndicesCalls") // Avoid allocating an Iterator for every pixel swiped. + for (i in 0 until size) { + val action = this[i] + val actionWidth = (action.weight / totalWeights) * totalWidth + val actionEndX = offsetSoFar + actionWidth + + if (offset <= actionEndX) { + return action + } + offsetSoFar += actionEndX + } + + // Precision error in the above loop maybe? + error("Couldn't find any swipe action. Width=$totalWidth, offset=$offset, actions=$this") + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeAction.kt b/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeAction.kt new file mode 100644 index 00000000..adbccdc9 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeAction.kt @@ -0,0 +1,77 @@ +package me.ash.reader.ui.component.swipe + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp + +/** + * Represents an action that can be shown in [SwipeableActionsBox]. + * + * @param background Color used as the background of [SwipeableActionsBox] while + * this action is visible. If this action is swiped, its background color is + * also used for drawing a ripple over the content for providing a visual + * feedback to the user. + * + * @param weight The proportional width to give to this element, as related + * to the total of all weighted siblings. [SwipeableActionsBox] will divide its + * horizontal space and distribute it to actions according to their weight. + * + * @param isUndo Determines the direction in which a ripple is drawn when this + * action is swiped. When false, the ripple grows from this action's position + * to consume the entire composable, and vice versa. This can be used for + * actions that can be toggled on and off. + */ +class SwipeAction( + val onSwipe: () -> Unit, + val icon: @Composable () -> Unit, + val background: Color, + val weight: Double = 1.0, + val isUndo: Boolean = false +) { + init { + require(weight > 0.0) { "invalid weight $weight; must be greater than zero" } + } + + fun copy( + onSwipe: () -> Unit = this.onSwipe, + icon: @Composable () -> Unit = this.icon, + background: Color = this.background, + weight: Double = this.weight, + isUndo: Boolean = this.isUndo, + ) = SwipeAction( + onSwipe = onSwipe, + icon = icon, + background = background, + weight = weight, + isUndo = isUndo + ) +} + +/** + * See [SwipeAction] for documentation. + */ +fun SwipeAction( + onSwipe: () -> Unit, + icon: Painter, + background: Color, + weight: Double = 1.0, + isUndo: Boolean = false +): SwipeAction { + return SwipeAction( + icon = { + Image( + modifier = Modifier.padding(16.dp), + painter = icon, + contentDescription = null + ) + }, + background = background, + weight = weight, + onSwipe = onSwipe, + isUndo = isUndo + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeRipple.kt b/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeRipple.kt new file mode 100644 index 00000000..876c819f --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeRipple.kt @@ -0,0 +1,91 @@ +@file:Suppress("NAME_SHADOWING") + +package me.ash.reader.ui.component.swipe + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Stable +internal class SwipeRippleState { + private var ripple = mutableStateOf(null) + + suspend fun animate( + action: SwipeActionMeta, + ) { + val drawOnRightSide = action.isOnRightSide + val action = action.value + + ripple.value = SwipeRipple( + isUndo = action.isUndo, + rightSide = drawOnRightSide, + color = action.background, + alpha = 0f, + progress = 0f + ) + + // Reverse animation feels faster (especially for larger swipe distances) so slow it down further. + val animationDurationMs = (animationDurationMs * (if (action.isUndo) 1.75f else 1f)).roundToInt() + + coroutineScope { + launch { + Animatable(initialValue = 0f).animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = animationDurationMs), + block = { + ripple.value = ripple.value!!.copy(progress = value) + } + ) + } + launch { + Animatable(initialValue = if (action.isUndo) 0f else 0.25f).animateTo( + targetValue = if (action.isUndo) 0.5f else 0f, + animationSpec = tween( + durationMillis = animationDurationMs, + delayMillis = if (action.isUndo) 0 else animationDurationMs / 2 + ), + block = { + ripple.value = ripple.value!!.copy(alpha = value) + } + ) + } + } + } + + fun draw(scope: DrawScope) { + ripple.value?.run { + scope.clipRect { + val size = scope.size + // Start the ripple with a radius equal to the available height so that it covers the entire edge. + val startRadius = if (isUndo) size.width + size.height else size.height + val endRadius = if (!isUndo) size.width + size.height else size.height + val radius = lerp(startRadius, endRadius, fraction = progress) + + drawCircle( + color = color, + radius = radius, + alpha = alpha, + center = this.center.copy(x = if (rightSide) this.size.width + this.size.height else 0f - this.size.height) + ) + } + } + } +} + +private data class SwipeRipple( + val isUndo: Boolean, + val rightSide: Boolean, + val color: Color, + val alpha: Float, + val progress: Float, +) + +private fun lerp(start: Float, stop: Float, fraction: Float) = + (start * (1 - fraction) + stop * fraction) diff --git a/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeableActionsBox.kt b/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeableActionsBox.kt new file mode 100644 index 00000000..d52b102e --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeableActionsBox.kt @@ -0,0 +1,144 @@ +package me.ash.reader.ui.component.swipe + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * A composable that can be swiped left or right for revealing actions. + * + * @param swipeThreshold Minimum drag distance before any [SwipeAction] is + * activated and can be swiped. + * + * @param backgroundUntilSwipeThreshold Color drawn behind the content until + * [swipeThreshold] is reached. When the threshold is passed, this color is + * replaced by the currently visible [SwipeAction]'s background. + */ +@Composable +fun SwipeableActionsBox( + modifier: Modifier = Modifier, + state: SwipeableActionsState = rememberSwipeableActionsState(), + startActions: List = emptyList(), + endActions: List = emptyList(), + swipeThreshold: Dp = 40.dp, + backgroundUntilSwipeThreshold: Color = Color.DarkGray, + content: @Composable BoxScope.() -> Unit +) = Box(modifier) { + state.also { + it.swipeThresholdPx = LocalDensity.current.run { swipeThreshold.toPx() } + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + it.actions = remember(endActions, startActions, isRtl) { + ActionFinder( + left = if (isRtl) endActions else startActions, + right = if (isRtl) startActions else endActions, + ) + } + } + + val backgroundColor = when { + state.swipedAction != null -> state.swipedAction!!.value.background + !state.hasCrossedSwipeThreshold() -> backgroundUntilSwipeThreshold + state.visibleAction != null -> state.visibleAction!!.value.background + else -> Color.Transparent + } + val animatedBackgroundColor: Color = if (state.layoutWidth == 0) { + // Use the current color immediately because paparazzi can only capture the 1st frame. + // https://github.com/cashapp/paparazzi/issues/1261 + backgroundColor + } else { + animateColorAsState(backgroundColor).value + } + + val scope = rememberCoroutineScope() + Box( + modifier = Modifier + .onSizeChanged { state.layoutWidth = it.width } + .absoluteOffset { IntOffset(x = state.offset.value.roundToInt(), y = 0) } + .drawOverContent { state.ripple.draw(scope = this) } + .horizontalDraggable( + enabled = !state.isResettingOnRelease, + onDragStopped = { + scope.launch { + state.handleOnDragStopped() + } + }, + state = state.draggableState, + ), + content = content + ) + + (state.swipedAction ?: state.visibleAction)?.let { action -> + ActionIconBox( + modifier = Modifier.matchParentSize(), + action = action, + offset = state.offset.value, + backgroundColor = animatedBackgroundColor, + content = { action.value.icon() } + ) + } + + val hapticFeedback = LocalHapticFeedback.current + if (state.hasCrossedSwipeThreshold() && state.swipedAction == null) { + LaunchedEffect(state.visibleAction) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + } +} + +@Composable +private fun ActionIconBox( + action: SwipeActionMeta, + offset: Float, + backgroundColor: Color, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Row( + modifier = modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(width = placeable.width, height = placeable.height) { + // Align icon with the left/right edge of the content being swiped. + val iconOffset = if (action.isOnRightSide) constraints.maxWidth + offset else offset - placeable.width + placeable.place(x = iconOffset.roundToInt(), y = 0) + } + } + .background(color = backgroundColor), + horizontalArrangement = if (action.isOnRightSide) Arrangement.Absolute.Left else Arrangement.Absolute.Right, + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +private fun Modifier.drawOverContent(onDraw: DrawScope.() -> Unit): Modifier { + return drawWithContent { + drawContent() + onDraw(this) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeableActionsState.kt b/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeableActionsState.kt new file mode 100644 index 00000000..7cafef8a --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/swipe/SwipeableActionsState.kt @@ -0,0 +1,95 @@ +package me.ash.reader.ui.component.swipe + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlin.math.abs + +@Composable +fun rememberSwipeableActionsState(): SwipeableActionsState { + return remember { SwipeableActionsState() } +} + +/** + * The state of a [SwipeableActionsBox]. + */ +@Stable +class SwipeableActionsState internal constructor() { + /** + * The current position (in pixels) of a [SwipeableActionsBox]. + */ + val offset: State get() = offsetState + internal var offsetState = mutableStateOf(0f) + + /** + * Whether [SwipeableActionsBox] is currently animating to reset its offset after it was swiped. + */ + val isResettingOnRelease: Boolean by derivedStateOf { + swipedAction != null + } + + internal var layoutWidth: Int by mutableIntStateOf(0) + internal var swipeThresholdPx: Float by mutableFloatStateOf(0f) + internal val ripple = SwipeRippleState() + + internal var actions: ActionFinder by mutableStateOf( + ActionFinder(left = emptyList(), right = emptyList()) + ) + internal val visibleAction: SwipeActionMeta? by derivedStateOf { + actions.actionAt(offsetState.value, totalWidth = layoutWidth) + } + internal var swipedAction: SwipeActionMeta? by mutableStateOf(null) + + internal val draggableState = DraggableState { delta -> + val targetOffset = offsetState.value + delta + + val canSwipeTowardsRight = actions.left.isNotEmpty() + val canSwipeTowardsLeft = actions.right.isNotEmpty() + + val isAllowed = isResettingOnRelease + || targetOffset == 0f + || (targetOffset > 0f && canSwipeTowardsRight) + || (targetOffset < 0f && canSwipeTowardsLeft) + offsetState.value += if (isAllowed) delta else delta / 10 + } + + internal fun hasCrossedSwipeThreshold(): Boolean { + return abs(offsetState.value) > swipeThresholdPx + } + + internal suspend fun handleOnDragStopped() = coroutineScope { + launch { + if (hasCrossedSwipeThreshold()) { + visibleAction?.let { action -> + swipedAction = action + action.value.onSwipe() + ripple.animate(action = action) + } + } + } + launch { + draggableState.drag(MutatePriority.PreventUserInput) { + Animatable(offsetState.value).animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = animationDurationMs), + ) { + dragBy(value - offsetState.value) + } + } + swipedAction = null + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/swipe/defaults.kt b/app/src/main/java/me/ash/reader/ui/component/swipe/defaults.kt new file mode 100644 index 00000000..4ccbbc8c --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/swipe/defaults.kt @@ -0,0 +1,3 @@ +package me.ash.reader.ui.component.swipe + +internal const val animationDurationMs = 4_00 diff --git a/app/src/main/java/me/ash/reader/ui/component/swipe/horizontalDraggable.kt b/app/src/main/java/me/ash/reader/ui/component/swipe/horizontalDraggable.kt new file mode 100644 index 00000000..2017ef41 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/swipe/horizontalDraggable.kt @@ -0,0 +1,282 @@ +package me.ash.reader.ui.component.swipe + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.drag +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +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.changedToUpIgnoreConsumed +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.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.Velocity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import me.ash.reader.ui.component.swipe.DragEvent.* +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs +import kotlin.math.sign + +/** + * Workaround for [269627294](https://issuetracker.google.com/issues/269627294). + * + * Copy of Compose UI's draggable modifier, but with an additional check to only accept horizontal swipes + * made within 22.5°. This prevents accidental swipes while scrolling a vertical list. + */ +internal fun Modifier.horizontalDraggable( + state: DraggableState, + enabled: Boolean = true, + startDragImmediately: Boolean = false, + onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, + onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, +): Modifier = this then DraggableElement( + state = state, + enabled = enabled, + startDragImmediately = { startDragImmediately }, + onDragStarted = onDragStarted, + onDragStopped = { velocity -> onDragStopped(velocity.x) }, +) + +internal data class DraggableElement( + private val state: DraggableState, + private val enabled: Boolean, + private val startDragImmediately: () -> Boolean, + private val onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit, + private val onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit, +) : ModifierNodeElement() { + + override fun create(): DraggableNode = DraggableNode( + state, + enabled, + startDragImmediately, + onDragStarted, + onDragStopped, + ) + + override fun update(node: DraggableNode) { + node.update( + state = state, + enabled = enabled, + startDragImmediately = startDragImmediately, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, + ) + } + + override fun InspectorInfo.inspectableProperties() { + name = "draggable" + properties["enabled"] = enabled + properties["startDragImmediately"] = startDragImmediately + properties["onDragStarted"] = onDragStarted + properties["onDragStopped"] = onDragStopped + properties["state"] = state + } +} + +internal class DraggableNode( + private var state: DraggableState, + private var enabled: Boolean, + private var startDragImmediately: () -> Boolean, + private var onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit, + private var onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit, +) : DelegatingNode(), PointerInputModifierNode { + + private val velocityTracker = VelocityTracker() + private val channel = Channel(capacity = Channel.UNLIMITED) + + @Suppress("NAME_SHADOWING") + private val pointerInputNode = SuspendingPointerInputModifierNode { + if (!enabled) { + return@SuspendingPointerInputModifierNode + } + + coroutineScope { + launch(start = CoroutineStart.UNDISPATCHED) { + while (isActive) { + var event = channel.receive() + if (event !is DragStarted) continue + onDragStarted.invoke(this, event.startPoint) + try { + state.drag(MutatePriority.UserInput) { + while (event !is DragStopped && event !is DragCancelled) { + (event as? DragDelta)?.let { dragBy(it.delta.x) } + event = channel.receive() + } + } + event.let { event -> + if (event is DragStopped) { + onDragStopped.invoke(this, event.velocity) + } else if (event is DragCancelled) { + onDragStopped.invoke(this, Velocity.Zero) + } + } + } catch (c: CancellationException) { + onDragStopped.invoke(this, Velocity.Zero) + } + } + } + + awaitEachGesture { + val awaited = awaitDownAndSlop( + startDragImmediately = startDragImmediately, + velocityTracker = velocityTracker, + ) + + if (awaited != null) { + var isDragSuccessful = false + try { + isDragSuccessful = awaitDrag( + startEvent = awaited.first, + initialDelta = awaited.second, + velocityTracker = velocityTracker, + channel = channel, + reverseDirection = false, + ) + } catch (cancellation: CancellationException) { + isDragSuccessful = false + if (!isActive) throw cancellation + } finally { + val event = if (isDragSuccessful) { + val velocity = velocityTracker.calculateVelocity() + velocityTracker.resetTracking() + DragStopped(velocity) + } else { + DragCancelled + } + channel.trySend(event) + } + } + } + } + } + + init { + delegate(pointerInputNode) + } + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) { + pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) + } + + override fun onCancelPointerInput() { + pointerInputNode.onCancelPointerInput() + } + + fun update( + state: DraggableState, + enabled: Boolean, + startDragImmediately: () -> Boolean, + onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit, + onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit, + ) { + var resetPointerInputHandling = false + if (this.state != state) { + this.state = state + resetPointerInputHandling = true + } + if (this.enabled != enabled) { + this.enabled = enabled + resetPointerInputHandling = true + } + this.startDragImmediately = startDragImmediately + this.onDragStarted = onDragStarted + this.onDragStopped = onDragStopped + if (resetPointerInputHandling) { + pointerInputNode.resetPointerInputHandler() + } + } +} + +private suspend fun AwaitPointerEventScope.awaitDownAndSlop( + startDragImmediately: () -> Boolean, + velocityTracker: VelocityTracker, +): Pair? { + val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + return if (startDragImmediately()) { + initialDown.consume() + velocityTracker.addPointerInputChange(initialDown) + // since we start immediately we don't wait for slop and the initial delta is 0 + initialDown to Offset.Zero + } else { + val down = awaitFirstDown(requireUnconsumed = false) + velocityTracker.addPointerInputChange(down) + var initialDelta = Offset.Zero + val postPointerSlop = { event: PointerInputChange, overSlop: Float -> + val isHorizontalSwipe = event.positionChange().let { + abs(it.x) > abs(it.y * 2f) // Accept swipes made at a max. of 22.5° in either direction. + } + if (isHorizontalSwipe) { + velocityTracker.addPointerInputChange(event) + event.consume() + initialDelta = Offset(x = overSlop, 0f) + } else { + throw CancellationException() + } + } + + val afterSlopResult = awaitHorizontalTouchSlopOrCancellation( + pointerId = down.id, + onTouchSlopReached = postPointerSlop + ) + + if (afterSlopResult != null) afterSlopResult to initialDelta else null + } +} + +private suspend fun AwaitPointerEventScope.awaitDrag( + startEvent: PointerInputChange, + initialDelta: Offset, + velocityTracker: VelocityTracker, + channel: SendChannel, + reverseDirection: Boolean, +): Boolean { + val overSlopOffset = initialDelta + val xSign = sign(startEvent.position.x) + val ySign = sign(startEvent.position.y) + val adjustedStart = startEvent.position - + Offset(overSlopOffset.x * xSign, overSlopOffset.y * ySign) + channel.trySend(DragStarted(adjustedStart)) + + channel.trySend(DragDelta(if (reverseDirection) initialDelta * -1f else initialDelta)) + + return drag(pointerId = startEvent.id) { event -> + // Velocity tracker takes all events, even UP + velocityTracker.addPointerInputChange(event) + + // Dispatch only MOVE events + if (!event.changedToUpIgnoreConsumed()) { + val delta = event.positionChange() + event.consume() + channel.trySend(DragDelta(if (reverseDirection) delta * -1f else delta)) + } + } +} + +private sealed class DragEvent { + class DragStarted(val startPoint: Offset) : DragEvent() + class DragStopped(val velocity: Velocity) : DragEvent() + data object DragCancelled : DragEvent() + class DragDelta(val delta: Offset) : DragEvent() +} diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt index 4f12a582..5981c7e0 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt @@ -1,13 +1,6 @@ package me.ash.reader.ui.page.home.flow -import android.util.Log import android.view.HapticFeedbackConstants -import androidx.compose.animation.Animatable -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.exponentialDecay -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -17,7 +10,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -43,32 +35,26 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import coil.size.Precision import coil.size.Scale @@ -95,8 +81,8 @@ import me.ash.reader.ui.page.settings.color.flow.generateArticleWithFeedPreview import me.ash.reader.ui.theme.Shape20 import me.ash.reader.ui.theme.applyTextDirection import me.ash.reader.ui.theme.palette.onDark -import me.saket.swipe.SwipeAction -import me.saket.swipe.SwipeableActionsBox +import me.ash.reader.ui.component.swipe.SwipeAction +import me.ash.reader.ui.component.swipe.SwipeableActionsBox private const val TAG = "ArticleItem"