fix(ui): RTL in swipe actions

This commit is contained in:
junkfood 2024-11-08 00:51:04 +08:00
parent 24087803b1
commit ed7c272193
No known key found for this signature in database
GPG Key ID: 2EA5B648DB112A34
9 changed files with 752 additions and 17 deletions

View File

@ -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)

View File

@ -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<SwipeAction>,
val right: List<SwipeAction>
) {
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<SwipeAction>.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")
}
}

View File

@ -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
)
}

View File

@ -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<SwipeRipple?>(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)

View File

@ -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<SwipeAction> = emptyList(),
endActions: List<SwipeAction> = 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)
}
}

View File

@ -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<Float> 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
}
}
}

View File

@ -0,0 +1,3 @@
package me.ash.reader.ui.component.swipe
internal const val animationDurationMs = 4_00

View File

@ -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<DraggableNode>() {
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<DragEvent>(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<PointerInputChange, Offset>? {
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<DragEvent>,
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()
}

View File

@ -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"