chore: Refactor selection state management and improve performance across various screens (#1607)

* chore: Refactor selection state management and improve performance across various screens

*   **MigrateMangaScreen**: Refactor state management to use a `Set<Long>` of IDs for selection instead of a list of wrapper items.
*   **LibraryUpdateErrorScreen**: Simplify `onErrorSelected` signature by removing the unused `userSelected` parameter and clean up related UI components.
*   **Performance**: Use `remember(state.selection)` in `MigrateMangaScreen` for efficient selection status lookups.
*   **Code Quality**: Fix minor typos (e.g., `inbetweenItem` to `inBetweenItem`) and optimize imports.

* Refactor selection logic to prevent redundant state updates

* Move early exit checks for item selection status outside or to the beginning of state update blocks to avoid unnecessary recompositions.
* Improve safety and correctness of item selection by returning early from state updates when an item index is not found.
* Standardize selection checks across `HistoryScreenModel`, `LibraryUpdateErrorScreenModel`, `MigrateMangaScreenModel`, `UpdatesScreenModel`, and `MangaScreenModel`.

* update selection when migration list got updated

* Update state management to reset selection and loading status on fetch failure
This commit is contained in:
Cuong-Tran 2026-05-08 12:41:46 +07:00 committed by GitHub
parent bdf81596e9
commit 4c21f019c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 141 additions and 140 deletions

View file

@ -67,7 +67,7 @@ fun LibraryUpdateErrorScreen(
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
onErrorsDelete: () -> Unit, onErrorsDelete: () -> Unit,
onErrorDelete: (Long) -> Unit, onErrorDelete: (Long) -> Unit,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit, onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
) { ) {
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) }) BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })

View file

@ -44,7 +44,7 @@ import tachiyomi.presentation.core.util.selectedBackground
internal fun LazyListScope.libraryUpdateErrorUiItems( internal fun LazyListScope.libraryUpdateErrorUiItems(
uiModels: List<LibraryUpdateErrorUiModel>, uiModels: List<LibraryUpdateErrorUiModel>,
selectionMode: Boolean, selectionMode: Boolean,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit, onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
onClick: (LibraryUpdateErrorItem) -> Unit, onClick: (LibraryUpdateErrorItem) -> Unit,
onClickCover: (LibraryUpdateErrorItem) -> Unit, onClickCover: (LibraryUpdateErrorItem) -> Unit,
onDelete: (Long) -> Unit, onDelete: (Long) -> Unit,
@ -81,7 +81,6 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
selectionMode -> onErrorSelected( selectionMode -> onErrorSelected(
libraryUpdateErrorItem, libraryUpdateErrorItem,
!libraryUpdateErrorItem.selected, !libraryUpdateErrorItem.selected,
true,
false, false,
) )
@ -93,7 +92,6 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
libraryUpdateErrorItem, libraryUpdateErrorItem,
!libraryUpdateErrorItem.selected, !libraryUpdateErrorItem.selected,
true, true,
true,
) )
}, },
onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode }, onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode },

View file

