[优化] 优化发现页相关逻辑

This commit is contained in:
HapeLee
2025-12-06 02:17:17 +08:00
parent a4ee58c6d5
commit dbee7c2945
4 changed files with 173 additions and 77 deletions
@@ -1,8 +1,14 @@
package io.legado.app.ui.book.explore
import android.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.animateContentSize
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.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -19,9 +25,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
@@ -36,6 +44,7 @@ import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -48,12 +57,14 @@ import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -75,6 +86,7 @@ import androidx.compose.ui.unit.sp
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.model.BookShelfState
import io.legado.app.ui.widget.components.AnimatedTextButton
import io.legado.app.ui.widget.components.AnimatedTextLine
import io.legado.app.ui.widget.components.Cover
import io.legado.app.ui.widget.components.EmptyMessageView
@@ -95,6 +107,8 @@ fun ExploreShowScreen(
}
val books by viewModel.uiBooks.collectAsState()
val isBookEnd by viewModel.isRawDataEmptyAndEnd.collectAsState()
val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState()
val kinds by viewModel.kinds.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMsg by viewModel.errorMsg.collectAsState()
@@ -106,6 +120,7 @@ fun ExploreShowScreen(
var showKindSheet by remember { mutableStateOf(false) }
val layoutState by viewModel.layoutState.collectAsState()
val isGridMode = layoutState == 1
val shouldLoadMore = remember {
derivedStateOf {
val totalItems: Int
@@ -117,7 +132,7 @@ fun ExploreShowScreen(
totalItems = listState.layoutInfo.totalItemsCount
lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
}
totalItems > 0 && lastVisibleIndex >= totalItems - 3
totalItems > 0 && lastVisibleIndex >= totalItems - 6
}
}
@@ -127,10 +142,29 @@ fun ExploreShowScreen(
}
}
if (showKindSheet) {
val scrollState = rememberScrollState() // 记住滚动状态
LaunchedEffect(shouldTriggerAutoLoad) {
if (shouldTriggerAutoLoad) {
viewModel.loadMore()
}
}
LaunchedEffect(isGridMode) {
if (isGridMode) {
if (listState.firstVisibleItemIndex > 0) {
gridState.scrollToItem(listState.firstVisibleItemIndex)
}
} else {
if (gridState.firstVisibleItemIndex > 0) {
listState.scrollToItem(gridState.firstVisibleItemIndex)
}
}
}
if (showKindSheet) {
val scrollState = rememberScrollState()
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = { showKindSheet = false }
) {
Column(
@@ -142,13 +176,10 @@ fun ExploreShowScreen(
Text(
"选择分类",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(bottom = 8.dp)
modifier = Modifier.padding(bottom = 8.dp)
)
FlowRow(
modifier = Modifier.fillMaxWidth()
) {
FlowRow(modifier = Modifier.fillMaxWidth()) {
kinds.forEach { kind ->
KindListItem(
kind = kind,
@@ -184,7 +215,7 @@ fun ExploreShowScreen(
.fillMaxSize()
.padding(paddingValues)
) {
if (!isLoading && books.isEmpty() && errorMsg == null) {
if (!isLoading && isBookEnd && errorMsg == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
EmptyMessageView(message = "暂无书籍")
}
@@ -199,12 +230,7 @@ fun ExploreShowScreen(
state = gridState,
modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(minSize = 90.dp),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 8.dp,
bottom = 12.dp
),
contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -221,7 +247,7 @@ fun ExploreShowScreen(
)
}
item {
item(span = { GridItemSpan(maxLineSpan) }) {
LoadMoreFooter(
isLoading = isLoading,
errorMsg = errorMsg,
@@ -376,25 +402,44 @@ fun ExploreBookItem(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val badge: (@Composable RowScope.() -> Unit)?
= when (shelfState) {
BookShelfState.IN_SHELF -> {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "已在书架",
modifier = Modifier.size(12.dp)
)
}
}
BookShelfState.SAME_NAME_AUTHOR -> {
{
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "同名书籍",
modifier = Modifier.size(12.dp)
)
}
}
BookShelfState.NOT_IN_SHELF -> null
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Column {
Cover(path = book.coverUrl)
if (shelfState != BookShelfState.NOT_IN_SHELF) {
Spacer(modifier = Modifier.height(4.dp))
BookshelfStatusBadge(
shelfState = shelfState,
modifier = Modifier
.width(48.dp)
)
}
}
Cover(
path = book.coverUrl,
badgeContent = badge)
Spacer(modifier = Modifier.width(8.dp))
@@ -514,35 +559,6 @@ fun ExploreBookGridItem(
}
}
@Composable
fun BookshelfStatusBadge(
shelfState: BookShelfState,
modifier: Modifier = Modifier
) {
val text = when (shelfState) {
BookShelfState.IN_SHELF -> "已在书架"
BookShelfState.SAME_NAME_AUTHOR -> "同名书籍"
else -> null
}
if (text != null) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold, fontSize = 9.sp),
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
}
// 简单的标签组件
@Composable
fun TagChip(text: String) {
@@ -572,23 +588,35 @@ fun LoadMoreFooter(
.padding(32.dp),
contentAlignment = Alignment.Center
) {
when {
isLoading -> LoadingIndicator()
errorMsg != null -> {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("加载失败: $errorMsg", color = Color.Red)
TextButton(onClick = onRetry) { Text("重试") }
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
else -> {
AnimatedContent(
targetState = when {
isLoading -> "加载中…"
errorMsg != null -> "加载失败: $errorMsg"
else -> "已经到底了~"
},
label = "FooterTextChange"
) { text ->
Text(
text = "已经到底了~",
color = Color.Gray,
text = text,
color = when {
errorMsg != null -> Color.Red
else -> Color.Gray
},
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(8.dp))
AnimatedTextButton(
isLoading = isLoading,
onClick = onRetry,
text = if (errorMsg != null) "重试" else "再试一次",
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
@@ -1,6 +1,7 @@
package io.legado.app.ui.book.explore
import android.content.Intent
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.entities.BookSource
@@ -52,7 +53,7 @@ class ExploreShowViewModel(
private var bookSource: BookSource? = null
private var exploreUrl: String? = null
private var page = 1
private var isEnd = false
private val _isEndStateFlow = MutableStateFlow(false)
private val _bookshelf = MutableStateFlow<Set<BookKey>>(emptySet())
private val _kinds = MutableStateFlow<List<ExploreKind>>(emptyList())
val kinds = _kinds.asStateFlow()
@@ -71,6 +72,21 @@ class ExploreShowViewModel(
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val isRawDataEmptyAndEnd = combine(
_rawBooks,
_isEndStateFlow
) { rawBooks, isEndValue ->
rawBooks.isEmpty() && isEndValue
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val shouldTriggerAutoLoad = combine(
_isLoading,
uiBooks,
_rawBooks,
_isEndStateFlow
) { loading, uiBooks, rawBooks, isEnd ->
!loading && uiBooks.isEmpty() && rawBooks.isNotEmpty() && !isEnd
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isLoading = _isLoading.asStateFlow()
val errorMsg = _errorMsg.asStateFlow()
@@ -107,6 +123,7 @@ class ExploreShowViewModel(
fun switchExploreUrl(kind: ExploreKind) {
_selectedKindTitle.value = kind.title
exploreUrl = kind.url
_isEndStateFlow.value = false
loadMore(isRefresh = true)
}
@@ -122,7 +139,7 @@ class ExploreShowViewModel(
}
fun loadMore(isRefresh: Boolean = false) {
if (_isLoading.value || (isEnd && !isRefresh)) return
if (_isLoading.value || (_isEndStateFlow.value && !isRefresh)) return
viewModelScope.launch {
_isLoading.value = true
@@ -139,20 +156,26 @@ class ExploreShowViewModel(
if (isRefresh) {
page = 1
isEnd = false
_isEndStateFlow.value = false
_rawBooks.value = emptyList()
}
repository.exploreBook(source, url, page)
.onSuccess { newBooks ->
if (newBooks.isEmpty()) {
isEnd = true
_isEndStateFlow.value = true
} else {
repository.saveSearchBooks(newBooks)
val currentList = if (page == 1) emptyList() else _rawBooks.value
val combined = (currentList + newBooks).distinctBy { it.bookUrl }
_rawBooks.value = combined
if (page == 1) {
_rawBooks.value = newBooks.distinctBy { it.bookUrl }
} else {
val existingUrls = currentList.map { it.bookUrl }.toSet()
val uniqueNewBooks = newBooks.filter { it.bookUrl !in existingUrls }
_rawBooks.value = currentList + uniqueNewBooks
}
page++
_isEndStateFlow.value = false
}
}
.onFailure {
@@ -0,0 +1,45 @@
package io.legado.app.ui.widget.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AnimatedTextButton(
isLoading: Boolean,
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier
) {
TextButton(
onClick = onClick,
enabled = !isLoading,
modifier = modifier.animateContentSize()
) {
AnimatedContent(
targetState = isLoading,
contentAlignment = Alignment.Center,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "ButtonLoading"
) { loading ->
if (loading) {
LoadingIndicator()
} else {
Text(text)
}
}
}
}
@@ -81,14 +81,14 @@ fun Cover(
Surface(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
.padding(2.dp),
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
tonalElevation = 2.dp
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
modifier = Modifier.padding(2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
content = badgeContent