mirror of
https://github.com/Ashinch/ReadYou.git
synced 2025-02-08 08:08:40 +01:00
feat(ui): swipe up and down to switch between articles (#589)
* build(deps): bump up dependencies, compile sdk, and gradle version (#502) * build(deps): bump up dependencies, compile sdk, and gradle version * build(deps): remove redundant safe-args plugin * build(deps): update Compose BOM to `2024.01.00` & compiler to `1.5.8` * fix(i18n): configuration loss when switching locale (#541) * fix(i18n): configuration loss when switching locale * feat(locale): enable auto-localeconfig * feat(i18n): add languages to in-app language picker (#571) * feat(i18n): add languages to in-app language picker * fix(i18n): locale system settings not working for Android 13 * feat(i18n): show selected language at settings page * fix(ci): ignore ExtraTranslation for linter * feat(i18n): add fallback in in-app language picker for A13+ * chore: clean up * fix(ui): ProgressIndicator crashes in m3 1.1.2 * fix(ui): NavigationBarItem color * feat(ui): grey out read articles even if starred (#547) * refactor(ui): improve add account dialog * fix(ui): accessing listState on io thread causes app to crash * fix(ui): NavigationBar text color * feat(ui): show full screen image viewer when clicking on images (#578) * feat(ui): add crash report activity to handle uncaught exceptions (#576) * feat(ui): swipe up and down to switch between articles (WIP) * feat(ui): update animation * docs(ui): add comments on pull to load implementation * feat(ui): move the indicator to another file * build: revert changes * feat(ui): make the transition directions match the content changes --------- Co-authored-by: MauroGuida <57829432+MauroGuida@users.noreply.github.com> Co-authored-by: Ash <Glaxyinfinite@outlook.com> Co-authored-by: Ash <Ashinch@outlook.it>
This commit is contained in:
parent
80f335ab71
commit
1b758dfca5
@ -34,7 +34,7 @@ fun LazyListScope.Reader(
|
||||
onImageClick: ((imgUrl: String, altText: String) -> Unit)? = null,
|
||||
onLinkClick: (String) -> Unit
|
||||
) {
|
||||
Log.i("RLog", "Reader: ")
|
||||
// Log.i("RLog", "Reader: ")
|
||||
htmlFormattedText(
|
||||
inputStream = content.byteInputStream(),
|
||||
subheadUpperCase = subheadUpperCase,
|
||||
|
@ -1,28 +1,47 @@
|
||||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.selection.DisableSelection
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowUp
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.ash.reader.infrastructure.preference.LocalOpenLink
|
||||
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser
|
||||
import me.ash.reader.infrastructure.preference.LocalReadingSubheadUpperCase
|
||||
import me.ash.reader.ui.component.base.RYExtensibleVisibility
|
||||
import me.ash.reader.ui.component.reader.Reader
|
||||
import me.ash.reader.ui.ext.drawVerticalScrollbar
|
||||
import me.ash.reader.ui.ext.openURL
|
||||
import me.ash.reader.ui.ext.pagerAnimate
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun Content(
|
||||
modifier: Modifier = Modifier,
|
||||
content: String,
|
||||
feedName: String,
|
||||
title: String,
|
||||
@ -31,6 +50,7 @@ fun Content(
|
||||
publishedDate: Date,
|
||||
listState: LazyListState,
|
||||
isLoading: Boolean,
|
||||
pullToLoadState: PullToLoadState,
|
||||
onImageClick: ((imgUrl: String, altText: String) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@ -38,53 +58,44 @@ fun Content(
|
||||
val openLink = LocalOpenLink.current
|
||||
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
|
||||
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawVerticalScrollbar(listState),
|
||||
state = listState,
|
||||
) {
|
||||
item {
|
||||
// Top bar height
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
// padding
|
||||
Spacer(modifier = Modifier.height(22.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
DisableSelection {
|
||||
Metadata(
|
||||
feedName = feedName,
|
||||
title = title,
|
||||
author = author,
|
||||
link = link,
|
||||
publishedDate = publishedDate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(22.dp))
|
||||
RYExtensibleVisibility(visible = isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
if (isLoading) {
|
||||
Column {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.drawVerticalScrollbar(listState)
|
||||
.offset(x = 0.dp, y = (pullToLoadState.offsetFraction * 80).dp),
|
||||
state = listState,
|
||||
) {
|
||||
item {
|
||||
// Top bar height
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
// padding
|
||||
Spacer(modifier = Modifier.height(22.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(22.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
DisableSelection {
|
||||
Metadata(
|
||||
feedName = feedName,
|
||||
title = title,
|
||||
author = author,
|
||||
link = link,
|
||||
publishedDate = publishedDate,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isLoading) {
|
||||
Reader(
|
||||
context = context,
|
||||
subheadUpperCase = subheadUpperCase.value,
|
||||
@ -95,10 +106,11 @@ fun Content(
|
||||
context.openURL(it, openLink, openLinkSpecificBrowser)
|
||||
}
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(128.dp))
|
||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(128.dp))
|
||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,317 @@
|
||||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
|
||||
import androidx.compose.animation.core.FloatExponentialDecaySpec
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.animateDecay
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private const val TAG = "PullRelease"
|
||||
|
||||
/**
|
||||
* A [NestedScrollConnection] that provides scroll events to a hoisted [state].
|
||||
*
|
||||
* Note that this modifier must be added above a scrolling container using [Modifier.nestedScroll],
|
||||
* such as a lazy column, in order to receive scroll events.
|
||||
*
|
||||
* And you should manually handle the offset of components
|
||||
* with [PullToLoadState.progress] or [PullToLoadState.offsetFraction]
|
||||
*
|
||||
* @param state The [PullToLoadState] associated with this pull-to-load component.
|
||||
* The state will be updated by this connection.
|
||||
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored.
|
||||
* @param onScroll Used for detecting if the reader is scrolling down
|
||||
*/
|
||||
class ReaderNestedScrollConnection(
|
||||
private val state: PullToLoadState,
|
||||
private val enabled: Boolean,
|
||||
private val onScroll: (Float) -> Unit
|
||||
) : NestedScrollConnection {
|
||||
|
||||
override fun onPreScroll(
|
||||
available: Offset, source: NestedScrollSource
|
||||
): Offset {
|
||||
onScroll(available.y)
|
||||
return when {
|
||||
!enabled || available.y == 0f -> Offset.Zero
|
||||
|
||||
// Scroll down to reduce the progress when the offset is currently pulled up, same for the opposite
|
||||
source == Drag && state.offsetFraction.signOpposites(available.y) -> {
|
||||
Offset(0f, state.onPull(available.y))
|
||||
}
|
||||
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset, available: Offset, source: NestedScrollSource
|
||||
): Offset = when {
|
||||
!enabled -> Offset.Zero
|
||||
source == Drag -> Offset(0f, state.onPull(available.y)) // Pull to load
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
return if (abs(state.progress) > 1f) {
|
||||
state.onRelease(available.y)
|
||||
Velocity.Zero
|
||||
} else {
|
||||
state.animateDistanceTo(0f)
|
||||
Velocity.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a [PullToLoadState] that is remembered across compositions.
|
||||
*
|
||||
* Changes from [ReaderNestedScrollConnection] will result in this state being updated.
|
||||
*
|
||||
*
|
||||
* @param key Key used for remembering the state
|
||||
* @param onLoadNext The function to be called to load the next item when pulled up.
|
||||
* @param onLoadPrevious The function to be called to load the previous item when pulled down.
|
||||
* @param loadThreshold The threshold below which, if a release
|
||||
* occurs, [onLoadNext] or [onLoadPrevious] will be called.
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterialApi
|
||||
fun rememberPullToLoadState(
|
||||
key: Any?,
|
||||
onLoadPrevious: () -> Unit,
|
||||
onLoadNext: () -> Unit,
|
||||
loadThreshold: Dp = PullToLoadDefaults.LoadThreshold,
|
||||
): PullToLoadState {
|
||||
require(loadThreshold > 0.dp) { "The load trigger must be greater than zero!" }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val onNext = rememberUpdatedState(onLoadNext)
|
||||
val onPrevious = rememberUpdatedState(onLoadPrevious)
|
||||
val thresholdPx: Float
|
||||
|
||||
with(LocalDensity.current) {
|
||||
thresholdPx = loadThreshold.toPx()
|
||||
}
|
||||
|
||||
val state = remember(key, scope) {
|
||||
PullToLoadState(
|
||||
animationScope = scope,
|
||||
onLoadPrevious = onPrevious,
|
||||
onLoadNext = onNext,
|
||||
threshold = thresholdPx
|
||||
)
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
state.setThreshold(thresholdPx)
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be used in conjunction with [ReaderNestedScrollConnection] to add pull-to-load
|
||||
* behaviour to a scroll component. Based on Android's SwipeRefreshLayout.
|
||||
*
|
||||
* Provides [progress], a float representing how far the user has pulled as a percentage of the
|
||||
* [threshold]. Values of one or less indicate that the user has not yet pulled past the
|
||||
* threshold. Values greater than one indicate how far past the threshold the user has pulled.
|
||||
*
|
||||
*
|
||||
* Should be created using [rememberPullToLoadState].
|
||||
*/
|
||||
class PullToLoadState internal constructor(
|
||||
private val animationScope: CoroutineScope,
|
||||
private val onLoadPrevious: State<() -> Unit>,
|
||||
private val onLoadNext: State<() -> Unit>,
|
||||
threshold: Float
|
||||
) {
|
||||
/**
|
||||
* A float representing how far the user has pulled as a percentage of the [threshold].
|
||||
*
|
||||
* If the component has not been pulled at all, progress is zero. If the pull has reached
|
||||
* halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has
|
||||
* gone beyond the [threshold] - e.g. a value of 2f indicates that the user has pulled to
|
||||
* two times the [threshold].
|
||||
*/
|
||||
val progress get() = abs(offsetPulled) / threshold
|
||||
|
||||
/**
|
||||
* The offset fraction calculated from [progress] and [status],
|
||||
* This fraction grows in linear when the [progress] is no greater than 1,
|
||||
* then grows exponentially with the rate 1/2 if the [progress] greater than 1. - e.g. a value
|
||||
* of 2f indicates that the user has pulled to **four** times the [threshold].
|
||||
*
|
||||
* @return The offset fraction currently of this state, could be negative if the content is pulling up
|
||||
*/
|
||||
val offsetFraction: Float get() = calculateOffsetFraction()
|
||||
|
||||
sealed interface Status {
|
||||
data object PullingUp : Status
|
||||
|
||||
data object PullingDown : Status
|
||||
|
||||
data object PulledDown : Status
|
||||
|
||||
data object PulledUp : Status
|
||||
|
||||
data object Idle : Status
|
||||
}
|
||||
|
||||
val status: Status
|
||||
get() = when {
|
||||
offsetPulled < threshold && offsetPulled > 0f -> Status.PullingDown
|
||||
offsetPulled > -threshold && offsetPulled < 0f -> Status.PullingUp
|
||||
offsetPulled >= threshold -> Status.PulledDown
|
||||
offsetPulled <= -threshold -> Status.PulledUp
|
||||
else -> Status.Idle
|
||||
}
|
||||
|
||||
private val threshold get() = _threshold
|
||||
|
||||
|
||||
private var offsetPulled by mutableFloatStateOf(0f)
|
||||
private var _threshold by mutableFloatStateOf(threshold)
|
||||
|
||||
internal fun onPull(pullDelta: Float): Float {
|
||||
val consumed = if (offsetPulled.signOpposites(offsetPulled + pullDelta)) {
|
||||
-offsetPulled
|
||||
} else {
|
||||
pullDelta
|
||||
}
|
||||
/*
|
||||
Log.d(
|
||||
TAG,
|
||||
"onPull: currentOffset = $offsetPulled, pullDelta = $pullDelta, consumed = $consumed"
|
||||
)*/
|
||||
|
||||
|
||||
offsetPulled += consumed
|
||||
return consumed
|
||||
}
|
||||
|
||||
internal fun onRelease(velocity: Float): Float {
|
||||
// val consumed = when {
|
||||
// // We are flinging without having dragged the pull refresh (for example a fling inside
|
||||
// // a list) - don't consume
|
||||
// distancePulled == 0f -> 0f
|
||||
// // If the velocity is negative, the fling is upwards, and we don't want to prevent the
|
||||
// // the list from scrolling
|
||||
// velocity < 0f -> 0f
|
||||
// // We are showing the indicator, and the fling is downwards - consume everything
|
||||
// else -> velocity
|
||||
// }
|
||||
when (status) {
|
||||
// We don't change the pull offset here because the animation for loading another content
|
||||
// should be handled outside, and this state will be soon disposed
|
||||
Status.PulledDown -> {
|
||||
onLoadPrevious.value()
|
||||
}
|
||||
|
||||
Status.PulledUp -> {
|
||||
onLoadNext.value()
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Snap to 0f and hide the indicator
|
||||
animateDistanceTo(0f)
|
||||
}
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
|
||||
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
|
||||
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
|
||||
// overhead of running through the animation pipeline instead of directly mutating the state.
|
||||
private val mutatorMutex = MutatorMutex()
|
||||
internal fun animateDistanceTo(float: Float, velocity: Float = 0f) {
|
||||
animationScope.launch {
|
||||
mutatorMutex.mutate {
|
||||
animate(
|
||||
initialValue = offsetPulled,
|
||||
targetValue = float,
|
||||
initialVelocity = velocity
|
||||
) { value, _ ->
|
||||
offsetPulled = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun flingWithVelocity(initialVelocity: Float) {
|
||||
animationScope.launch {
|
||||
mutatorMutex.mutate {
|
||||
animateDecay(
|
||||
initialValue = offsetPulled,
|
||||
initialVelocity = initialVelocity,
|
||||
animationSpec = FloatExponentialDecaySpec(
|
||||
frictionMultiplier = 3f,
|
||||
absVelocityThreshold = 10f
|
||||
)
|
||||
) { value, _ ->
|
||||
if (abs(value) > threshold) {
|
||||
cancel()
|
||||
} else {
|
||||
onPull(value - offsetPulled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.invokeOnCompletion { animateDistanceTo(0f) }
|
||||
}
|
||||
|
||||
internal fun setThreshold(threshold: Float) {
|
||||
_threshold = threshold
|
||||
}
|
||||
|
||||
private fun calculateOffsetFraction(): Float = when (status) {
|
||||
Status.Idle -> 0f
|
||||
Status.PulledDown -> sqrt(progress)
|
||||
Status.PulledUp -> -sqrt(progress)
|
||||
Status.PullingDown -> progress
|
||||
Status.PullingUp -> -progress
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun Float.signOpposites(f: Float): Boolean =
|
||||
(this > 0f && f < 0f) || (this < 0f && f > 0f)
|
||||
|
||||
/**
|
||||
* Default parameter values for [rememberPullToLoadState].
|
||||
*/
|
||||
@ExperimentalMaterialApi
|
||||
object PullToLoadDefaults {
|
||||
/**
|
||||
* If the indicator is below this threshold offset when it is released, the load action
|
||||
* will be triggered.
|
||||
*/
|
||||
val LoadThreshold = 120.dp
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun BoxScope.PullToLoadIndicator(state:PullToLoadState) {
|
||||
state.status.run {
|
||||
val fraction = state.offsetFraction
|
||||
val absFraction = abs(fraction)
|
||||
val imageVector = when (this) {
|
||||
PullToLoadState.Status.PulledDown -> Icons.Outlined.KeyboardArrowUp
|
||||
PullToLoadState.Status.PulledUp -> Icons.Outlined.KeyboardArrowDown
|
||||
else -> null
|
||||
}
|
||||
|
||||
val alignment = if (fraction < 0f) {
|
||||
Alignment.BottomCenter
|
||||
} else {
|
||||
Alignment.TopCenter
|
||||
}
|
||||
if (this != PullToLoadState.Status.Idle) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.padding(vertical = 80.dp)
|
||||
.offset(y = (fraction * 48).dp)
|
||||
.width(36.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = MaterialTheme.shapes.extraLarge
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = imageVector, modifier = Modifier.align(
|
||||
Alignment.CenterHorizontally
|
||||
), transitionSpec = {
|
||||
(fadeIn(animationSpec = tween(220, delayMillis = 0)))
|
||||
.togetherWith(fadeOut(animationSpec = tween(90)))
|
||||
}, label = ""
|
||||
) {
|
||||
if (it != null) {
|
||||
Icon(
|
||||
imageVector = it,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.padding(vertical = (2 * absFraction).dp)
|
||||
.size(32.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
.height((12 * absFraction).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -2,32 +2,45 @@ package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.LocalOverscrollConfiguration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
|
||||
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
|
||||
import me.ash.reader.ui.component.base.RYScaffold
|
||||
import me.ash.reader.ui.ext.collectAsStateValue
|
||||
import me.ash.reader.ui.ext.isScrollDown
|
||||
import me.ash.reader.ui.motion.materialSharedAxisY
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
private const val UPWARD = 1
|
||||
private const val DOWNWARD = -1
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ReadingPage(
|
||||
navController: NavHostController,
|
||||
@ -45,7 +58,7 @@ fun ReadingPage(
|
||||
var currentImageData by remember { mutableStateOf(ImageData()) }
|
||||
|
||||
val isShowToolBar = if (LocalReadingAutoHideToolbar.current.value) {
|
||||
readingUiState.articleId != null && !isReaderScrollingDown
|
||||
readerState.articleId != null && !isReaderScrollingDown
|
||||
} else {
|
||||
true
|
||||
}
|
||||
@ -55,28 +68,32 @@ fun ReadingPage(
|
||||
LaunchedEffect(Unit) {
|
||||
navController.currentBackStackEntryFlow.collect {
|
||||
it.arguments?.getString("articleId")?.let { articleId ->
|
||||
if (readingUiState.articleId != articleId) {
|
||||
if (readerState.articleId != articleId) {
|
||||
readingViewModel.initData(articleId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(readingUiState.articleId) {
|
||||
LaunchedEffect(readerState.articleId) {
|
||||
Log.i("RLog", "ReadPage: ${readingUiState.articleWithFeed}")
|
||||
readingUiState.articleId?.let {
|
||||
readingViewModel.updateNextArticleId(pagingItems)
|
||||
readerState.articleId?.let {
|
||||
if (readingUiState.isUnread) {
|
||||
readingViewModel.markAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RYScaffold(
|
||||
topBarTonalElevation = tonalElevation.value.dp,
|
||||
containerTonalElevation = tonalElevation.value.dp,
|
||||
content = {
|
||||
LaunchedEffect(readerState.articleId, pagingItems.size) {
|
||||
if (pagingItems.isNotEmpty() && readerState.articleId != null)
|
||||
readingViewModel.prefetchArticleId(pagingItems)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
// topBarTonalElevation = tonalElevation.value.dp,
|
||||
// containerTonalElevation = tonalElevation.value.dp,
|
||||
content = { paddings ->
|
||||
Log.i("RLog", "TopBar: recomposition")
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
@ -84,64 +101,115 @@ fun ReadingPage(
|
||||
TopBar(
|
||||
navController = navController,
|
||||
isShow = isShowToolBar,
|
||||
windowInsets = WindowInsets(top = paddings.calculateTopPadding()),
|
||||
title = readerState.title,
|
||||
link = readerState.link,
|
||||
onClose = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
val context = LocalContext.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
val isNextArticleAvailable = !readerState.nextArticleId.isNullOrEmpty()
|
||||
|
||||
|
||||
if (readingUiState.articleId != null) {
|
||||
if (readerState.articleId != null) {
|
||||
// Content
|
||||
AnimatedContent(
|
||||
targetState = readerState,
|
||||
contentKey = { it.content },
|
||||
transitionSpec = {
|
||||
if (initialState.title != targetState.title)
|
||||
materialSharedAxisY(
|
||||
initialOffsetY = { (it * 0.1f).toInt() },
|
||||
targetOffsetY = { (it * -0.1f).toInt() })
|
||||
else {
|
||||
ContentTransform(
|
||||
targetContentEnter = EnterTransition.None,
|
||||
initialContentExit = ExitTransition.None, sizeTransform = null
|
||||
)
|
||||
val direction = when {
|
||||
initialState.nextArticleId == targetState.articleId -> UPWARD
|
||||
initialState.previousArticleId == targetState.articleId -> DOWNWARD
|
||||
initialState.articleId == targetState.articleId -> {
|
||||
when (targetState.content) {
|
||||
is ReaderState.Description -> DOWNWARD
|
||||
else -> UPWARD
|
||||
}
|
||||
}
|
||||
|
||||
else -> UPWARD
|
||||
}
|
||||
materialSharedAxisY(
|
||||
initialOffsetY = { (it * 0.1f * direction).toInt() },
|
||||
targetOffsetY = { (it * -0.1f * direction).toInt() })
|
||||
}, label = ""
|
||||
) {
|
||||
|
||||
it.run {
|
||||
val state =
|
||||
rememberPullToLoadState(
|
||||
key = content,
|
||||
onLoadNext = {
|
||||
readingViewModel.loadNext()
|
||||
},
|
||||
onLoadPrevious = {
|
||||
readingViewModel.loadPrevious()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LaunchedEffect(state.status) {
|
||||
when (state.status) {
|
||||
PullToLoadState.Status.PulledDown, PullToLoadState.Status.PulledUp -> {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val listState = rememberSaveable(
|
||||
inputs = arrayOf(content),
|
||||
saver = LazyListState.Saver
|
||||
) { LazyListState() }
|
||||
|
||||
isReaderScrollingDown = listState.isScrollDown()
|
||||
CompositionLocalProvider(LocalOverscrollConfiguration provides null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Content(
|
||||
modifier = Modifier
|
||||
.nestedScroll(
|
||||
ReaderNestedScrollConnection(
|
||||
state = state,
|
||||
enabled = true,
|
||||
onScroll = { f ->
|
||||
if (abs(f) > 2f)
|
||||
isReaderScrollingDown = f < 0f
|
||||
})
|
||||
)
|
||||
|
||||
Content(
|
||||
content = content.text ?: "",
|
||||
feedName = feedName,
|
||||
title = title.toString(),
|
||||
author = author,
|
||||
link = link,
|
||||
publishedDate = publishedDate,
|
||||
isLoading = content is ReaderState.Loading,
|
||||
listState = listState,
|
||||
onImageClick = { imgUrl, altText ->
|
||||
currentImageData = ImageData(imgUrl, altText)
|
||||
showFullScreenImageViewer = true
|
||||
.padding(paddings),
|
||||
content = content.text ?: "",
|
||||
feedName = feedName,
|
||||
title = title.toString(),
|
||||
author = author,
|
||||
link = link,
|
||||
publishedDate = publishedDate,
|
||||
isLoading = content is ReaderState.Loading,
|
||||
listState = listState,
|
||||
pullToLoadState = state,
|
||||
onImageClick = { imgUrl, altText ->
|
||||
currentImageData = ImageData(imgUrl, altText)
|
||||
showFullScreenImageViewer = true
|
||||
}
|
||||
)
|
||||
PullToLoadIndicator(state = state)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bottom Bar
|
||||
if (readingUiState.articleId != null) {
|
||||
if (readerState.articleId != null) {
|
||||
BottomBar(
|
||||
isShow = isShowToolBar,
|
||||
isUnread = readingUiState.isUnread,
|
||||
isStarred = readingUiState.isStarred,
|
||||
isNextArticleAvailable = readingUiState.run { !nextArticleId.isNullOrEmpty() && nextArticleId != articleId },
|
||||
isNextArticleAvailable = isNextArticleAvailable,
|
||||
isFullContent = readerState.content is ReaderState.FullContent,
|
||||
onUnread = {
|
||||
readingViewModel.updateReadStatus(it)
|
||||
@ -150,7 +218,7 @@ fun ReadingPage(
|
||||
readingViewModel.updateStarredStatus(it)
|
||||
},
|
||||
onNextArticle = {
|
||||
readingUiState.nextArticleId?.let { readingViewModel.initData(it) }
|
||||
readingViewModel.loadNext()
|
||||
},
|
||||
onFullContent = {
|
||||
if (it) readingViewModel.renderFullContent()
|
||||
|
@ -1,7 +1,6 @@
|
||||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.ItemSnapshotList
|
||||
@ -46,19 +45,19 @@ class ReadingViewModel @Inject constructor(
|
||||
get() = readingUiState.value.articleWithFeed?.feed
|
||||
|
||||
fun initData(articleId: String) {
|
||||
showLoading()
|
||||
setLoading()
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
rssService.get().findArticleById(articleId)?.run {
|
||||
_readingUiState.update {
|
||||
it.copy(
|
||||
articleWithFeed = this,
|
||||
articleId = article.id,
|
||||
isStarred = article.isStarred,
|
||||
isUnread = article.isUnread
|
||||
)
|
||||
}
|
||||
_readerState.update {
|
||||
it.copy(
|
||||
articleId = article.id,
|
||||
feedName = feed.name,
|
||||
title = article.title,
|
||||
author = article.author,
|
||||
@ -92,7 +91,7 @@ class ReadingViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun internalRenderFullContent() {
|
||||
showLoading()
|
||||
setLoading()
|
||||
runCatching {
|
||||
rssHelper.parseFullContent(
|
||||
currentArticle?.link ?: "",
|
||||
@ -102,7 +101,7 @@ class ReadingViewModel @Inject constructor(
|
||||
_readerState.update { it.copy(content = ReaderState.FullContent(content = content)) }
|
||||
}.onFailure { th ->
|
||||
Log.i("RLog", "renderFullContent: ${th.message}")
|
||||
_readerState.update { it.copy(content = ReaderState.Error(th.message)) }
|
||||
_readerState.update { it.copy(content = ReaderState.Error(th.message.toString())) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,42 +136,83 @@ class ReadingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoading() {
|
||||
private fun setLoading() {
|
||||
_readerState.update {
|
||||
it.copy(content = ReaderState.Loading)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNextArticleId(pagingItems: ItemSnapshotList<ArticleFlowItem>) {
|
||||
fun prefetchArticleId(pagingItems: ItemSnapshotList<ArticleFlowItem>) {
|
||||
val items = pagingItems.items
|
||||
val currentId = currentArticle?.id
|
||||
val index = items.indexOfFirst { item ->
|
||||
item is ArticleFlowItem.Article && item.articleWithFeed.article.id == currentArticle?.id
|
||||
item is ArticleFlowItem.Article && item.articleWithFeed.article.id == currentId
|
||||
}
|
||||
items.subList(index + 1, items.size).forEach { item ->
|
||||
if (item is ArticleFlowItem.Article) {
|
||||
_readingUiState.update { it.copy(nextArticleId = item.articleWithFeed.article.id) }
|
||||
return
|
||||
var previousId: String? = null
|
||||
var nextId: String? = null
|
||||
|
||||
if (index != -1 || currentId == null) {
|
||||
val prevIterator = items.listIterator(index)
|
||||
while (prevIterator.hasPrevious()) {
|
||||
Log.d("Log", "index: $index, previous: ${prevIterator.previousIndex()}")
|
||||
val prev = prevIterator.previous()
|
||||
if (prev is ArticleFlowItem.Article) {
|
||||
previousId = prev.articleWithFeed.article.id
|
||||
break
|
||||
}
|
||||
}
|
||||
val nextIterator = items.listIterator(index + 1)
|
||||
while (nextIterator.hasNext()) {
|
||||
Log.d("Log", "index: $index, next: ${nextIterator.nextIndex()}")
|
||||
val next = nextIterator.next()
|
||||
if (next is ArticleFlowItem.Article && next.articleWithFeed.article.id != currentId) {
|
||||
nextId = next.articleWithFeed.article.id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
_readingUiState.update { it.copy(nextArticleId = null) }
|
||||
|
||||
_readerState.update {
|
||||
it.copy(
|
||||
nextArticleId = nextId,
|
||||
previousArticleId = previousId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fun loadPrevious(): Boolean {
|
||||
readerStateStateFlow.value.previousArticleId?.run {
|
||||
initData(this)
|
||||
} ?: return false
|
||||
return true
|
||||
}
|
||||
|
||||
fun loadNext(): Boolean {
|
||||
readerStateStateFlow.value.nextArticleId?.run {
|
||||
initData(this)
|
||||
} ?: return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
data class ReadingUiState(
|
||||
val articleWithFeed: ArticleWithFeed? = null,
|
||||
val articleId: String? = null,
|
||||
val isUnread: Boolean = false,
|
||||
val isStarred: Boolean = false,
|
||||
val nextArticleId: String? = null,
|
||||
)
|
||||
|
||||
data class ReaderState(
|
||||
val articleId: String? = null,
|
||||
val feedName: String = "",
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val link: String? = null,
|
||||
val publishedDate: Date = Date(0L),
|
||||
val content: ContentState = Description(null)
|
||||
val content: ContentState = Loading,
|
||||
val nextArticleId: String? = null,
|
||||
val previousArticleId: String? = null
|
||||
) {
|
||||
sealed interface ContentState {
|
||||
val text: String?
|
||||
@ -186,9 +226,8 @@ data class ReaderState(
|
||||
}
|
||||
}
|
||||
|
||||
data class FullContent(val content: String?) : ContentState
|
||||
data class Description(val content: String?) : ContentState
|
||||
data class Error(val message: String?) : ContentState
|
||||
|
||||
object Loading: ContentState
|
||||
data class FullContent(val content: String) : ContentState
|
||||
data class Description(val content: String) : ContentState
|
||||
data class Error(val message: String) : ContentState
|
||||
data object Loading : ContentState
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ import me.ash.reader.ui.page.common.RouteName
|
||||
fun TopBar(
|
||||
navController: NavHostController,
|
||||
isShow: Boolean,
|
||||
windowInsets: WindowInsets = WindowInsets(0.dp),
|
||||
title: String? = "",
|
||||
link: String? = "",
|
||||
onClose: () -> Unit = {},
|
||||
@ -49,7 +50,7 @@ fun TopBar(
|
||||
TopAppBar(
|
||||
title = {},
|
||||
modifier = Modifier,
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
windowInsets = windowInsets,
|
||||
navigationIcon = {
|
||||
FeedbackIconButton(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
|
@ -413,4 +413,5 @@
|
||||
<string name="copy_error_report">Copy error report</string>
|
||||
<string name="submit_bug_report">submit a bug report on GitHub</string>
|
||||
<string name="unexpected_error_msg">The app encountered an unexpected error and had to close.\n\nTo help us identify and fix this issue quickly, you can %1$s with the error stack trace below.</string>
|
||||
<string name="next_article">Next article</string>
|
||||
</resources>
|
||||
|
Loading…
x
Reference in New Issue
Block a user