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 1/7] 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" From 99e1d894e406c6097cea871631c90f79b47208cf Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Sun, 10 Nov 2024 20:27:55 +0900 Subject: [PATCH 2/7] feat(ui): separate toolbars from the content below with dividers (#747) --- .../reader/ui/page/home/reading/BottomBar.kt | 224 ++++++++++-------- .../reader/ui/page/home/reading/Content.kt | 4 +- .../ui/page/home/reading/ReadingPage.kt | 33 ++- .../ash/reader/ui/page/home/reading/TopBar.kt | 110 ++++++--- 4 files changed, 214 insertions(+), 157 deletions(-) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/BottomBar.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/BottomBar.kt index 78b3be02..8eeac121 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/BottomBar.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/BottomBar.kt @@ -1,6 +1,13 @@ package me.ash.reader.ui.page.home.reading import android.view.HapticFeedbackConstants +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -18,6 +25,7 @@ import androidx.compose.material.icons.outlined.Headphones import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.StarOutline +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -25,6 +33,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import me.ash.reader.R @@ -59,113 +68,120 @@ fun BottomBar( .zIndex(1f), contentAlignment = Alignment.BottomCenter ) { - RYExtensibleVisibility(visible = isShow) { + AnimatedVisibility( + visible = isShow, + enter = expandVertically(), + exit = shrinkVertically() + ) { val view = LocalView.current - - Surface( - tonalElevation = tonalElevation.value.dp, - ) { - // TODO: Component styles await refactoring - Row( - modifier = Modifier - .navigationBarsPadding() - .fillMaxWidth() - .height(60.dp), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically, - ) { - CanBeDisabledIconButton( - modifier = Modifier.size(40.dp), - disabled = false, - imageVector = if (isUnread) { - Icons.Filled.FiberManualRecord - } else { - Icons.Outlined.FiberManualRecord - }, - contentDescription = stringResource(if (isUnread) R.string.mark_as_read else R.string.mark_as_unread), - tint = if (isUnread) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.outline - }, + Column { + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + thickness = 0.5f.dp + ) + Surface() { + // TODO: Component styles await refactoring + Row( + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .height(60.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, ) { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - onUnread(!isUnread) - } - CanBeDisabledIconButton( - modifier = Modifier.size(40.dp), - disabled = false, - imageVector = if (isStarred) { - Icons.Rounded.Star - } else { - Icons.Rounded.StarOutline - }, - contentDescription = stringResource(if (isStarred) R.string.mark_as_unstar else R.string.mark_as_starred), - tint = if (isStarred) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.outline - }, - ) { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - onStarred(!isStarred) - } - CanBeDisabledIconButton( - disabled = !isNextArticleAvailable, - modifier = Modifier.size(40.dp), - imageVector = Icons.Rounded.ExpandMore, - contentDescription = "Next Article", - tint = MaterialTheme.colorScheme.outline, - ) { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - onNextArticle() - } - CanBeDisabledIconButton( - modifier = Modifier.size(36.dp), - disabled = false, - imageVector = if (renderer == ReadingRendererPreference.WebView) null else Icons.Outlined.Headphones, - contentDescription = if (renderer == ReadingRendererPreference.WebView) { - stringResource(R.string.bionic_reading) - } else { - stringResource(R.string.read_aloud) - }, - tint = MaterialTheme.colorScheme.outline, - icon = { - BionicReadingIcon( - filled = isBionicReading, - size = 24.dp, - tint = if (renderer == ReadingRendererPreference.WebView) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.outline - } - ) - }, - ) { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - if (renderer == ReadingRendererPreference.WebView) { - onBionicReading() - } else { - onReadAloud() + CanBeDisabledIconButton( + modifier = Modifier.size(40.dp), + disabled = false, + imageVector = if (isUnread) { + Icons.Filled.FiberManualRecord + } else { + Icons.Outlined.FiberManualRecord + }, + contentDescription = stringResource(if (isUnread) R.string.mark_as_read else R.string.mark_as_unread), + tint = if (isUnread) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.outline + }, + ) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + onUnread(!isUnread) + } + CanBeDisabledIconButton( + modifier = Modifier.size(40.dp), + disabled = false, + imageVector = if (isStarred) { + Icons.Rounded.Star + } else { + Icons.Rounded.StarOutline + }, + contentDescription = stringResource(if (isStarred) R.string.mark_as_unstar else R.string.mark_as_starred), + tint = if (isStarred) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.outline + }, + ) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + onStarred(!isStarred) + } + CanBeDisabledIconButton( + disabled = !isNextArticleAvailable, + modifier = Modifier.size(40.dp), + imageVector = Icons.Rounded.ExpandMore, + contentDescription = "Next Article", + tint = MaterialTheme.colorScheme.outline, + ) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + onNextArticle() + } + CanBeDisabledIconButton( + modifier = Modifier.size(36.dp), + disabled = false, + imageVector = if (renderer == ReadingRendererPreference.WebView) null else Icons.Outlined.Headphones, + contentDescription = if (renderer == ReadingRendererPreference.WebView) { + stringResource(R.string.bionic_reading) + } else { + stringResource(R.string.read_aloud) + }, + tint = MaterialTheme.colorScheme.outline, + icon = { + BionicReadingIcon( + filled = isBionicReading, + size = 24.dp, + tint = if (renderer == ReadingRendererPreference.WebView) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.outline + } + ) + }, + ) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + if (renderer == ReadingRendererPreference.WebView) { + onBionicReading() + } else { + onReadAloud() + } + } + CanBeDisabledIconButton( + disabled = false, + modifier = Modifier.size(40.dp), + imageVector = if (isFullContent) { + Icons.AutoMirrored.Rounded.Article + } else { + Icons.AutoMirrored.Outlined.Article + }, + contentDescription = stringResource(R.string.parse_full_content), + tint = if (isFullContent) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.outline + }, + ) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + onFullContent(!isFullContent) } - } - CanBeDisabledIconButton( - disabled = false, - modifier = Modifier.size(40.dp), - imageVector = if (isFullContent) { - Icons.AutoMirrored.Rounded.Article - } else { - Icons.AutoMirrored.Outlined.Article - }, - contentDescription = stringResource(R.string.parse_full_content), - tint = if (isFullContent) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.outline - }, - ) { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - onFullContent(!isFullContent) } } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt index f7ba366e..7555b2a5 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt @@ -68,6 +68,7 @@ fun Content( } } else { + when (renderer) { ReadingRendererPreference.WebView -> { Column( @@ -81,8 +82,7 @@ fun Content( Spacer(modifier = Modifier.height(64.dp)) // padding Column( - modifier = Modifier - .padding(horizontal = 12.dp) + modifier = Modifier.padding(horizontal = 12.dp) ) { DisableSelection { Metadata( diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt index 72419bce..539b7c0f 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -73,6 +74,10 @@ fun ReadingPage( true } + var showTopDivider by remember { + mutableStateOf(false) + } + val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList LaunchedEffect(Unit) { @@ -97,23 +102,22 @@ fun ReadingPage( Scaffold( containerColor = MaterialTheme.colorScheme.surface, -// topBarTonalElevation = tonalElevation.value.dp, -// containerTonalElevation = tonalElevation.value.dp, content = { paddings -> Log.i("RLog", "TopBar: recomposition") Box(modifier = Modifier.fillMaxSize()) { - // Top Bar - TopBar( - navController = navController, - isShow = isShowToolBar, - windowInsets = WindowInsets(top = paddings.calculateTopPadding()), - title = readerState.title, - link = readerState.link, - onClose = { - navController.popBackStack() - }, - ) + if (readerState.articleId != null) { + TopBar( + navController = navController, + isShow = isShowToolBar, + showDivider = showTopDivider, + title = readerState.title, + link = readerState.link, + onClose = { + navController.popBackStack() + }, + ) + } val isNextArticleAvailable = !readerState.nextArticleId.isNullOrEmpty() val isPreviousArticleAvailable = !readerState.previousArticleId.isNullOrEmpty() @@ -163,6 +167,9 @@ fun ReadingPage( saver = LazyListState.Saver ) { LazyListState() } + showTopDivider = snapshotFlow { + listState.firstVisibleItemIndex != 0 + }.collectAsStateValue(initial = false) CompositionLocalProvider( LocalOverscrollConfiguration provides diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt index c589a264..138befb1 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt @@ -1,15 +1,27 @@ package me.ash.reader.ui.page.home.reading +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -17,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.navigation.NavHostController @@ -24,7 +37,6 @@ import me.ash.reader.R import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation import me.ash.reader.infrastructure.preference.LocalSharedContent import me.ash.reader.ui.component.base.FeedbackIconButton -import me.ash.reader.ui.component.base.RYExtensibleVisibility import me.ash.reader.ui.ext.surfaceColorAtElevation import me.ash.reader.ui.page.common.RouteName @@ -33,7 +45,7 @@ import me.ash.reader.ui.page.common.RouteName fun TopBar( navController: NavHostController, isShow: Boolean, - windowInsets: WindowInsets = WindowInsets(0.dp), + showDivider: Boolean = false, title: String? = "", link: String? = "", onClose: () -> Unit = {}, @@ -48,43 +60,65 @@ fun TopBar( .zIndex(1f), contentAlignment = Alignment.TopCenter ) { - RYExtensibleVisibility(visible = isShow) { - TopAppBar( - title = {}, - modifier = Modifier, - windowInsets = windowInsets, - navigationIcon = { - FeedbackIconButton( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.close), - tint = MaterialTheme.colorScheme.onSurface - ) { - onClose() - } - }, - actions = { - FeedbackIconButton( - modifier = Modifier.size(22.dp), - imageVector = Icons.Outlined.Palette, - contentDescription = stringResource(R.string.style), - tint = MaterialTheme.colorScheme.onSurface - ) { - navController.navigate(RouteName.READING_PAGE_STYLE) { - launchSingleTop = true + Column { + Surface( + modifier = Modifier + .fillMaxWidth() + .height( + WindowInsets.statusBars + .asPaddingValues() + .calculateTopPadding() + ) + ) {} + AnimatedVisibility( + visible = isShow, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + ) { + TopAppBar( + title = {}, + modifier = Modifier, + windowInsets = WindowInsets(0.dp), + navigationIcon = { + FeedbackIconButton( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.close), + tint = MaterialTheme.colorScheme.onSurface + ) { + onClose() } - } - FeedbackIconButton( - modifier = Modifier.size(20.dp), - imageVector = Icons.Outlined.Share, - contentDescription = stringResource(R.string.share), - tint = MaterialTheme.colorScheme.onSurface, - ) { - sharedContent.share(context, title, link) - } - }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(tonalElevation.value.dp), + }, actions = { + FeedbackIconButton( + modifier = Modifier.size(22.dp), + imageVector = Icons.Outlined.Palette, + contentDescription = stringResource(R.string.style), + tint = MaterialTheme.colorScheme.onSurface + ) { + navController.navigate(RouteName.READING_PAGE_STYLE) { + launchSingleTop = true + } + } + FeedbackIconButton( + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(R.string.share), + tint = MaterialTheme.colorScheme.onSurface, + ) { + sharedContent.share(context, title, link) + } + }, colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( + tonalElevation.value.dp + ), + ) ) - ) + } + if (showDivider) { + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + thickness = 0.5f.dp + ) + } } } -} +} \ No newline at end of file From 58ce1a44f21fb133ba3a4ad6a19f82aee24a39eb Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Sun, 10 Nov 2024 19:27:27 +0800 Subject: [PATCH 3/7] fix(reader): tweak line height --- .../java/me/ash/reader/ui/component/webview/WebViewStyle.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/WebViewStyle.kt b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewStyle.kt index 30af66c9..ab393073 100644 --- a/app/src/main/java/me/ash/reader/ui/component/webview/WebViewStyle.kt +++ b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewStyle.kt @@ -27,7 +27,7 @@ object WebViewStyle { :root { /* --font-family: Inter; */ --font-size: ${fontSize}px; - --line-height: ${lineHeight}; + --line-height: ${lineHeight * 1.5f}; --letter-spacing: ${letterSpacing}px; --text-margin: ${textMargin}px; --text-color: ${argbToCssColor(textColor)}; From cd9c31e456a3486c1e5902db5db1d87026ace101 Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Sun, 10 Nov 2024 19:48:13 +0800 Subject: [PATCH 4/7] fix(ui): show top bar divider --- .../java/me/ash/reader/ui/page/home/reading/Content.kt | 4 +++- .../me/ash/reader/ui/page/home/reading/ReadingPage.kt | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt index 7555b2a5..ee058382 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt @@ -1,5 +1,6 @@ package me.ash.reader.ui.page.home.reading +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -47,6 +48,7 @@ fun Content( author: String? = null, link: String? = null, publishedDate: Date, + scrollState: ScrollState, listState: LazyListState, isLoading: Boolean, contentPadding: PaddingValues = PaddingValues(), @@ -75,7 +77,7 @@ fun Content( modifier = modifier .padding(top = contentPadding.calculateTopPadding()) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) ) { // Top bar height diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt index 539b7c0f..5bb11d84 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -16,6 +17,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -32,6 +34,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.collect import me.ash.reader.R import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar @@ -167,8 +170,10 @@ fun ReadingPage( saver = LazyListState.Saver ) { LazyListState() } + val scrollState = rememberScrollState() + showTopDivider = snapshotFlow { - listState.firstVisibleItemIndex != 0 + scrollState.value != 0 || listState.firstVisibleItemIndex != 0 }.collectAsStateValue(initial = false) CompositionLocalProvider( @@ -200,6 +205,7 @@ fun ReadingPage( link = link, publishedDate = publishedDate, isLoading = content is ReaderState.Loading, + scrollState = scrollState, listState = listState, onImageClick = { imgUrl, altText -> currentImageData = ImageData(imgUrl, altText) From 5bf274d98658b10beee1e0125ceb496db0195d8e Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Sun, 10 Nov 2024 20:11:57 +0800 Subject: [PATCH 5/7] feat(ui): click top bar to scroll back to the top --- .../ReadingTextLineHeightPreference.kt | 2 +- .../ui/page/home/reading/ReadingPage.kt | 26 ++++++++++++++++++- .../ash/reader/ui/page/home/reading/TopBar.kt | 11 +++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTextLineHeightPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTextLineHeightPreference.kt index 671a03cc..d347aaa9 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTextLineHeightPreference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTextLineHeightPreference.kt @@ -13,7 +13,7 @@ import me.ash.reader.ui.ext.put val LocalReadingTextLineHeight = compositionLocalOf { ReadingTextLineHeightPreference.default } data object ReadingTextLineHeightPreference { - const val default = 1.5F + const val default = 1.0F private val range = 0.8F..2F fun put(context: Context, scope: CoroutineScope, value: Float) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt index 5bb11d84..f575ed02 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt @@ -16,17 +16,22 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp @@ -35,6 +40,7 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController import androidx.paging.compose.collectAsLazyPagingItems import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import me.ash.reader.R import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar @@ -60,6 +66,7 @@ fun ReadingPage( readingViewModel: ReadingViewModel = hiltViewModel(), ) { val context = LocalContext.current + val hapticFeedback = LocalHapticFeedback.current val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value val readingUiState = readingViewModel.readingUiState.collectAsStateValue() val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue() @@ -81,6 +88,8 @@ fun ReadingPage( mutableStateOf(false) } + var bringToTop by remember { mutableStateOf(false) } + val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList LaunchedEffect(Unit) { @@ -116,6 +125,7 @@ fun ReadingPage( showDivider = showTopDivider, title = readerState.title, link = readerState.link, + onClick = { bringToTop = true }, onClose = { navController.popBackStack() }, @@ -164,7 +174,6 @@ fun ReadingPage( } ) - val listState = rememberSaveable( inputs = arrayOf(content), saver = LazyListState.Saver @@ -172,6 +181,21 @@ fun ReadingPage( val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + + LaunchedEffect(bringToTop) { + if (bringToTop) { + scope.launch { + if (scrollState.value != 0) { + scrollState.animateScrollTo(0) + } else if (listState.firstVisibleItemIndex != 0) { + listState.animateScrollToItem(0) + } + }.invokeOnCompletion { bringToTop = false } + } + } + + showTopDivider = snapshotFlow { scrollState.value != 0 || listState.firstVisibleItemIndex != 0 }.collectAsStateValue(initial = false) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt index 138befb1..40c27c4b 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt @@ -3,6 +3,8 @@ package me.ash.reader.ui.page.home.reading import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -25,6 +27,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -48,6 +51,7 @@ fun TopBar( showDivider: Boolean = false, title: String? = "", link: String? = "", + onClick: (() -> Unit)? = null, onClose: () -> Unit = {}, ) { val context = LocalContext.current @@ -60,7 +64,12 @@ fun TopBar( .zIndex(1f), contentAlignment = Alignment.TopCenter ) { - Column { + Column(modifier = if (onClick == null) Modifier else Modifier.clickable( + onClick = onClick, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + ) { Surface( modifier = Modifier .fillMaxWidth() From d4541dd0264cd3fb831b87f2b1184980263ccc33 Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Sun, 10 Nov 2024 20:33:45 +0800 Subject: [PATCH 6/7] feat(ui): option for not grey out all articles --- .../FlowArticleReadIndicatorPreference.kt | 27 +++++++++---------- .../java/me/ash/reader/ui/ext/DataStoreExt.kt | 4 +-- .../reader/ui/page/home/flow/ArticleItem.kt | 2 ++ 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/FlowArticleReadIndicatorPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/FlowArticleReadIndicatorPreference.kt index 816b731b..562a5019 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/FlowArticleReadIndicatorPreference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/FlowArticleReadIndicatorPreference.kt @@ -16,14 +16,15 @@ import me.ash.reader.ui.ext.put val LocalFlowArticleListReadIndicator = compositionLocalOf { FlowArticleReadIndicatorPreference.default } -sealed class FlowArticleReadIndicatorPreference(val value: Boolean) : Preference() { - object ExcludingStarred : FlowArticleReadIndicatorPreference(true) - object AllRead : FlowArticleReadIndicatorPreference(false) +sealed class FlowArticleReadIndicatorPreference(val value: Int) : Preference() { + data object ExcludingStarred : FlowArticleReadIndicatorPreference(0) + data object AllRead : FlowArticleReadIndicatorPreference(1) + data object None : FlowArticleReadIndicatorPreference(2) override fun put(context: Context, scope: CoroutineScope) { scope.launch { context.dataStore.put( - DataStoreKey.flowArticleListReadIndicator, + flowArticleListReadIndicator, value ) } @@ -34,26 +35,22 @@ sealed class FlowArticleReadIndicatorPreference(val value: Boolean) : Preference return when (this) { AllRead -> stringResource(id = R.string.all_read) ExcludingStarred -> stringResource(id = R.string.read_excluding_starred) + None -> stringResource(id = R.string.none) } } companion object { val default = ExcludingStarred - val values = listOf(ExcludingStarred, AllRead) + val values = listOf(ExcludingStarred, AllRead, None) fun fromPreferences(preferences: Preferences) = - when (preferences[DataStoreKey.keys[flowArticleListReadIndicator]?.key as Preferences.Key]) { - true -> ExcludingStarred - false -> AllRead + when (preferences[DataStoreKey.keys[flowArticleListReadIndicator]?.key as Preferences.Key]) { + 0 -> ExcludingStarred + 1 -> AllRead + 2 -> None else -> default } } -} - -operator fun FlowArticleReadIndicatorPreference.not(): FlowArticleReadIndicatorPreference = - when (value) { - true -> FlowArticleReadIndicatorPreference.AllRead - false -> FlowArticleReadIndicatorPreference.ExcludingStarred - } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt index c8d26873..00b4151b 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt @@ -131,7 +131,7 @@ data class DataStoreKey( const val flowArticleListTime = "flowArticleListTime" const val flowArticleListDateStickyHeader = "flowArticleListDateStickyHeader" const val flowArticleListTonalElevation = "flowArticleListTonalElevation" - const val flowArticleListReadIndicator = "flowArticleListReadIndicator" + const val flowArticleListReadIndicator = "flowArticleListReadStatusIndicator" // Reading page const val readingRenderer = "readingRender" @@ -207,7 +207,7 @@ data class DataStoreKey( flowArticleListTime to DataStoreKey(booleanPreferencesKey(flowArticleListTime), Boolean::class.java), flowArticleListDateStickyHeader to DataStoreKey(booleanPreferencesKey(flowArticleListDateStickyHeader), Boolean::class.java), flowArticleListTonalElevation to DataStoreKey(intPreferencesKey(flowArticleListTonalElevation), Int::class.java), - flowArticleListReadIndicator to DataStoreKey(booleanPreferencesKey(flowArticleListReadIndicator), Boolean::class.java), + flowArticleListReadIndicator to DataStoreKey(intPreferencesKey(flowArticleListReadIndicator), Int::class.java), // Reading page readingRenderer to DataStoreKey(intPreferencesKey(readingRenderer), Int::class.java), readingBionicReading to DataStoreKey(booleanPreferencesKey(readingBionicReading), Boolean::class.java), 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 5981c7e0..efdc5900 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 @@ -145,6 +145,8 @@ fun ArticleItem( .padding(horizontal = 12.dp, vertical = 12.dp) .alpha( when (articleListReadIndicator) { + FlowArticleReadIndicatorPreference.None -> 1f + FlowArticleReadIndicatorPreference.AllRead -> { if (isUnread) 1f else 0.5f } From 1343978fc977bfceddab2efb73590f5883a0e3e0 Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Sun, 10 Nov 2024 22:18:02 +0800 Subject: [PATCH 7/7] feat(ui): lazy tag for mark article as read --- .../reader/ui/page/home/flow/ArticleItem.kt | 8 +++--- .../reader/ui/page/home/flow/ArticleList.kt | 8 +++--- .../ash/reader/ui/page/home/flow/FlowPage.kt | 25 ++++++++++------- .../reader/ui/page/home/flow/FlowViewModel.kt | 27 +++++++++++++------ 4 files changed, 45 insertions(+), 23 deletions(-) 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 efdc5900..6dc7cd8a 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 @@ -90,6 +90,7 @@ private const val TAG = "ArticleItem" fun ArticleItem( modifier: Modifier = Modifier, articleWithFeed: ArticleWithFeed, + isUnread: Boolean = articleWithFeed.article.isUnread, onClick: (ArticleWithFeed) -> Unit = {}, onLongClick: (() -> Unit)? = null ) { @@ -105,7 +106,7 @@ fun ArticleItem( dateString = article.dateString, imgData = article.img, isStarred = article.isStarred, - isUnread = article.isUnread, + isUnread = isUnread, onClick = { onClick(articleWithFeed) }, onLongClick = onLongClick ) @@ -280,7 +281,7 @@ private const val SwipeActionDelay = 300L @Composable fun SwipeableArticleItem( articleWithFeed: ArticleWithFeed, - isFilterUnread: Boolean = false, + isUnread: Boolean = articleWithFeed.article.isUnread, articleListTonalElevation: Int = 0, onClick: (ArticleWithFeed) -> Unit = {}, isSwipeEnabled: () -> Boolean = { false }, @@ -311,7 +312,7 @@ fun SwipeableArticleItem( SwipeActionBox( articleWithFeed = articleWithFeed, - isRead = !articleWithFeed.article.isUnread, + isRead = !isUnread, isStarred = articleWithFeed.article.isStarred, onToggleStarred = onToggleStarred, onToggleRead = onToggleRead @@ -337,6 +338,7 @@ fun SwipeableArticleItem( ) { ArticleItem( articleWithFeed = articleWithFeed, + isUnread = isUnread, onClick = onClick, onLongClick = onLongClick ) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt index c0b67877..0de2f94d 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt @@ -16,7 +16,7 @@ import me.ash.reader.domain.model.article.ArticleWithFeed @OptIn(ExperimentalFoundationApi::class) fun LazyListScope.ArticleList( pagingItems: LazyPagingItems, - isFilterUnread: Boolean, + diffMap: Map, isShowFeedIcon: Boolean, isShowStickyHeader: Boolean, articleListTonalElevation: Int, @@ -40,9 +40,10 @@ fun LazyListScope.ArticleList( ) { index -> when (val item = pagingItems[index]) { is ArticleFlowItem.Article -> { + val article = item.articleWithFeed.article SwipeableArticleItem( articleWithFeed = item.articleWithFeed, - isFilterUnread = isFilterUnread, + isUnread = diffMap[article.id]?.isUnread ?: article.isUnread, articleListTonalElevation = articleListTonalElevation, onClick = onClick, isSwipeEnabled = isSwipeEnabled, @@ -70,9 +71,10 @@ fun LazyListScope.ArticleList( when (val item = pagingItems.peek(index)) { is ArticleFlowItem.Article -> { item(key = key(item), contentType = contentType(item)) { + val article = item.articleWithFeed.article SwipeableArticleItem( articleWithFeed = item.articleWithFeed, - isFilterUnread = isFilterUnread, + isUnread = diffMap[article.id]?.isUnread ?: article.isUnread, articleListTonalElevation = articleListTonalElevation, onClick = onClick, isSwipeEnabled = isSwipeEnabled, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt index fd93e09f..86631b90 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -43,7 +44,6 @@ import kotlinx.coroutines.launch import me.ash.reader.R import me.ash.reader.domain.model.article.ArticleWithFeed import me.ash.reader.domain.model.general.Filter -import me.ash.reader.domain.model.general.MarkAsReadConditions import me.ash.reader.infrastructure.preference.LocalFlowArticleListDateStickyHeader import me.ash.reader.infrastructure.preference.LocalFlowArticleListFeedIcon import me.ash.reader.infrastructure.preference.LocalFlowArticleListTonalElevation @@ -107,6 +107,12 @@ fun FlowPage( } } + DisposableEffect(pagingItems) { + onDispose { + flowViewModel.commitDiff() + } + } + DisposableEffect(owner) { homeViewModel.syncWorkLiveData.observe(owner) { workInfoList -> workInfoList.let { @@ -127,15 +133,16 @@ fun FlowPage( val onToggleRead: (ArticleWithFeed) -> Unit = remember { { article -> - flowViewModel.updateReadStatus( - groupId = null, - feedId = null, - articleId = article.article.id, - conditions = MarkAsReadConditions.All, - isUnread = !article.article.isUnread, - ) + val id = article.article.id + val isUnread = article.article.isUnread + + with(flowViewModel.diffMap) { + if (contains(id)) remove(id) + else put(id, Diff(isUnread = !isUnread)) + } } } + val onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = remember { { flowViewModel.markAsReadFromListByDate( @@ -326,7 +333,7 @@ fun FlowPage( } ArticleList( pagingItems = pagingItems, - isFilterUnread = filterUiState.filter == Filter.Unread, + diffMap = flowViewModel.diffMap, isShowFeedIcon = articleListFeedIcon.value, isShowStickyHeader = articleListDateStickyHeader.value, articleListTonalElevation = articleListTonalElevation.value, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt index 408cded5..194af1ca 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt @@ -1,24 +1,22 @@ package me.ash.reader.ui.page.home.flow import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.ViewModel import androidx.paging.compose.LazyPagingItems import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import me.ash.reader.domain.model.article.ArticleFlowItem -import me.ash.reader.domain.model.article.ArticleWithFeed import me.ash.reader.domain.model.general.MarkAsReadConditions import me.ash.reader.domain.service.RssService import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.IODispatcher import java.util.Date -import java.util.function.BiPredicate import javax.inject.Inject @HiltViewModel @@ -32,6 +30,7 @@ class FlowViewModel @Inject constructor( private val _flowUiState = MutableStateFlow(FlowUiState()) val flowUiState: StateFlow = _flowUiState.asStateFlow() + val diffMap = mutableStateMapOf() fun sync() { applicationScope.launch(ioDispatcher) { @@ -45,10 +44,8 @@ class FlowViewModel @Inject constructor( articleId: String?, conditions: MarkAsReadConditions, isUnread: Boolean, - withDelay: Long = 0, ) { applicationScope.launch(ioDispatcher) { - delay(withDelay) rssService.get().markAsRead( groupId = groupId, feedId = feedId, @@ -62,11 +59,8 @@ class FlowViewModel @Inject constructor( fun updateStarredStatus( articleId: String?, isStarred: Boolean, - withDelay: Long = 0, ) { applicationScope.launch(ioDispatcher) { - // FIXME: a dirty hack to ensure the swipe animation doesn't get interrupted when recomposed, remove this after implementing a lazy tag! - delay(withDelay) if (articleId != null) { rssService.get().markAsStarred( articleId = articleId, @@ -97,6 +91,21 @@ class FlowViewModel @Inject constructor( rssService.get().batchMarkAsRead(articleIds = articleIdSet, isUnread = false) } } + + fun commitDiff() { + applicationScope.launch(ioDispatcher) { + val markAsReadArticles = + diffMap.filter { !it.value.isUnread }.map { it.key }.toSet() + val markAsUnreadArticles = + diffMap.filter { it.value.isUnread }.map { it.key }.toSet() + + rssService.get() + .batchMarkAsRead(articleIds = markAsReadArticles, isUnread = false) + rssService.get() + .batchMarkAsRead(articleIds = markAsUnreadArticles, isUnread = true) + + }.invokeOnCompletion { diffMap.clear() } + } } data class FlowUiState( @@ -105,3 +114,5 @@ data class FlowUiState( val isBack: Boolean = false, val syncWorkInfo: String = "", ) + +data class Diff(val isUnread: Boolean)