@ -128,11 +128,9 @@ data class MigrateMangaScreen(
// KMK --> // KMK -->
bottomBar = { bottomBar = {
MigrateMangaBottomBar( MigrateMangaBottomBar(
selected = state.selection, selectionMode = state.selectionMode,
onMultiMigrateClicked = { onMultiMigrateClicked = {
val selection = state.selection navigator.push(MigrationConfigScreen(state.selection))
.map { it.manga.id }
navigator.push(MigrationConfigScreen(selection))
}, },
enableScrollToTop = enableScrollToTop, enableScrollToTop = enableScrollToTop,
enableScrollToBottom = enableScrollToBottom, enableScrollToBottom = enableScrollToBottom,
@ -187,7 +185,7 @@ data class MigrateMangaScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
state: MigrateMangaScreenModel.State, state: MigrateMangaScreenModel.State,
// KMK --> // KMK -->
onMangaSelected: (MigrateMangaItem, Boolean, Boolean, Boolean) -> Unit, onMangaSelected: (Manga, Boolean, Boolean) -> Unit,
// KMK <-- // KMK <--
onClickItem: (Manga) -> Unit, onClickItem: (Manga) -> Unit,
onClickCover: (Manga) -> Unit, onClickCover: (Manga) -> Unit,
@ -197,20 +195,23 @@ data class MigrateMangaScreen(
contentPadding = contentPadding, contentPadding = contentPadding,
) { ) {
items(items = state.titles) { manga -> items(items = state.titles) { manga ->
// KMK -->
val isSelected = manga.id in state.selection
// KMK <--
MigrateMangaItem( MigrateMangaItem(
manga = manga.manga, manga = manga,
isSelected = manga.selected, isSelected = isSelected,
onClickItem = { onClickItem = {
// KMK --> // KMK -->
when { when {
state.selectionMode -> onMangaSelected(manga, !manga.selected, true, false) state.selectionMode -> onMangaSelected(manga, !isSelected, false)
// KMK <-- // KMK <--
else -> onClickItem(it) else -> onClickItem(it)
} }
}, },
onClickCover = onClickCover, onClickCover = onClickCover,
// KMK --> // KMK -->
onLongClick = { onMangaSelected(manga, !manga.selected, true, true) }, onLongClick = { onMangaSelected(manga, !isSelected, true) },
modifier = Modifier.animateItemFastScroll(), modifier = Modifier.animateItemFastScroll(),
// KMK <-- // KMK <--
) )
@ -293,7 +294,7 @@ data class MigrateMangaScreen(
@Composable @Composable
private fun MigrateMangaBottomBar( private fun MigrateMangaBottomBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selected: List<MigrateMangaItem>, selectionMode: Boolean,
onMultiMigrateClicked: () -> Unit, onMultiMigrateClicked: () -> Unit,
enableScrollToTop: Boolean, enableScrollToTop: Boolean,
enableScrollToBottom: Boolean, enableScrollToBottom: Boolean,
@ -302,7 +303,7 @@ data class MigrateMangaScreen(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val animatedElevation by animateDpAsState( val animatedElevation by animateDpAsState(
targetValue = if (selected.isNotEmpty()) 3.dp else 0.dp, targetValue = if (selectionMode) 3.dp else 0.dp,
label = "elevation", label = "elevation",
) )
Surface( Surface(
@ -350,7 +351,7 @@ data class MigrateMangaScreen(
toConfirm = confirm[1], toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) }, onLongClick = { onLongClickItem(1) },
onClick = onMultiMigrateClicked, onClick = onMultiMigrateClicked,
enabled = selected.isNotEmpty(), enabled = selectionMode,
) )
Button( Button(
title = stringResource(KMR.strings.action_scroll_to_bottom), title = stringResource(KMR.strings.action_scroll_to_bottom),

View file

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.browse.migration.manga
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.util.addOrRemove
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -17,6 +16,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import mihon.core.common.utils.mutate
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.manga.interactor.GetFavorites import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@ -36,7 +36,6 @@ class MigrateMangaScreenModel(
// KMK --> // KMK -->
// First and last selected index in list // First and last selected index in list
private val selectedPositions: Array<Int> = arrayOf(-1, -1) private val selectedPositions: Array<Int> = arrayOf(-1, -1)
private val selectedMangaIds: HashSet<Long> = HashSet()
// KMK <-- // KMK <--
init { init {
@ -50,120 +49,133 @@ class MigrateMangaScreenModel(
logcat(LogPriority.ERROR, it) logcat(LogPriority.ERROR, it)
_events.send(MigrationMangaEvent.FailedFetchingFavorites) _events.send(MigrationMangaEvent.FailedFetchingFavorites)
mutableState.update { state -> mutableState.update { state ->
state.copy(titleList = persistentListOf()) state.copy(
titleList = persistentListOf(),
// KMK -->
selection = emptySet(),
// KMK <--
)
} }
} }
// KMK -->
.map { manga ->
toMigrationMangaScreenItems(manga)
}
// KMK <--
.map { manga -> .map { manga ->
manga manga
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.manga.title }) .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title })
.toImmutableList() .toImmutableList()
} }
.collectLatest { list -> .collectLatest { list ->
mutableState.update { it.copy(titleList = list) } // KMK -->
mutableState.update { state ->
val titleIds = list.map { it.id }.toSet()
val selection = state.selection.intersect(titleIds).toMutableSet()
updateSelectedPositions(list, selection)
state.copy(
titleList = list,
selection = selection,
)
}
// KMK <--
} }
} }
} }
// KMK -->
private fun toMigrationMangaScreenItems(mangas: List<Manga>): List<MigrateMangaItem> {
return mangas.map { manga ->
MigrateMangaItem(
manga = manga,
selected = manga.id in selectedMangaIds,
)
}
}
fun toggleSelection( fun toggleSelection(
item: MigrateMangaItem, item: Manga,
// KMK -->
selected: Boolean, selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
mutableState.update { state -> mutableState.update { state ->
val newItems = state.titles.toMutableList().apply { if (item.id in state.selection == selected) return@update state
val selectedIndex = indexOfFirst { it.manga.id == item.manga.id } val selectedIndex = state.titles.indexOfFirst { it.id == item.id }
if (selectedIndex < 0) return@apply if (selectedIndex < 0) return@update state
val selectedItem = get(selectedIndex) val selection = state.selection.mutate { list ->
if (selectedItem.selected == selected) return@apply state.titles.run {
val firstSelection = list.isEmpty()
if (selected) list.add(item.id) else list.remove(item.id)
val firstSelection = none { it.selected } if (selected && fromLongPress) {
set(selectedIndex, selectedItem.copy(selected = selected)) if (firstSelection) {
selectedMangaIds.addOrRemove(item.manga.id, selected)
if (selected && userSelected && fromLongPress) {
if (firstSelection) {
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Try to select the items in-between when possible
val range: IntRange
if (selectedIndex < selectedPositions[0]) {
range = selectedIndex + 1 until selectedPositions[0]
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1) until selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
} else { } else {
// Just select itself // Try to select the items in-between when possible
range = IntRange.EMPTY val range: IntRange
} if (selectedIndex < selectedPositions[0]) {
range = selectedIndex + 1 until selectedPositions[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1) until selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = IntRange.EMPTY
}
range.forEach { range.forEach {
val inBetweenItem = get(it) val inBetweenItem = get(it)
if (!inBetweenItem.selected) { if (inBetweenItem.id !in list) {
selectedMangaIds.add(inBetweenItem.manga.id) list.add(inBetweenItem.id)
set(it, inBetweenItem.copy(selected = true)) }
} }
} }
} } else if (!fromLongPress) {
} else if (userSelected && !fromLongPress) { if (!selected) {
if (!selected) { if (selectedIndex == selectedPositions[0]) {
if (selectedIndex == selectedPositions[0]) { selectedPositions[0] = indexOfFirst { it.id in list }
selectedPositions[0] = indexOfFirst { it.selected } } else if (selectedIndex == selectedPositions[1]) {
} else if (selectedIndex == selectedPositions[1]) { selectedPositions[1] = indexOfLast { it.id in list }
selectedPositions[1] = indexOfLast { it.selected } }
} } else {
} else { if (selectedIndex < selectedPositions[0]) {
if (selectedIndex < selectedPositions[0]) { selectedPositions[0] = selectedIndex
selectedPositions[0] = selectedIndex } else if (selectedIndex > selectedPositions[1]) {
} else if (selectedIndex > selectedPositions[1]) { selectedPositions[1] = selectedIndex
selectedPositions[1] = selectedIndex }
} }
} }
} }
} }
state.copy(titleList = newItems.toImmutableList()) // KMK <--
state.copy(selection = selection)
} }
} }
// KMK -->
private fun updateSelectedPositions(titles: List<Manga>, selection: Set<Long>) {
if (selection.isEmpty()) {
selectedPositions[0] = -1
selectedPositions[1] = -1
return
}
selectedPositions[0] = titles.indexOfFirst { it.id in selection }
selectedPositions[1] = titles.indexOfLast { it.id in selection }
}
fun toggleAllSelection(selected: Boolean = true) { fun toggleAllSelection(selected: Boolean = true) {
mutableState.update { state -> mutableState.update { state ->
val newItems = state.titles.map { val selection = if (selected) {
selectedMangaIds.addOrRemove(it.manga.id, selected) state.titles.mapTo(mutableSetOf()) { it.id }
it.copy(selected = selected) } else {
emptySet()
} }
selectedPositions[0] = -1 selectedPositions[0] = -1
selectedPositions[1] = -1 selectedPositions[1] = -1
state.copy(titleList = newItems.toImmutableList()) state.copy(selection = selection)
} }
} }
fun invertSelection() { fun invertSelection() {
mutableState.update { state -> mutableState.update { state ->
val newItems = state.titles.map { val selection = state.selection.mutate { list ->
selectedMangaIds.addOrRemove(it.manga.id, !it.selected) state.titles.forEach { item ->
it.copy(selected = !it.selected) if (!list.remove(item.id)) list.add(item.id)
}
} }
selectedPositions[0] = -1 selectedPositions[0] = -1
selectedPositions[1] = -1 selectedPositions[1] = -1
state.copy(titleList = newItems.toImmutableList()) state.copy(selection = selection)
} }
} }
// KMK <-- // KMK <--
@ -177,13 +189,11 @@ class MigrateMangaScreenModel(
@Immutable @Immutable
data class State( data class State(
val source: Source? = null, val source: Source? = null,
private val titleList: ImmutableList<MigrateMangaItem>? = null, val selection: Set<Long> = emptySet(),
private val titleList: ImmutableList<Manga>? = null,
) { ) {
// KMK -->
val selection = titles.filter { it.selected }
// KMK <--
val titles: ImmutableList<MigrateMangaItem> val titles: ImmutableList<Manga>
get() = titleList ?: persistentListOf() get() = titleList ?: persistentListOf()
val isLoading: Boolean val isLoading: Boolean
@ -199,11 +209,3 @@ class MigrateMangaScreenModel(
sealed interface MigrationMangaEvent { sealed interface MigrationMangaEvent {
data object FailedFetchingFavorites : MigrationMangaEvent data object FailedFetchingFavorites : MigrationMangaEvent
} }
// KMK -->
@Immutable
data class MigrateMangaItem(
val manga: Manga,
val selected: Boolean,
)
// KMK <--

View file

@ -298,14 +298,14 @@ class HistoryScreenModel(
selectionOptions: HistorySelectionOptions, selectionOptions: HistorySelectionOptions,
) { ) {
val (selected, fromLongPress) = selectionOptions val (selected, fromLongPress) = selectionOptions
if (item.chapterId in state.value.selection == selected) return
mutableState.update { state -> mutableState.update { state ->
if (item.chapterId in state.selection == selected) return@update state
val selectedIndex = state.list.indexOfFirst { it.chapterId == item.chapterId }
if (selectedIndex < 0) return@update state
val selection = state.selection.mutate { list -> val selection = state.selection.mutate { list ->
state.list.run { state.list.run {
val selectedIndex = indexOfFirst { it.chapterId == item.chapterId }
if (selectedIndex < 0) return@run
val firstSelection = list.isEmpty() val firstSelection = list.isEmpty()
if (selected) list.add(item.chapterId) else list.remove(item.chapterId) if (selected) list.add(item.chapterId) else list.remove(item.chapterId)

View file

@ -62,22 +62,20 @@ class LibraryUpdateErrorScreenModel(
fun toggleSelection( fun toggleSelection(
item: LibraryUpdateErrorItem, item: LibraryUpdateErrorItem,
selected: Boolean, selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
mutableState.update { state -> mutableState.update { state ->
val selectedIndex = state.items.indexOfFirst { it.error.errorId == item.error.errorId }
if (selectedIndex < 0) return@update state
val selectedItem = state.items[selectedIndex]
if (selectedItem.selected == selected) return@update state
val newItems = state.items.toMutableList().apply { val newItems = state.items.toMutableList().apply {
val selectedIndex = indexOfFirst { it.error.errorId == item.error.errorId }
if (selectedIndex < 0) return@apply
val selectedItem = get(selectedIndex)
if (selectedItem.selected == selected) return@apply
val firstSelection = none { it.selected } val firstSelection = none { it.selected }
set(selectedIndex, selectedItem.copy(selected = selected)) set(selectedIndex, selectedItem.copy(selected = selected))
selectedErrorIds.addOrRemove(item.error.errorId, selected) selectedErrorIds.addOrRemove(item.error.errorId, selected)
if (selected && userSelected && fromLongPress) { if (selected && fromLongPress) {
if (firstSelection) { if (firstSelection) {
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
@ -96,14 +94,14 @@ class LibraryUpdateErrorScreenModel(
} }
range.forEach { range.forEach {
val inbetweenItem = get(it) val inBetweenItem = get(it)
if (!inbetweenItem.selected) { if (!inBetweenItem.selected) {
selectedErrorIds.add(inbetweenItem.error.errorId) selectedErrorIds.add(inBetweenItem.error.errorId)
set(it, inbetweenItem.copy(selected = true)) set(it, inBetweenItem.copy(selected = true))
} }
} }
} }
} else if (userSelected && !fromLongPress) { } else if (!fromLongPress) {
if (!selected) { if (!selected) {
if (selectedIndex == selectedPositions[0]) { if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected } selectedPositions[0] = indexOfFirst { it.selected }

View file

@ -555,8 +555,8 @@ class MangaScreenModel(
} }
val vibrantColor = it.getBestColor() ?: return@launchIO val vibrantColor = it.getBestColor() ?: return@launchIO
mangaCover.vibrantCoverColor = vibrantColor mangaCover.vibrantCoverColor = vibrantColor
updateSuccessState { updateSuccessState { state ->
it.copy(seedColor = Color(vibrantColor)) state.copy(seedColor = Color(vibrantColor))
} }
} }
} }
@ -1729,13 +1729,14 @@ class MangaScreenModel(
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
updateSuccessState { successState -> updateSuccessState { successState ->
// KMK -->
val selectedIndex = successState.processedChapters.indexOfFirst { it.id == item.chapter.id }
if (selectedIndex < 0) return@updateSuccessState successState
val selectedItem = successState.processedChapters[selectedIndex]
if (selectedItem.selected == selected) return@updateSuccessState successState
// KMK <--
val newChapters = successState.processedChapters.toMutableList().apply { val newChapters = successState.processedChapters.toMutableList().apply {
val selectedIndex = successState.processedChapters.indexOfFirst { it.id == item.chapter.id }
if (selectedIndex < 0) return@apply
val selectedItem = get(selectedIndex)
if ((selectedItem.selected && selected) || (!selectedItem.selected && !selected)) return@apply
val firstSelection = none { it.selected } val firstSelection = none { it.selected }
set(selectedIndex, selectedItem.copy(selected = selected)) set(selectedIndex, selectedItem.copy(selected = selected))
selectedChapterIds.addOrRemove(item.id, selected) selectedChapterIds.addOrRemove(item.id, selected)
@ -1759,10 +1760,10 @@ class MangaScreenModel(
} }
range.forEach { range.forEach {
val inbetweenItem = get(it) val inBetweenItem = get(it)
if (!inbetweenItem.selected) { if (!inBetweenItem.selected) {
selectedChapterIds.add(inbetweenItem.id) selectedChapterIds.add(inBetweenItem.id)
set(it, inbetweenItem.copy(selected = true)) set(it, inBetweenItem.copy(selected = true))
} }
} }
} }

View file

@ -379,13 +379,14 @@ class UpdatesScreenModel(
val (selected, userSelected, fromLongPress, isGroup, isExpanded) = selectionOptions val (selected, userSelected, fromLongPress, isGroup, isExpanded) = selectionOptions
// KMK <-- // KMK <--
mutableState.update { state -> mutableState.update { state ->
// KMK -->
val selectedIndex = state.items.indexOfFirst { it.update.chapterId == item.update.chapterId }
if (selectedIndex < 0) return@update state
val selectedItem = state.items[selectedIndex]
if (selectedItem.selected == selected) return@update state
// KMK <--
val newItems = state.items.toMutableList().apply { val newItems = state.items.toMutableList().apply {
val selectedIndex = indexOfFirst { it.update.chapterId == item.update.chapterId }
if (selectedIndex < 0) return@apply
val selectedItem = get(selectedIndex)
if (selectedItem.selected == selected) return@apply
val firstSelection = none { it.selected } val firstSelection = none { it.selected }
set(selectedIndex, selectedItem.copy(selected = selected)) set(selectedIndex, selectedItem.copy(selected = selected))
selectedChapterIds.addOrRemove(item.update.chapterId, selected) selectedChapterIds.addOrRemove(item.update.chapterId, selected)
@ -428,10 +429,10 @@ class UpdatesScreenModel(
} }
range.forEach { range.forEach {
val inbetweenItem = get(it) val inBetweenItem = get(it)
if (!inbetweenItem.selected) { if (!inBetweenItem.selected) {
selectedChapterIds.add(inbetweenItem.update.chapterId) selectedChapterIds.add(inBetweenItem.update.chapterId)
set(it, inbetweenItem.copy(selected = true)) set(it, inBetweenItem.copy(selected = true))
} }
} }
} }