refactor(ui): refactor `ReadingPage` & `ReadingViewModel` (#559)

* refactor(ui): refactor `ReadingPage` & `ReadingViewModel`

* fix(ui): disable action when next article unavailable
This commit is contained in:
junkfood 2024-01-22 17:26:31 +08:00 committed by GitHub
parent de5e6cf0f8
commit 610895fcdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 228 additions and 134 deletions

View File

@ -27,7 +27,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
@ -114,3 +116,62 @@ public fun materialSharedAxisXOut(
easing = FastOutLinearInEasing
)
)
/**
* [materialSharedAxisY] allows to switch a layout with shared Y-axis transition.
*
*/
@OptIn(ExperimentalAnimationApi::class)
public fun materialSharedAxisY(
initialOffsetY: (fullWidth: Int) -> Int,
targetOffsetY: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ContentTransform = ContentTransform(materialSharedAxisYIn(
initialOffsetY = initialOffsetY,
durationMillis = durationMillis
), materialSharedAxisYOut(
targetOffsetY = targetOffsetY,
durationMillis = durationMillis
))
/**
* [materialSharedAxisYIn] allows to switch a layout with shared Y-axis enter transition.
*/
public fun materialSharedAxisYIn(
initialOffsetY: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): EnterTransition = slideInVertically(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
initialOffsetY = initialOffsetY
) + fadeIn(
animationSpec = tween(
durationMillis = durationMillis.ForIncoming,
delayMillis = durationMillis.ForOutgoing,
easing = LinearOutSlowInEasing
)
)
/**
* [materialSharedAxisYOut] allows to switch a layout with shared X-axis exit transition.
*
*/
public fun materialSharedAxisYOut(
targetOffsetY: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ExitTransition = slideOutVertically (
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
targetOffsetY = targetOffsetY
) + fadeOut(
animationSpec = tween(
durationMillis = durationMillis.ForOutgoing,
delayMillis = 0,
easing = FastOutLinearInEasing
)
)

View File

@ -30,6 +30,7 @@ fun BottomBar(
isShow: Boolean,
isUnread: Boolean,
isStarred: Boolean,
isNextArticleAvailable: Boolean,
isFullContent: Boolean,
onUnread: (isUnread: Boolean) -> Unit = {},
onStarred: (isStarred: Boolean) -> Unit = {},
@ -96,7 +97,7 @@ fun BottomBar(
onStarred(!isStarred)
}
CanBeDisabledIconButton(
disabled = false,
disabled = !isNextArticleAvailable,
modifier = Modifier.size(40.dp),
imageVector = Icons.Rounded.ExpandMore,
contentDescription = "Next Article",

View File

@ -2,22 +2,27 @@ package me.ash.reader.ui.page.home.reading
import android.util.Log
import androidx.compose.animation.*
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
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
@OptIn(ExperimentalAnimationApi::class)
@ -29,33 +34,35 @@ fun ReadingPage(
) {
val tonalElevation = LocalReadingPageTonalElevation.current
val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
val isShowToolBar = if (LocalReadingAutoHideToolbar.current.value) {
readingUiState.articleWithFeed != null && !readingUiState.listState.isScrollDown()
readingUiState.articleId != null && !readingUiState.listState.isScrollDown()
} else {
true
}
val pagingItems = homeUiState.pagingData.collectAsLazyPagingItems().itemSnapshotList
readingViewModel.recorderNextArticle(pagingItems)
LaunchedEffect(Unit) {
navController.currentBackStackEntryFlow.collect {
it.arguments?.getString("articleId")?.let { articleId ->
if (readingUiState.articleWithFeed?.article?.id != articleId) {
if (readingUiState.articleId != articleId) {
readingViewModel.initData(articleId)
}
}
}
}
LaunchedEffect(readingUiState.articleWithFeed?.article?.id) {
LaunchedEffect(readingUiState.articleId) {
Log.i("RLog", "ReadPage: ${readingUiState.articleWithFeed}")
readingUiState.articleWithFeed?.let {
if (it.article.isUnread) {
readingViewModel.markUnread(false)
readingUiState.articleId?.let {
readingViewModel.updateNextArticleId(pagingItems)
if (readingUiState.isUnread) {
readingViewModel.markAsRead()
}
}
}
RYScaffold(
@ -69,61 +76,63 @@ fun ReadingPage(
TopBar(
navController = navController,
isShow = isShowToolBar,
title = readingUiState.articleWithFeed?.article?.title,
link = readingUiState.articleWithFeed?.article?.link,
title = readerState.title,
link = readerState.link,
onClose = {
navController.popBackStack()
},
)
// Content
if (readingUiState.articleWithFeed != null) {
if (readingUiState.articleId != null) {
// Content
AnimatedContent(
targetState = readingUiState.content ?: "",
targetState = readerState,
transitionSpec = {
slideInVertically(
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessLow,
)
) { height -> height / 2 } with slideOutVertically { height -> -(height / 2) } + fadeOut(
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessLow,
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
)
}
}
) {
it.run {
Content(
content = content.text ?: "",
feedName = feedName,
title = title.toString(),
author = author,
link = link,
publishedDate = publishedDate,
isLoading = content is ReaderState.Loading,
listState = readingUiState.listState,
isShowToolBar = isShowToolBar,
)
}
) { target ->
Content(
content = target,
feedName = readingUiState.articleWithFeed.feed.name,
title = readingUiState.articleWithFeed.article.title,
author = readingUiState.articleWithFeed.article.author,
link = readingUiState.articleWithFeed.article.link,
publishedDate = readingUiState.articleWithFeed.article.date,
isLoading = readingUiState.isLoading,
listState = readingUiState.listState,
isShowToolBar = isShowToolBar,
)
}
}
// Bottom Bar
if (readingUiState.articleWithFeed != null) {
if (readingUiState.articleId != null) {
BottomBar(
isShow = isShowToolBar,
isUnread = readingUiState.articleWithFeed.article.isUnread,
isStarred = readingUiState.articleWithFeed.article.isStarred,
isFullContent = readingUiState.isFullContent,
isUnread = readingUiState.isUnread,
isStarred = readingUiState.isStarred,
isNextArticleAvailable = readingUiState.run { !nextArticleId.isNullOrEmpty() && nextArticleId != articleId },
isFullContent = readerState.content is ReaderState.FullContent,
onUnread = {
readingViewModel.markUnread(it)
readingViewModel.updateReadStatus(it)
},
onStarred = {
readingViewModel.markStarred(it)
readingViewModel.updateStarredStatus(it)
},
onNextArticle = {
if (readingUiState.nextArticleId.isNotEmpty()) {
readingViewModel.initData(readingUiState.nextArticleId)
}
readingUiState.nextArticleId?.let { readingViewModel.initData(it) }
},
onFullContent = {
if (it) readingViewModel.renderFullContent()

View File

@ -12,10 +12,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.ash.reader.domain.model.article.Article
import me.ash.reader.domain.model.feed.Feed
import me.ash.reader.domain.model.article.ArticleFlowItem
import me.ash.reader.domain.model.article.ArticleWithFeed
import me.ash.reader.infrastructure.rss.RssHelper
import me.ash.reader.domain.service.RssService
import java.util.Date
import javax.inject.Inject
@HiltViewModel
@ -27,14 +30,38 @@ class ReadingViewModel @Inject constructor(
private val _readingUiState = MutableStateFlow(ReadingUiState())
val readingUiState: StateFlow<ReadingUiState> = _readingUiState.asStateFlow()
private val _readerState: MutableStateFlow<ReaderState> = MutableStateFlow(ReaderState())
val readerStateStateFlow = _readerState.asStateFlow()
private val currentArticle: Article?
get() = readingUiState.value.articleWithFeed?.article
private val currentFeed: Feed?
get() = readingUiState.value.articleWithFeed?.feed
fun initData(articleId: String) {
showLoading()
viewModelScope.launch {
_readingUiState.update {
it.copy(articleWithFeed = rssService.get().findArticleById(articleId))
rssService.get().findArticleById(articleId)?.run {
_readingUiState.update {
it.copy(
articleWithFeed = this,
articleId = article.id,
isStarred = article.isStarred,
isUnread = article.isUnread
)
}
_readerState.update {
it.copy(
feedName = feed.name,
title = article.title,
author = article.author,
link = article.link,
publishedDate = article.date,
)
}
}
_readingUiState.value.articleWithFeed?.let {
if (it.feed.isFullContent) internalRenderFullContent()
currentFeed?.let {
if (it.isFullContent) internalRenderFullContent()
else renderDescriptionContent()
}
// java.lang.NullPointerException: Attempt to invoke virtual method
@ -43,16 +70,16 @@ class ReadingViewModel @Inject constructor(
if (_readingUiState.value.listState.firstVisibleItemIndex != 0) {
_readingUiState.value.listState.scrollToItem(0)
}
hideLoading()
}
}
fun renderDescriptionContent() {
_readingUiState.update {
_readerState.update {
it.copy(
content = it.articleWithFeed?.article?.fullContent
?: it.articleWithFeed?.article?.rawDescription ?: "",
isFullContent = false
content = ReaderState.Description(
content = currentArticle?.fullContent
?: currentArticle?.rawDescription ?: ""
)
)
}
}
@ -65,107 +92,103 @@ class ReadingViewModel @Inject constructor(
private suspend fun internalRenderFullContent() {
showLoading()
try {
_readingUiState.update {
it.copy(
content = rssHelper.parseFullContent(
_readingUiState.value.articleWithFeed?.article?.link ?: "",
_readingUiState.value.articleWithFeed?.article?.title ?: ""
),
isFullContent = true
)
}
} catch (e: Exception) {
Log.i("RLog", "renderFullContent: ${e.message}")
_readingUiState.update { it.copy(content = e.message) }
}
hideLoading()
}
fun markUnread(isUnread: Boolean) {
val articleWithFeed = _readingUiState.value.articleWithFeed ?: return
viewModelScope.launch {
_readingUiState.update {
it.copy(
articleWithFeed = articleWithFeed.copy(
article = articleWithFeed.article.copy(
isUnread = isUnread
)
)
)
}
rssService.get().markAsRead(
groupId = null,
feedId = null,
articleId = _readingUiState.value.articleWithFeed!!.article.id,
before = null,
isUnread = isUnread,
runCatching {
rssHelper.parseFullContent(
currentArticle?.link ?: "",
currentArticle?.title ?: ""
)
}.onSuccess { content ->
_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)) }
}
}
fun markStarred(isStarred: Boolean) {
val articleWithFeed = _readingUiState.value.articleWithFeed ?: return
fun updateReadStatus(isUnread: Boolean) {
currentArticle?.run {
viewModelScope.launch {
_readingUiState.update { it.copy(isUnread = isUnread) }
rssService.get().markAsRead(
groupId = null,
feedId = null,
articleId = id,
before = null,
isUnread = isUnread,
)
}
}
}
fun markAsRead() = updateReadStatus(isUnread = false)
fun markAsUnread() = updateReadStatus(isUnread = true)
fun updateStarredStatus(isStarred: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
_readingUiState.update {
it.copy(
articleWithFeed = articleWithFeed.copy(
article = articleWithFeed.article.copy(
isStarred = isStarred
)
)
_readingUiState.update { it.copy(isStarred = isStarred) }
currentArticle?.let {
rssService.get().markAsStarred(
articleId = it.id,
isStarred = isStarred,
)
}
rssService.get().markAsStarred(
articleId = articleWithFeed.article.id,
isStarred = isStarred,
)
}
}
private fun showLoading() {
_readingUiState.update {
it.copy(isLoading = true)
_readerState.update {
it.copy(content = ReaderState.Loading)
}
}
private fun hideLoading() {
_readingUiState.update {
it.copy(isLoading = false)
fun updateNextArticleId(pagingItems: ItemSnapshotList<ArticleFlowItem>) {
val items = pagingItems.items
val index = items.indexOfFirst { item ->
item is ArticleFlowItem.Article && item.articleWithFeed.article.id == currentArticle?.id
}
}
fun recorderNextArticle(pagingItems: ItemSnapshotList<ArticleFlowItem>) {
if (pagingItems.size > 0) {
val cur = _readingUiState.value.articleWithFeed?.article
if (cur != null) {
var found = false
for (item in pagingItems) {
if (item is ArticleFlowItem.Article) {
val itemId = item.articleWithFeed.article.id
if (itemId == cur.id) {
found = true
_readingUiState.update {
it.copy(nextArticleId = "")
}
} else if (found) {
_readingUiState.update {
it.copy(nextArticleId = itemId)
}
break
}
}
}
items.subList(index + 1, items.size).forEach { item ->
if (item is ArticleFlowItem.Article) {
_readingUiState.update { it.copy(nextArticleId = item.articleWithFeed.article.id) }
return
}
}
_readingUiState.update { it.copy(nextArticleId = null) }
}
}
data class ReadingUiState(
val articleWithFeed: ArticleWithFeed? = null,
val content: String? = null,
val isFullContent: Boolean = false,
val isLoading: Boolean = true,
val articleId: String? = null,
val isUnread: Boolean = false,
val isStarred: Boolean = false,
val listState: LazyListState = LazyListState(),
val nextArticleId: String = "",
val nextArticleId: String? = null,
)
data class ReaderState(
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)
) {
sealed interface ContentState {
val text: String?
get() {
return when (this) {
is Description -> content
is Error -> message
is FullContent -> content
Loading -> null
}
}
}
data class FullContent(val content: String?) : ContentState
data class Description(val content: String?) : ContentState
data class Error(val message: String?) : ContentState
object Loading: ContentState
}