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.readability4j)
implementation(libs.rome) implementation(libs.rome)
implementation(libs.telephoto) implementation(libs.telephoto)
implementation(libs.swipe)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.coroutines) implementation(libs.okhttp.coroutines)
implementation(libs.retrofit) implementation(libs.retrofit)

View File

@ -16,14 +16,15 @@ import me.ash.reader.ui.ext.put
val LocalFlowArticleListReadIndicator = val LocalFlowArticleListReadIndicator =
compositionLocalOf<FlowArticleReadIndicatorPreference> { FlowArticleReadIndicatorPreference.default } compositionLocalOf<FlowArticleReadIndicatorPreference> { FlowArticleReadIndicatorPreference.default }
sealed class FlowArticleReadIndicatorPreference(val value: Boolean) : Preference() { sealed class FlowArticleReadIndicatorPreference(val value: Int) : Preference() {
object ExcludingStarred : FlowArticleReadIndicatorPreference(true) data object ExcludingStarred : FlowArticleReadIndicatorPreference(0)
object AllRead : FlowArticleReadIndicatorPreference(false) data object AllRead : FlowArticleReadIndicatorPreference(1)
data object None : FlowArticleReadIndicatorPreference(2)
override fun put(context: Context, scope: CoroutineScope) { override fun put(context: Context, scope: CoroutineScope) {
scope.launch { scope.launch {
context.dataStore.put( context.dataStore.put(
DataStoreKey.flowArticleListReadIndicator, flowArticleListReadIndicator,
value value
) )
} }
@ -34,26 +35,22 @@ sealed class FlowArticleReadIndicatorPreference(val value: Boolean) : Preference
return when (this) { return when (this) {
AllRead -> stringResource(id = R.string.all_read) AllRead -> stringResource(id = R.string.all_read)
ExcludingStarred -> stringResource(id = R.string.read_excluding_starred) ExcludingStarred -> stringResource(id = R.string.read_excluding_starred)
None -> stringResource(id = R.string.none)
} }
} }
companion object { companion object {
val default = ExcludingStarred val default = ExcludingStarred
val values = listOf(ExcludingStarred, AllRead) val values = listOf(ExcludingStarred, AllRead, None)
fun fromPreferences(preferences: Preferences) = fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKey.keys[flowArticleListReadIndicator]?.key as Preferences.Key<Boolean>]) { when (preferences[DataStoreKey.keys[flowArticleListReadIndicator]?.key as Preferences.Key<Int>]) {
true -> ExcludingStarred 0 -> ExcludingStarred
false -> AllRead 1 -> AllRead
2 -> None
else -> default 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 } val LocalReadingTextLineHeight = compositionLocalOf { ReadingTextLineHeightPreference.default }
data object ReadingTextLineHeightPreference { data object ReadingTextLineHeightPreference {
const val default = 1.5F const val default = 1.0F
private val range = 0.8F..2F private val range = 0.8F..2F
fun put(context: Context, scope: CoroutineScope, value: Float) { 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 { :root {
/* --font-family: Inter; */ /* --font-family: Inter; */
--font-size: ${fontSize}px; --font-size: ${fontSize}px;
--line-height: ${lineHeight}; --line-height: ${lineHeight * 1.5f};
--letter-spacing: ${letterSpacing}px; --letter-spacing: ${letterSpacing}px;
--text-margin: ${textMargin}px; --text-margin: ${textMargin}px;
--text-color: ${argbToCssColor(textColor)}; --text-color: ${argbToCssColor(textColor)};

View File

@ -131,7 +131,7 @@ data class DataStoreKey<T>(
const val flowArticleListTime = "flowArticleListTime" const val flowArticleListTime = "flowArticleListTime"
const val flowArticleListDateStickyHeader = "flowArticleListDateStickyHeader" const val flowArticleListDateStickyHeader = "flowArticleListDateStickyHeader"
const val flowArticleListTonalElevation = "flowArticleListTonalElevation" const val flowArticleListTonalElevation = "flowArticleListTonalElevation"
const val flowArticleListReadIndicator = "flowArticleListReadIndicator" const val flowArticleListReadIndicator = "flowArticleListReadStatusIndicator"
// Reading page // Reading page
const val readingRenderer = "readingRender" const val readingRenderer = "readingRender"
@ -207,7 +207,7 @@ data class DataStoreKey<T>(
flowArticleListTime to DataStoreKey(booleanPreferencesKey(flowArticleListTime), Boolean::class.java), flowArticleListTime to DataStoreKey(booleanPreferencesKey(flowArticleListTime), Boolean::class.java),
flowArticleListDateStickyHeader to DataStoreKey(booleanPreferencesKey(flowArticleListDateStickyHeader), Boolean::class.java), flowArticleListDateStickyHeader to DataStoreKey(booleanPreferencesKey(flowArticleListDateStickyHeader), Boolean::class.java),
flowArticleListTonalElevation to DataStoreKey(intPreferencesKey(flowArticleListTonalElevation), Int::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 // Reading page
readingRenderer to DataStoreKey(intPreferencesKey(readingRenderer), Int::class.java), readingRenderer to DataStoreKey(intPreferencesKey(readingRenderer), Int::class.java),
readingBionicReading to DataStoreKey(booleanPreferencesKey(readingBionicReading), Boolean::class.java), readingBionicReading to DataStoreKey(booleanPreferencesKey(readingBionicReading), Boolean::class.java),

View File

@ -1,14 +1,6 @@
package me.ash.reader.ui.page.home.flow package me.ash.reader.ui.page.home.flow
import android.util.Log
import android.view.HapticFeedbackConstants 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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable 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.Shape20
import me.ash.reader.ui.theme.applyTextDirection import me.ash.reader.ui.theme.applyTextDirection
import me.ash.reader.ui.theme.palette.onDark import me.ash.reader.ui.theme.palette.onDark
import me.saket.swipe.SwipeAction import me.ash.reader.ui.component.swipe.SwipeAction
import me.saket.swipe.SwipeableActionsBox import me.ash.reader.ui.component.swipe.SwipeableActionsBox
private const val TAG = "ArticleItem" private const val TAG = "ArticleItem"
@ -100,6 +92,7 @@ fun ArticleItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
articleWithFeed: ArticleWithFeed, articleWithFeed: ArticleWithFeed,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
isUnread: Boolean = articleWithFeed.article.isUnread,
onClick: (ArticleWithFeed) -> Unit = {}, onClick: (ArticleWithFeed) -> Unit = {},
onLongClick: (() -> Unit)? = null onLongClick: (() -> Unit)? = null
) { ) {
@ -115,8 +108,8 @@ fun ArticleItem(
dateString = article.dateString, dateString = article.dateString,
imgData = article.img, imgData = article.img,
isStarred = article.isStarred, isStarred = article.isStarred,
isUnread = article.isUnread,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
isUnread = isUnread,
onClick = { onClick(articleWithFeed) }, onClick = { onClick(articleWithFeed) },
onLongClick = onLongClick onLongClick = onLongClick
) )
@ -159,6 +152,9 @@ fun ArticleItem(
.alpha( .alpha(
if (isHighlighted) 1f else { if (isHighlighted) 1f else {
when (articleListReadIndicator) { when (articleListReadIndicator) {
FlowArticleReadIndicatorPreference.None -> 1f
FlowArticleReadIndicatorPreference.AllRead -> { FlowArticleReadIndicatorPreference.AllRead -> {
if (isUnread) 1f else 0.5f if (isUnread) 1f else 0.5f
} }
@ -294,11 +290,12 @@ private const val SwipeActionDelay = 300L
fun SwipeableArticleItem( fun SwipeableArticleItem(
articleWithFeed: ArticleWithFeed, articleWithFeed: ArticleWithFeed,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
isUnread: Boolean = articleWithFeed.article.isUnread,
articleListTonalElevation: Int = 0, articleListTonalElevation: Int = 0,
onClick: (ArticleWithFeed) -> Unit = {}, onClick: (ArticleWithFeed) -> Unit = {},
isMenuEnabled: Boolean = true, isMenuEnabled: Boolean = true,
onToggleStarred: (ArticleWithFeed) -> Unit = { }, onToggleStarred: (ArticleWithFeed) -> Unit = {},
onToggleRead: (ArticleWithFeed) -> Unit = { }, onToggleRead: (ArticleWithFeed) -> Unit = {},
onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = null, onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = null,
onMarkBelowAsRead: ((ArticleWithFeed) -> Unit)? = null, onMarkBelowAsRead: ((ArticleWithFeed) -> Unit)? = null,
onShare: ((ArticleWithFeed) -> Unit)? = null, onShare: ((ArticleWithFeed) -> Unit)? = null,
@ -323,7 +320,7 @@ fun SwipeableArticleItem(
SwipeActionBox( SwipeActionBox(
articleWithFeed = articleWithFeed, articleWithFeed = articleWithFeed,
isRead = !articleWithFeed.article.isUnread, isRead = !isUnread,
isStarred = articleWithFeed.article.isStarred, isStarred = articleWithFeed.article.isStarred,
onToggleStarred = onToggleStarred, onToggleStarred = onToggleStarred,
onToggleRead = onToggleRead onToggleRead = onToggleRead
@ -350,6 +347,7 @@ fun SwipeableArticleItem(
ArticleItem( ArticleItem(
articleWithFeed = articleWithFeed, articleWithFeed = articleWithFeed,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
isUnread = isUnread,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
) )

View File

@ -16,6 +16,7 @@ import me.ash.reader.domain.model.article.ArticleWithFeed
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
fun LazyListScope.ArticleList( fun LazyListScope.ArticleList(
pagingItems: LazyPagingItems<ArticleFlowItem>, pagingItems: LazyPagingItems<ArticleFlowItem>,
diffMap: Map<String, Diff>,
isShowFeedIcon: Boolean, isShowFeedIcon: Boolean,
isShowStickyHeader: Boolean, isShowStickyHeader: Boolean,
articleListTonalElevation: Int, articleListTonalElevation: Int,
@ -39,9 +40,11 @@ fun LazyListScope.ArticleList(
) { index -> ) { index ->
when (val item = pagingItems[index]) { when (val item = pagingItems[index]) {
is ArticleFlowItem.Article -> { is ArticleFlowItem.Article -> {
val article = item.articleWithFeed.article
SwipeableArticleItem( SwipeableArticleItem(
articleWithFeed = item.articleWithFeed, articleWithFeed = item.articleWithFeed,
isHighlighted = readingArticleId == item.articleWithFeed.article.id, isHighlighted = readingArticleId == item.articleWithFeed.article.id,
isUnread = diffMap[article.id]?.isUnread ?: article.isUnread,
articleListTonalElevation = articleListTonalElevation, articleListTonalElevation = articleListTonalElevation,
onClick = onClick, onClick = onClick,
isMenuEnabled = isMenuEnabled, isMenuEnabled = isMenuEnabled,
@ -68,9 +71,11 @@ fun LazyListScope.ArticleList(
when (val item = pagingItems.peek(index)) { when (val item = pagingItems.peek(index)) {
is ArticleFlowItem.Article -> { is ArticleFlowItem.Article -> {
item(key = key(item), contentType = contentType(item)) { item(key = key(item), contentType = contentType(item)) {
val article = item.articleWithFeed.article
SwipeableArticleItem( SwipeableArticleItem(
articleWithFeed = item.articleWithFeed, articleWithFeed = item.articleWithFeed,
isHighlighted = readingArticleId == item.articleWithFeed.article.id, isHighlighted = readingArticleId == item.articleWithFeed.article.id,
isUnread = diffMap[article.id]?.isUnread ?: article.isUnread,
articleListTonalElevation = articleListTonalElevation, articleListTonalElevation = articleListTonalElevation,
onClick = onClick, onClick = onClick,
isMenuEnabled = isMenuEnabled, 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.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel 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.ArticleFlowItem
import me.ash.reader.domain.model.article.ArticleWithFeed import me.ash.reader.domain.model.article.ArticleWithFeed
import me.ash.reader.domain.model.general.Filter 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.LocalFlowArticleListDateStickyHeader
import me.ash.reader.infrastructure.preference.LocalFlowArticleListFeedIcon import me.ash.reader.infrastructure.preference.LocalFlowArticleListFeedIcon
import me.ash.reader.infrastructure.preference.LocalFlowArticleListTonalElevation import me.ash.reader.infrastructure.preference.LocalFlowArticleListTonalElevation
@ -110,6 +110,12 @@ fun FlowPage(
} }
} }
DisposableEffect(pagingItems) {
onDispose {
flowViewModel.commitDiff()
}
}
DisposableEffect(owner) { DisposableEffect(owner) {
homeViewModel.syncWorkLiveData.observe(owner) { workInfoList -> homeViewModel.syncWorkLiveData.observe(owner) { workInfoList ->
workInfoList.let { workInfoList.let {
@ -130,15 +136,16 @@ fun FlowPage(
val onToggleRead: (ArticleWithFeed) -> Unit = remember { val onToggleRead: (ArticleWithFeed) -> Unit = remember {
{ article -> { article ->
flowViewModel.updateReadStatus( val id = article.article.id
groupId = null, val isUnread = article.article.isUnread
feedId = null,
articleId = article.article.id, with(flowViewModel.diffMap) {
conditions = MarkAsReadConditions.All, if (contains(id)) remove(id)
isUnread = !article.article.isUnread, else put(id, Diff(isUnread = !isUnread))
)
} }
} }
}
val onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = remember { val onMarkAboveAsRead: ((ArticleWithFeed) -> Unit)? = remember {
{ {
flowViewModel.markAsReadFromListByDate( flowViewModel.markAsReadFromListByDate(
@ -338,6 +345,7 @@ fun FlowPage(
ArticleList( ArticleList(
pagingItems = pagingItems, pagingItems = pagingItems,
readingArticleId = readingArticleId, readingArticleId = readingArticleId,
diffMap = flowViewModel.diffMap,
isShowFeedIcon = articleListFeedIcon.value, isShowFeedIcon = articleListFeedIcon.value,
isShowStickyHeader = articleListDateStickyHeader.value, isShowStickyHeader = articleListDateStickyHeader.value,
articleListTonalElevation = articleListTonalElevation.value, articleListTonalElevation = articleListTonalElevation.value,

View File

@ -1,24 +1,22 @@
package me.ash.reader.ui.page.home.flow package me.ash.reader.ui.page.home.flow
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.ash.reader.domain.model.article.ArticleFlowItem 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.model.general.MarkAsReadConditions
import me.ash.reader.domain.service.RssService import me.ash.reader.domain.service.RssService
import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.ApplicationScope
import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.di.IODispatcher
import java.util.Date import java.util.Date
import java.util.function.BiPredicate
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -32,6 +30,7 @@ class FlowViewModel @Inject constructor(
private val _flowUiState = MutableStateFlow(FlowUiState()) private val _flowUiState = MutableStateFlow(FlowUiState())
val flowUiState: StateFlow<FlowUiState> = _flowUiState.asStateFlow() val flowUiState: StateFlow<FlowUiState> = _flowUiState.asStateFlow()
val diffMap = mutableStateMapOf<String, Diff>()
fun sync() { fun sync() {
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
@ -45,10 +44,8 @@ class FlowViewModel @Inject constructor(
articleId: String?, articleId: String?,
conditions: MarkAsReadConditions, conditions: MarkAsReadConditions,
isUnread: Boolean, isUnread: Boolean,
withDelay: Long = 0,
) { ) {
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
delay(withDelay)
rssService.get().markAsRead( rssService.get().markAsRead(
groupId = groupId, groupId = groupId,
feedId = feedId, feedId = feedId,
@ -62,11 +59,8 @@ class FlowViewModel @Inject constructor(
fun updateStarredStatus( fun updateStarredStatus(
articleId: String?, articleId: String?,
isStarred: Boolean, isStarred: Boolean,
withDelay: Long = 0,
) { ) {
applicationScope.launch(ioDispatcher) { 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) { if (articleId != null) {
rssService.get().markAsStarred( rssService.get().markAsStarred(
articleId = articleId, articleId = articleId,
@ -97,6 +91,21 @@ class FlowViewModel @Inject constructor(
rssService.get().batchMarkAsRead(articleIds = articleIdSet, isUnread = false) 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( data class FlowUiState(
@ -105,3 +114,5 @@ data class FlowUiState(
val isBack: Boolean = false, val isBack: Boolean = false,
val syncWorkInfo: String = "", val syncWorkInfo: String = "",
) )
data class Diff(val isUnread: Boolean)

View File

@ -1,6 +1,13 @@
package me.ash.reader.ui.page.home.reading package me.ash.reader.ui.page.home.reading
import android.view.HapticFeedbackConstants 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row 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.ExpandMore
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -25,6 +33,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import me.ash.reader.R import me.ash.reader.R
@ -59,12 +68,18 @@ fun BottomBar(
.zIndex(1f), .zIndex(1f),
contentAlignment = Alignment.BottomCenter contentAlignment = Alignment.BottomCenter
) { ) {
RYExtensibleVisibility(visible = isShow) { AnimatedVisibility(
val view = LocalView.current visible = isShow,
enter = expandVertically(),
Surface( exit = shrinkVertically()
tonalElevation = tonalElevation.value.dp,
) { ) {
val view = LocalView.current
Column {
HorizontalDivider(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
thickness = 0.5f.dp
)
Surface() {
// TODO: Component styles await refactoring // TODO: Component styles await refactoring
Row( Row(
modifier = Modifier modifier = Modifier
@ -171,4 +186,5 @@ fun BottomBar(
} }
} }
} }
}
} }

View File

@ -1,5 +1,6 @@
package me.ash.reader.ui.page.home.reading package me.ash.reader.ui.page.home.reading
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -47,6 +48,7 @@ fun Content(
author: String? = null, author: String? = null,
link: String? = null, link: String? = null,
publishedDate: Date, publishedDate: Date,
scrollState: ScrollState,
listState: LazyListState, listState: LazyListState,
isLoading: Boolean, isLoading: Boolean,
contentPadding: PaddingValues = PaddingValues(), contentPadding: PaddingValues = PaddingValues(),
@ -68,21 +70,21 @@ fun Content(
} }
} else { } else {
when (renderer) { when (renderer) {
ReadingRendererPreference.WebView -> { ReadingRendererPreference.WebView -> {
Column( Column(
modifier = modifier modifier = modifier
.padding(top = contentPadding.calculateTopPadding()) .padding(top = contentPadding.calculateTopPadding())
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(scrollState)
) { ) {
// Top bar height // Top bar height
Spacer(modifier = Modifier.height(64.dp)) Spacer(modifier = Modifier.height(64.dp))
// padding // padding
Column( Column(
modifier = Modifier modifier = Modifier.padding(horizontal = 12.dp)
.padding(horizontal = 12.dp)
) { ) {
DisableSelection { DisableSelection {
Metadata( Metadata(

View File

@ -5,10 +5,9 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.LocalOverscrollConfiguration
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -19,12 +18,15 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
@ -33,6 +35,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.launch
import me.ash.reader.R import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
@ -58,6 +61,7 @@ fun ReadingPage(
readingViewModel: ReadingViewModel = hiltViewModel(), readingViewModel: ReadingViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val hapticFeedback = LocalHapticFeedback.current
val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value
val readingUiState = readingViewModel.readingUiState.collectAsStateValue() val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue() val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
@ -75,6 +79,12 @@ fun ReadingPage(
true true
} }
var showTopDivider by remember {
mutableStateOf(false)
}
var bringToTop by remember { mutableStateOf(false) }
val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -99,23 +109,23 @@ fun ReadingPage(
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
// topBarTonalElevation = tonalElevation.value.dp,
// containerTonalElevation = tonalElevation.value.dp,
content = { paddings -> content = { paddings ->
Log.i("RLog", "TopBar: recomposition") Log.i("RLog", "TopBar: recomposition")
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Top Bar if (readerState.articleId != null) {
TopBar( TopBar(
navController = navController, navController = navController,
isShow = isShowToolBar, isShow = isShowToolBar,
windowInsets = WindowInsets(top = paddings.calculateTopPadding()), showDivider = showTopDivider,
title = readerState.title, title = readerState.title,
link = readerState.link, link = readerState.link,
onClick = { bringToTop = true },
onClose = { onClose = {
navController.popBackStack() navController.popBackStack()
}, },
) )
}
val isNextArticleAvailable = !readerState.nextArticleId.isNullOrEmpty() val isNextArticleAvailable = !readerState.nextArticleId.isNullOrEmpty()
val isPreviousArticleAvailable = !readerState.previousArticleId.isNullOrEmpty() val isPreviousArticleAvailable = !readerState.previousArticleId.isNullOrEmpty()
@ -159,12 +169,31 @@ fun ReadingPage(
} }
) )
val listState = rememberSaveable( val listState = rememberSaveable(
inputs = arrayOf(content), inputs = arrayOf(content),
saver = LazyListState.Saver saver = LazyListState.Saver
) { LazyListState() } ) { 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( CompositionLocalProvider(
LocalOverscrollConfiguration provides LocalOverscrollConfiguration provides
@ -197,6 +226,7 @@ fun ReadingPage(
link = link, link = link,
publishedDate = publishedDate, publishedDate = publishedDate,
isLoading = content is ReaderState.Loading, isLoading = content is ReaderState.Loading,
scrollState = scrollState,
listState = listState, listState = listState,
onImageClick = { imgUrl, altText -> onImageClick = { imgUrl, altText ->
currentImageData = ImageData(imgUrl, altText) currentImageData = ImageData(imgUrl, altText)

View File

@ -1,22 +1,38 @@
package me.ash.reader.ui.page.home.reading 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize 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.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.navigation.NavHostController 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.LocalReadingPageTonalElevation
import me.ash.reader.infrastructure.preference.LocalSharedContent import me.ash.reader.infrastructure.preference.LocalSharedContent
import me.ash.reader.ui.component.base.FeedbackIconButton 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.ext.surfaceColorAtElevation
import me.ash.reader.ui.page.common.RouteName import me.ash.reader.ui.page.common.RouteName
@ -33,9 +48,10 @@ import me.ash.reader.ui.page.common.RouteName
fun TopBar( fun TopBar(
navController: NavHostController, navController: NavHostController,
isShow: Boolean, isShow: Boolean,
windowInsets: WindowInsets = WindowInsets(0.dp), showDivider: Boolean = false,
title: String? = "", title: String? = "",
link: String? = "", link: String? = "",
onClick: (() -> Unit)? = null,
onClose: () -> Unit = {}, onClose: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -48,11 +64,30 @@ fun TopBar(
.zIndex(1f), .zIndex(1f),
contentAlignment = Alignment.TopCenter contentAlignment = Alignment.TopCenter
) { ) {
RYExtensibleVisibility(visible = isShow) { 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( TopAppBar(
title = {}, title = {},
modifier = Modifier, modifier = Modifier,
windowInsets = windowInsets, windowInsets = WindowInsets(0.dp),
navigationIcon = { navigationIcon = {
FeedbackIconButton( FeedbackIconButton(
imageVector = Icons.Rounded.Close, imageVector = Icons.Rounded.Close,
@ -61,8 +96,7 @@ fun TopBar(
) { ) {
onClose() onClose()
} }
}, }, actions = {
actions = {
FeedbackIconButton( FeedbackIconButton(
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
imageVector = Icons.Outlined.Palette, imageVector = Icons.Outlined.Palette,
@ -82,9 +116,18 @@ fun TopBar(
sharedContent.share(context, title, link) sharedContent.share(context, title, link)
} }
}, colors = TopAppBarDefaults.topAppBarColors( }, colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(tonalElevation.value.dp), containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
tonalElevation.value.dp
),
) )
) )
} }
if (showDivider) {
HorizontalDivider(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
thickness = 0.5f.dp
)
}
}
} }
} }