Merge branch 'main' into adaptive

This commit is contained in:
junkfood 2024-11-10 22:41:55 +08:00
commit 5862cf36ae
No known key found for this signature in database
GPG Key ID: 2EA5B648DB112A34
20 changed files with 1070 additions and 211 deletions

View File

@ -158,7 +158,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

@ -16,14 +16,15 @@ import me.ash.reader.ui.ext.put
val LocalFlowArticleListReadIndicator =
compositionLocalOf<FlowArticleReadIndicatorPreference> { 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<Boolean>]) {
true -> ExcludingStarred
false -> AllRead
when (preferences[DataStoreKey.keys[flowArticleListReadIndicator]?.key as Preferences.Key<Int>]) {
0 -> ExcludingStarred
1 -> AllRead
2 -> None
else -> default
}
}
}
operator fun FlowArticleReadIndicatorPreference.not(): FlowArticleReadIndicatorPreference =
when (value) {
true -> FlowArticleReadIndicatorPreference.AllRead
false -> FlowArticleReadIndicatorPreference.ExcludingStarred
}
}

View File

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

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

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

View File

@ -131,7 +131,7 @@ data class DataStoreKey<T>(
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<T>(
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),

View File

@ -1,14 +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
@ -90,8 +82,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"
@ -100,6 +92,7 @@ fun ArticleItem(
modifier: Modifier = Modifier,
articleWithFeed: ArticleWithFeed,
isHighlighted: Boolean = false,
isUnread: Boolean = articleWithFeed.article.isUnread,
onClick: (ArticleWithFeed) -> Unit = {},
onLongClick: (() -> Unit)? = null
) {
@ -115,8 +108,8 @@ fun ArticleItem(
dateString = article.dateString,
imgData = article.img,
isStarred = article.isStarred,
isUnread = article.isUnread,
isHighlighted = isHighlighted,
isUnread = isUnread,
onClick = { onClick(articleWithFeed) },
onLongClick = onLongClick
)
@ -159,6 +152,9 @@ fun ArticleItem(
.alpha(
if (isHighlighted) 1f else {
when (articleListReadIndicator) {
FlowArticleReadIndicatorPreference.None -> 1f
FlowArticleReadIndicatorPreference.AllRead -> {
if (isUnread) 1f else 0.5f
}
@ -294,11 +290,12 @@ private const val SwipeActionDelay = 300L
fun SwipeableArticleItem(
articleWithFeed: ArticleWithFeed,
isHighlighted: Boolean = false,
isUnread: Boolean = articleWithFeed.article.isUnread,
articleListTonalElevation: Int = 0,
onClick: (ArticleWithFeed) -> Unit = {},
isMenuEnabled: Boolean = true,
onToggleStarred: (ArticleWithFeed) -> Unit = { },
onToggleRead: (ArticleWithFeed) -> Unit = { },
onToggleStarred: (ArticleWithFeed) -> Unit = {},
onToggleRead: (ArticleWithFeed) -> Unit = {},
onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = null,
onMarkBelowAsRead: ((ArticleWithFeed) -> Unit)? = null,
onShare: ((ArticleWithFeed) -> Unit)? = null,
@ -323,7 +320,7 @@ fun SwipeableArticleItem(
SwipeActionBox(
articleWithFeed = articleWithFeed,
isRead = !articleWithFeed.article.isUnread,
isRead = !isUnread,
isStarred = articleWithFeed.article.isStarred,
onToggleStarred = onToggleStarred,
onToggleRead = onToggleRead
@ -350,6 +347,7 @@ fun SwipeableArticleItem(
ArticleItem(
articleWithFeed = articleWithFeed,
isHighlighted = isHighlighted,
isUnread = isUnread,
onClick = onClick,
onLongClick = onLongClick,
)

View File

@ -16,6 +16,7 @@ import me.ash.reader.domain.model.article.ArticleWithFeed
@OptIn(ExperimentalFoundationApi::class)
fun LazyListScope.ArticleList(
pagingItems: LazyPagingItems<ArticleFlowItem>,
diffMap: Map<String, Diff>,
isShowFeedIcon: Boolean,
isShowStickyHeader: Boolean,
articleListTonalElevation: Int,
@ -39,9 +40,11 @@ fun LazyListScope.ArticleList(
) { index ->
when (val item = pagingItems[index]) {
is ArticleFlowItem.Article -> {
val article = item.articleWithFeed.article
SwipeableArticleItem(
articleWithFeed = item.articleWithFeed,
isHighlighted = readingArticleId == item.articleWithFeed.article.id,
isUnread = diffMap[article.id]?.isUnread ?: article.isUnread,
articleListTonalElevation = articleListTonalElevation,
onClick = onClick,
isMenuEnabled = isMenuEnabled,
@ -68,9 +71,11 @@ 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,
isHighlighted = readingArticleId == item.articleWithFeed.article.id,
isUnread = diffMap[article.id]?.isUnread ?: article.isUnread,
articleListTonalElevation = articleListTonalElevation,
onClick = onClick,
isMenuEnabled = isMenuEnabled,

View File

@ -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
@ -44,7 +45,6 @@ import me.ash.reader.R
import me.ash.reader.domain.model.article.ArticleFlowItem
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
@ -110,6 +110,12 @@ fun FlowPage(
}
}
DisposableEffect(pagingItems) {
onDispose {
flowViewModel.commitDiff()
}
}
DisposableEffect(owner) {
homeViewModel.syncWorkLiveData.observe(owner) { workInfoList ->
workInfoList.let {
@ -130,15 +136,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(
@ -338,6 +345,7 @@ fun FlowPage(
ArticleList(
pagingItems = pagingItems,
readingArticleId = readingArticleId,
diffMap = flowViewModel.diffMap,
isShowFeedIcon = articleListFeedIcon.value,
isShowStickyHeader = articleListDateStickyHeader.value,
articleListTonalElevation = articleListTonalElevation.value,

View File

@ -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> = _flowUiState.asStateFlow()
val diffMap = mutableStateMapOf<String, Diff>()
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)

View File

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

View File

@ -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(),
@ -68,21 +70,21 @@ fun Content(
}
} else {
when (renderer) {
ReadingRendererPreference.WebView -> {
Column(
modifier = modifier
.padding(top = contentPadding.calculateTopPadding())
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
) {
// Top bar height
Spacer(modifier = Modifier.height(64.dp))
// padding
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
modifier = Modifier.padding(horizontal = 12.dp)
) {
DisableSelection {
Metadata(

View File

@ -5,10 +5,9 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalOverscrollConfiguration
import androidx.compose.foundation.layout.Box
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
@ -19,12 +18,15 @@ import androidx.compose.runtime.LaunchedEffect
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isSpecified
@ -33,6 +35,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.launch
import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
@ -58,6 +61,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()
@ -75,6 +79,12 @@ fun ReadingPage(
true
}
var showTopDivider by remember {
mutableStateOf(false)
}
var bringToTop by remember { mutableStateOf(false) }
val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList
LaunchedEffect(Unit) {
@ -99,23 +109,23 @@ 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,
onClick = { bringToTop = true },
onClose = {
navController.popBackStack()
},
)
}
val isNextArticleAvailable = !readerState.nextArticleId.isNullOrEmpty()
val isPreviousArticleAvailable = !readerState.previousArticleId.isNullOrEmpty()
@ -159,12 +169,31 @@ fun ReadingPage(
}
)
val listState = rememberSaveable(
inputs = arrayOf(content),
saver = LazyListState.Saver
) { LazyListState() }
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)
CompositionLocalProvider(
LocalOverscrollConfiguration provides
@ -197,6 +226,7 @@ fun ReadingPage(
link = link,
publishedDate = publishedDate,
isLoading = content is ReaderState.Loading,
scrollState = scrollState,
listState = listState,
onImageClick = { imgUrl, altText ->
currentImageData = ImageData(imgUrl, altText)

View File

@ -1,22 +1,38 @@
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
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
import androidx.compose.runtime.remember
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 +40,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,9 +48,10 @@ 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? = "",
onClick: (() -> Unit)? = null,
onClose: () -> Unit = {},
) {
val context = LocalContext.current
@ -48,43 +64,70 @@ 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(modifier = if (onClick == null) Modifier else Modifier.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
) {
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
)
}
}
}
}
}