fix: 保留书架滚动状态并避免空白闪烁

This commit is contained in:
HapeLee
2026-07-01 01:02:56 +08:00
parent b60c025473
commit 45c403fe79
4 changed files with 70 additions and 49 deletions
@@ -2,8 +2,6 @@ package io.legado.app.ui.main
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.SystemClock
import android.view.ViewConfiguration
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
@@ -197,26 +195,20 @@ fun MainScreen(
orientation = Orientation.Horizontal, orientation = Orientation.Horizontal,
) )
var bookshelfScrollToTopRequest by remember { mutableLongStateOf(0L) } var bookshelfScrollToTopRequest by remember { mutableLongStateOf(0L) }
var lastBookshelfNavClickTime by remember { mutableLongStateOf(0L) } var isBookshelfAtTop by remember { mutableStateOf(true) }
fun requestBookshelfScrollToTop(index: Int) { fun requestBookshelfScrollToTop() {
bookshelfScrollToTopRequest++ bookshelfScrollToTopRequest++
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
} }
fun handleMainDestinationClick(index: Int, destination: MainDestination) { fun handleMainDestinationClick(index: Int, destination: MainDestination) {
if (destination == MainDestination.Bookshelf) { if (
val now = SystemClock.uptimeMillis() destination == MainDestination.Bookshelf &&
if ( pagerState.currentPage == index &&
(pagerState.currentPage == index || pagerState.targetPage == index) && pagerState.targetPage == index &&
now - lastBookshelfNavClickTime <= ViewConfiguration.getDoubleTapTimeout() !isBookshelfAtTop
) { ) {
requestBookshelfScrollToTop(index) requestBookshelfScrollToTop()
lastBookshelfNavClickTime = 0L return
return
}
lastBookshelfNavClickTime = now
} }
coroutineScope.launch { coroutineScope.launch {
pagerState.animateScrollToPage(index) pagerState.animateScrollToPage(index)
@@ -434,6 +426,9 @@ fun MainScreen(
MainDestination.Bookshelf -> BookshelfScreen( MainDestination.Bookshelf -> BookshelfScreen(
scrollToTopRequest = bookshelfScrollToTopRequest, scrollToTopRequest = bookshelfScrollToTopRequest,
onScrollStateChanged = { isAtTop ->
isBookshelfAtTop = isAtTop
},
onBookClick = { book -> onBookClick = { book ->
context.startActivityForBook(book) context.startActivityForBook(book)
}, },
@@ -529,13 +524,6 @@ fun MainScreen(
onClick = { onClick = {
handleMainDestinationClick(index, destination) handleMainDestinationClick(index, destination)
}, },
onDoubleClick = if (destination == MainDestination.Bookshelf) {
{
requestBookshelfScrollToTop(index)
}
} else {
null
},
modifier = Modifier modifier = Modifier
.defaultMinSize(minWidth = 76.dp) .defaultMinSize(minWidth = 76.dp)
.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
@@ -44,6 +44,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
@@ -76,6 +77,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -156,6 +158,7 @@ import sh.calvin.reorderable.rememberReorderableLazyGridState
fun BookshelfScreen( fun BookshelfScreen(
viewModel: BookshelfViewModel = koinViewModel(), viewModel: BookshelfViewModel = koinViewModel(),
scrollToTopRequest: Long = 0L, scrollToTopRequest: Long = 0L,
onScrollStateChanged: (isAtTop: Boolean) -> Unit = {},
onBookClick: (BookShelfItem) -> Unit, onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (book: BookShelfItem, sharedCoverKey: String?) -> Unit, onBookLongClick: (book: BookShelfItem, sharedCoverKey: String?) -> Unit,
onNavigateToSearch: (String) -> Unit, onNavigateToSearch: (String) -> Unit,
@@ -233,6 +236,14 @@ fun BookshelfScreen(
initialPage = uiState.selectedGroupIndex.coerceAtLeast(0), initialPage = uiState.selectedGroupIndex.coerceAtLeast(0),
pageCount = { uiState.groups.size } pageCount = { uiState.groups.size }
) )
val folderGridState = rememberLazyGridState()
val standaloneSearchGridState = rememberLazyGridState()
val groupGridStates = mutableMapOf<Long, LazyGridState>()
uiState.groups.forEach { group ->
key(group.groupId) {
groupGridStates[group.groupId] = rememberLazyGridState()
}
}
val latestGroups by rememberUpdatedState(uiState.groups) val latestGroups by rememberUpdatedState(uiState.groups)
val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId) val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId)
@@ -281,6 +292,20 @@ fun BookshelfScreen(
uiState.isSearch && uiState.groups.none { it.groupId == currentGroupId } uiState.isSearch && uiState.groups.none { it.groupId == currentGroupId }
} }
} }
val isShowingFolderRoot =
bookGroupStyle == 2 && isInFolderRoot && !isUsingStandaloneSearchGroup
LaunchedEffect(folderGridState, isShowingFolderRoot) {
if (isShowingFolderRoot) {
snapshotFlow { !folderGridState.canScrollBackward }
.distinctUntilChanged()
.collect(onScrollStateChanged)
}
}
LaunchedEffect(scrollToTopRequest, isShowingFolderRoot) {
if (scrollToTopRequest > 0L && isShowingFolderRoot) {
folderGridState.animateScrollToItem(0)
}
}
val currentGroupBookCount by remember { derivedStateOf { uiState.currentGroupBookCount } } val currentGroupBookCount by remember { derivedStateOf { uiState.currentGroupBookCount } }
val clearSelection = { val clearSelection = {
@@ -726,6 +751,9 @@ fun BookshelfScreen(
} }
) { isRoot -> ) { isRoot ->
if (uiState.groups.isEmpty() && !uiState.isSearch) { if (uiState.groups.isEmpty() && !uiState.isSearch) {
LaunchedEffect(Unit) {
onScrollStateChanged(true)
}
if (!uiState.isInitialLoading) { if (!uiState.isInitialLoading) {
EmptyMessage( EmptyMessage(
modifier = Modifier modifier = Modifier
@@ -743,6 +771,7 @@ fun BookshelfScreen(
val isGridMode = bookshelfFolderLayoutMode != 0 val isGridMode = bookshelfFolderLayoutMode != 0
FastScrollLazyVerticalGrid( FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(folderColumns.coerceAtLeast(1)), columns = GridCells.Fixed(folderColumns.coerceAtLeast(1)),
state = folderGridState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.then( .then(
@@ -815,6 +844,8 @@ fun BookshelfScreen(
if (isUsingStandaloneSearchGroup) { if (isUsingStandaloneSearchGroup) {
BookshelfPage( BookshelfPage(
scrollToTopRequest = scrollToTopRequest, scrollToTopRequest = scrollToTopRequest,
gridState = standaloneSearchGridState,
onScrollStateChanged = onScrollStateChanged,
paddingValues = paddingValues, paddingValues = paddingValues,
books = uiState.items, books = uiState.items,
uiState = uiState, uiState = uiState,
@@ -862,6 +893,8 @@ fun BookshelfScreen(
isSelectedGroup isSelectedGroup
BookshelfPage( BookshelfPage(
scrollToTopRequest = scrollToTopRequest, scrollToTopRequest = scrollToTopRequest,
gridState = groupGridStates.getValue(group.groupId),
onScrollStateChanged = onScrollStateChanged,
paddingValues = paddingValues, paddingValues = paddingValues,
books = books, books = books,
uiState = uiState, uiState = uiState,
@@ -1212,6 +1245,8 @@ private data class BookshelfEditStickySummary(
@Composable @Composable
fun BookshelfPage( fun BookshelfPage(
scrollToTopRequest: Long, scrollToTopRequest: Long,
gridState: LazyGridState,
onScrollStateChanged: (isAtTop: Boolean) -> Unit,
paddingValues: PaddingValues, paddingValues: PaddingValues,
books: ImmutableList<BookUiItem>, books: ImmutableList<BookUiItem>,
uiState: BookshelfUiState, uiState: BookshelfUiState,
@@ -1230,6 +1265,11 @@ fun BookshelfPage(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
LaunchedEffect(books.isEmpty(), isCurrentPage) {
if (books.isEmpty() && isCurrentPage) {
onScrollStateChanged(true)
}
}
if (books.isEmpty()) { if (books.isEmpty()) {
if (!isCurrentPage) return if (!isCurrentPage) return
if (uiState.isSearch) { if (uiState.isSearch) {
@@ -1297,12 +1337,18 @@ fun BookshelfPage(
val gridInnerHorizontalPadding = totalHorizontalPadding / 2 val gridInnerHorizontalPadding = totalHorizontalPadding / 2
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val displayBooks = draggingBooks ?: pendingSavedBooks ?: books val displayBooks = draggingBooks ?: pendingSavedBooks ?: books
val gridState = rememberLazyGridState()
LaunchedEffect(scrollToTopRequest, isCurrentPage) { LaunchedEffect(scrollToTopRequest, isCurrentPage) {
if (scrollToTopRequest > 0L && isCurrentPage) { if (scrollToTopRequest > 0L && isCurrentPage) {
gridState.animateScrollToItem(0) gridState.animateScrollToItem(0)
} }
} }
LaunchedEffect(gridState, isCurrentPage) {
if (isCurrentPage) {
snapshotFlow { !gridState.canScrollBackward }
.distinctUntilChanged()
.collect(onScrollStateChanged)
}
}
val reorderableState = rememberReorderableLazyGridState(gridState) { from, to -> val reorderableState = rememberReorderableLazyGridState(gridState) { from, to ->
if (canReorderBooks) { if (canReorderBooks) {
onMoveBook(from.index, to.index, displayBooks) onMoveBook(from.index, to.index, displayBooks)
@@ -52,6 +52,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@@ -65,6 +66,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -140,13 +142,13 @@ class BookshelfViewModel(
protected val _eventChannel = Channel<BaseRuleEvent>() protected val _eventChannel = Channel<BaseRuleEvent>()
val events = _eventChannel.receiveAsFlow() val events = _eventChannel.receiveAsFlow()
val groupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowShow() val groupsFlow: SharedFlow<List<BookGroup>> = bookGroupRepository.flowShow()
.onEach { .onEach {
if (it.isNotEmpty()) { if (it.isNotEmpty()) {
isInitialLoadingFlow.value = false isInitialLoadingFlow.value = false
} }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll() val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -195,7 +197,7 @@ class BookshelfViewModel(
}.distinctUntilChanged().flowOn(Dispatchers.Default) }.distinctUntilChanged().flowOn(Dispatchers.Default)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private val allGroupBooksImmutableFlow: StateFlow<ImmutableMap<Long, ImmutableList<BookUiItem>>> = private val allGroupBooksImmutableFlow: Flow<ImmutableMap<Long, ImmutableList<BookUiItem>>> =
combine(groupsFlow, sortConfigFlow) { groups, sortConfig -> combine(groupsFlow, sortConfigFlow) { groups, sortConfig ->
groups to sortConfig groups to sortConfig
}.flatMapLatest { (groups, sortConfig) -> }.flatMapLatest { (groups, sortConfig) ->
@@ -220,7 +222,6 @@ class BookshelfViewModel(
} }
}.distinctUntilChanged() }.distinctUntilChanged()
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentMapOf())
private val visibleBooksFlow: Flow<List<BookUiItem>> = combine( private val visibleBooksFlow: Flow<List<BookUiItem>> = combine(
booksFlow, booksFlow,
@@ -9,7 +9,6 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -78,7 +77,6 @@ val LocalFloatingBottomBarTabScale = staticCompositionLocalOf { { 1f } }
@Composable @Composable
fun RowScope.FloatingBottomBarItem( fun RowScope.FloatingBottomBarItem(
onClick: () -> Unit, onClick: () -> Unit,
onDoubleClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
@@ -86,23 +84,11 @@ fun RowScope.FloatingBottomBarItem(
Column( Column(
modifier modifier
.clip(ContinuousCapsule) .clip(ContinuousCapsule)
.then( .clickable(
if (onDoubleClick != null) { interactionSource = null,
Modifier.combinedClickable( indication = null,
interactionSource = null, role = Role.Tab,
indication = null, onClick = onClick,
role = Role.Tab,
onClick = onClick,
onDoubleClick = onDoubleClick,
)
} else {
Modifier.clickable(
interactionSource = null,
indication = null,
role = Role.Tab,
onClick = onClick,
)
}
) )
.fillMaxHeight() .fillMaxHeight()
.weight(1f) .weight(1f)