Merge branch 'main' into adaptive
This commit is contained in:
commit
5862cf36ae
@ -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)
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
@ -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)
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
package me.ash.reader.ui.component.swipe
|
||||||
|
|
||||||
|
internal const val animationDurationMs = 4_00
|
@ -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()
|
||||||
|
}
|
@ -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)};
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
@ -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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user