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:
junkfood 2024-02-07 03:06:52 +08:00 committed by GitHub
parent 80f335ab71
commit 1b758dfca5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 641 additions and 114 deletions

View File

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

View File

@ -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,11 +58,22 @@ fun Content(
val openLink = LocalOpenLink.current
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
if (isLoading) {
Column {
CircularProgressIndicator(
modifier = Modifier
.size(30.dp),
color = MaterialTheme.colorScheme.onSurface,
)
}
} else {
SelectionContainer {
LazyColumn(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.drawVerticalScrollbar(listState),
.drawVerticalScrollbar(listState)
.offset(x = 0.dp, y = (pullToLoadState.offsetFraction * 80).dp),
state = listState,
) {
item {
@ -65,26 +96,6 @@ fun Content(
}
}
}
item {
Spacer(modifier = Modifier.height(22.dp))
RYExtensibleVisibility(visible = isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column {
Spacer(modifier = Modifier.height(22.dp))
CircularProgressIndicator(
modifier = Modifier
.size(30.dp),
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(22.dp))
}
}
}
}
if (!isLoading) {
Reader(
context = context,
subheadUpperCase = subheadUpperCase.value,
@ -95,7 +106,7 @@ fun Content(
context.openURL(it, openLink, openLinkSpecificBrowser)
}
)
}
item {
Spacer(modifier = Modifier.height(128.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
@ -103,3 +114,4 @@ fun Content(
}
}
}
}

View File

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

View File

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

View File

@ -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,41 +101,88 @@ 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
})
)
.padding(paddings),
content = content.text ?: "",
feedName = feedName,
title = title.toString(),
@ -127,21 +191,25 @@ fun ReadingPage(
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()

View File

@ -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
}
}
_readingUiState.update { it.copy(nextArticleId = null) }
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
}
}
}
_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
}

View File

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

View File

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