diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt b/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt index d9b668749..3c7f0ba6e 100644 --- a/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt +++ b/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt @@ -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, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt index dd21e8bc6..029facc2e 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt @@ -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)) + } } } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/PullToLoad.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/PullToLoad.kt new file mode 100644 index 000000000..56feea0f3 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/PullToLoad.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/PullToLoadIndicator.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/PullToLoadIndicator.kt new file mode 100644 index 000000000..65e21db9a --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/PullToLoadIndicator.kt @@ -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) + ) + } + } + + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt index 4b25571a9..b10c1cc79 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt @@ -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() } } + } + LaunchedEffect(readerState.articleId, pagingItems.size) { + if (pagingItems.isNotEmpty() && readerState.articleId != null) + readingViewModel.prefetchArticleId(pagingItems) } - RYScaffold( - topBarTonalElevation = tonalElevation.value.dp, - containerTonalElevation = tonalElevation.value.dp, - content = { + 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() - - 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 + 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(), + 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() diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt index 3f43355bf..a641e8518 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt @@ -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) { + fun prefetchArticleId(pagingItems: ItemSnapshotList) { 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 } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt index 8332d7e7d..fbff00869 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/TopBar.kt @@ -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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6ed88f01..90d1f5ac3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -413,4 +413,5 @@ Copy error report submit a bug report on GitHub 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. + Next article