[优化] 优化发现页相关逻辑
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user