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

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 package io.legado.app.ui.book.explore
import android.content.Intent import android.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade 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.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.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState 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.Check
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -48,12 +57,14 @@ import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.SearchBook
import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.model.BookShelfState 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.AnimatedTextLine
import io.legado.app.ui.widget.components.Cover import io.legado.app.ui.widget.components.Cover
import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.EmptyMessageView
@@ -95,6 +107,8 @@ fun ExploreShowScreen(
} }
val books by viewModel.uiBooks.collectAsState() 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 kinds by viewModel.kinds.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
val errorMsg by viewModel.errorMsg.collectAsState() val errorMsg by viewModel.errorMsg.collectAsState()
@@ -106,6 +120,7 @@ fun ExploreShowScreen(
var showKindSheet by remember { mutableStateOf(false) } var showKindSheet by remember { mutableStateOf(false) }
val layoutState by viewModel.layoutState.collectAsState() val layoutState by viewModel.layoutState.collectAsState()
val isGridMode = layoutState == 1 val isGridMode = layoutState == 1
val shouldLoadMore = remember { val shouldLoadMore = remember {
derivedStateOf { derivedStateOf {
val totalItems: Int val totalItems: Int
@@ -117,7 +132,7 @@ fun ExploreShowScreen(
totalItems = listState.layoutInfo.totalItemsCount totalItems = listState.layoutInfo.totalItemsCount
lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 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) { LaunchedEffect(shouldTriggerAutoLoad) {
val scrollState = rememberScrollState() // 记住滚动状态 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( ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = { showKindSheet = false } onDismissRequest = { showKindSheet = false }
) { ) {
Column( Column(
@@ -142,13 +176,10 @@ fun ExploreShowScreen(
Text( Text(
"选择分类", "选择分类",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier modifier = Modifier.padding(bottom = 8.dp)
.padding(bottom = 8.dp)
) )
FlowRow( FlowRow(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth()
) {
kinds.forEach { kind -> kinds.forEach { kind ->
KindListItem( KindListItem(
kind = kind, kind = kind,
@@ -184,7 +215,7 @@ fun ExploreShowScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
if (!isLoading && books.isEmpty() && errorMsg == null) { if (!isLoading && isBookEnd && errorMsg == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
EmptyMessageView(message = "暂无书籍") EmptyMessageView(message = "暂无书籍")
} }
@@ -199,12 +230,7 @@ fun ExploreShowScreen(
state = gridState, state = gridState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(minSize = 90.dp), columns = GridCells.Adaptive(minSize = 90.dp),
contentPadding = PaddingValues( contentPadding = PaddingValues(12.dp),
start = 12.dp,
end = 12.dp,
top = 8.dp,
bottom = 12.dp
),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@@ -221,7 +247,7 @@ fun ExploreShowScreen(
) )
} }
item { item(span = { GridItemSpan(maxLineSpan) }) {
LoadMoreFooter( LoadMoreFooter(
isLoading = isLoading, isLoading = isLoading,
errorMsg = errorMsg, errorMsg = errorMsg,
@@ -376,25 +402,44 @@ fun ExploreBookItem(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier 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( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
Column {
Cover(path = book.coverUrl) Cover(
path = book.coverUrl,
if (shelfState != BookShelfState.NOT_IN_SHELF) { badgeContent = badge)
Spacer(modifier = Modifier.height(4.dp))
BookshelfStatusBadge(
shelfState = shelfState,
modifier = Modifier
.width(48.dp)
)
}
}
Spacer(modifier = Modifier.width(8.dp)) 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 @Composable
fun TagChip(text: String) { fun TagChip(text: String) {
@@ -572,23 +588,35 @@ fun LoadMoreFooter(
.padding(32.dp), .padding(32.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when {
isLoading -> LoadingIndicator()
errorMsg != null -> { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("加载失败: $errorMsg", color = Color.Red)
TextButton(onClick = onRetry) { Text("重试") }
}
}
else -> { AnimatedContent(
targetState = when {
isLoading -> "加载中…"
errorMsg != null -> "加载失败: $errorMsg"
else -> "已经到底了~"
},
label = "FooterTextChange"
) { text ->
Text( Text(
text = "已经到底了~", text = text,
color = Color.Gray, color = when {
errorMsg != null -> Color.Red
else -> Color.Gray
},
style = MaterialTheme.typography.bodySmall 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 package io.legado.app.ui.book.explore
import android.content.Intent import android.content.Intent
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
@@ -52,7 +53,7 @@ class ExploreShowViewModel(
private var bookSource: BookSource? = null private var bookSource: BookSource? = null
private var exploreUrl: String? = null private var exploreUrl: String? = null
private var page = 1 private var page = 1
private var isEnd = false private val _isEndStateFlow = MutableStateFlow(false)
private val _bookshelf = MutableStateFlow<Set<BookKey>>(emptySet()) private val _bookshelf = MutableStateFlow<Set<BookKey>>(emptySet())
private val _kinds = MutableStateFlow<List<ExploreKind>>(emptyList()) private val _kinds = MutableStateFlow<List<ExploreKind>>(emptyList())
val kinds = _kinds.asStateFlow() val kinds = _kinds.asStateFlow()
@@ -71,6 +72,21 @@ class ExploreShowViewModel(
} }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) }.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 isLoading = _isLoading.asStateFlow()
val errorMsg = _errorMsg.asStateFlow() val errorMsg = _errorMsg.asStateFlow()
@@ -107,6 +123,7 @@ class ExploreShowViewModel(
fun switchExploreUrl(kind: ExploreKind) { fun switchExploreUrl(kind: ExploreKind) {
_selectedKindTitle.value = kind.title _selectedKindTitle.value = kind.title
exploreUrl = kind.url exploreUrl = kind.url
_isEndStateFlow.value = false
loadMore(isRefresh = true) loadMore(isRefresh = true)
} }
@@ -122,7 +139,7 @@ class ExploreShowViewModel(
} }
fun loadMore(isRefresh: Boolean = false) { fun loadMore(isRefresh: Boolean = false) {
if (_isLoading.value || (isEnd && !isRefresh)) return if (_isLoading.value || (_isEndStateFlow.value && !isRefresh)) return
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
@@ -139,20 +156,26 @@ class ExploreShowViewModel(
if (isRefresh) { if (isRefresh) {
page = 1 page = 1
isEnd = false _isEndStateFlow.value = false
_rawBooks.value = emptyList() _rawBooks.value = emptyList()
} }
repository.exploreBook(source, url, page) repository.exploreBook(source, url, page)
.onSuccess { newBooks -> .onSuccess { newBooks ->
if (newBooks.isEmpty()) { if (newBooks.isEmpty()) {
isEnd = true _isEndStateFlow.value = true
} else { } else {
repository.saveSearchBooks(newBooks) repository.saveSearchBooks(newBooks)
val currentList = if (page == 1) emptyList() else _rawBooks.value val currentList = if (page == 1) emptyList() else _rawBooks.value
val combined = (currentList + newBooks).distinctBy { it.bookUrl } if (page == 1) {
_rawBooks.value = combined _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++ page++
_isEndStateFlow.value = false
} }
} }
.onFailure { .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( Surface(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(4.dp), .padding(2.dp),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.secondaryContainer, color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
tonalElevation = 2.dp tonalElevation = 2.dp
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), modifier = Modifier.padding(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
content = badgeContent content = badgeContent