Drag to reorder feeds (#660)

This commit is contained in:
Cuong-Tran 2025-01-26 01:56:11 +07:00 committed by GitHub
parent 5b775366b8
commit af77a962c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 108 additions and 193 deletions

View file

@ -3,13 +3,20 @@ package eu.kanade.presentation.browse
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import eu.kanade.presentation.browse.components.FeedOrderListItem
import eu.kanade.tachiyomi.ui.browse.feed.FeedScreenState
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
import tachiyomi.domain.source.model.FeedSavedSearch
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
@ -22,39 +29,53 @@ import tachiyomi.presentation.core.util.plus
fun FeedOrderScreen(
state: FeedScreenState,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickMoveUp: (FeedSavedSearch) -> Unit,
onClickMoveDown: (FeedSavedSearch) -> Unit,
changeOrder: (FeedSavedSearch, Int) -> Unit,
) {
when {
state.isLoading -> LoadingScreen()
state.isEmpty -> EmptyScreen(
state.isEmpty || state.items.isNullOrEmpty() -> EmptyScreen(
stringRes = MR.strings.empty_screen,
)
else -> {
val lazyListState = rememberLazyListState()
val feeds = state.items ?: emptyList()
val feeds = state.items
var reorderableList by remember { mutableStateOf(feeds) }
val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to ->
reorderableList = reorderableList.toMutableList().apply {
changeOrder(reorderableList[from.index].feed, to.index - from.index)
add(to.index, removeAt(from.index))
}
}
LaunchedEffect(feeds) {
if (!reorderableLazyColumnState.isAnyItemDragging) {
reorderableList = feeds
}
}
LazyColumn(
state = lazyListState,
contentPadding = topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
itemsIndexed(
items = feeds,
key = { _, feed -> "feed-${feed.feed.id}" },
) { index, feed ->
FeedOrderListItem(
modifier = Modifier.animateItem(),
title = feed.title,
canMoveUp = index != 0,
canMoveDown = index != feeds.lastIndex,
onMoveUp = { onClickMoveUp(feed.feed) },
onMoveDown = { onClickMoveDown(feed.feed) },
onDelete = { onClickDelete(feed.feed) },
)
items(
items = reorderableList,
key = { it.feed.key },
) { feed ->
ReorderableItem(reorderableLazyColumnState, feed.feed.key) {
FeedOrderListItem(
modifier = Modifier.animateItem(),
title = feed.title,
onDelete = { onClickDelete(feed.feed) },
)
}
}
}
}
}
}
internal val FeedSavedSearch.key get() = "feed-$id"

View file

@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
@ -75,7 +75,7 @@ fun FeedScreen(
onClickSavedSearch: (SavedSearch, CatalogueSource) -> Unit,
onClickSource: (CatalogueSource) -> Unit,
// KMK -->
onLongClickFeed: (FeedItemUI, Boolean, Boolean) -> Unit,
onLongClickFeed: (FeedItemUI) -> Unit,
// KMK <--
onClickManga: (Manga) -> Unit,
// KMK -->
@ -113,21 +113,17 @@ fun FeedScreen(
) {
// KMK -->
val feeds = state.items.orEmpty()
itemsIndexed(
items(
items = feeds,
key = { _, it -> "feed-${it.feed.id}" },
) { index, item ->
key = { it.feed.key },
) { item ->
// KMK <--
GlobalSearchResultItem(
title = item.title,
subtitle = item.subtitle,
onLongClick = {
// KMK -->
onLongClickFeed(
item,
index != 0,
index != feeds.lastIndex,
)
onLongClickFeed(item)
// KMK <--
},
onClick = {

View file

@ -3,18 +3,25 @@ package eu.kanade.presentation.browse
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import eu.kanade.presentation.browse.components.FeedOrderListItem
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarTitle
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedState
import kotlinx.collections.immutable.persistentListOf
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
import tachiyomi.domain.source.model.FeedSavedSearch
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
@ -30,8 +37,7 @@ import tachiyomi.presentation.core.util.plus
fun SourceFeedOrderScreen(
state: SourceFeedState,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickMoveUp: (FeedSavedSearch) -> Unit,
onClickMoveDown: (FeedSavedSearch) -> Unit,
changeOrder: (FeedSavedSearch, Int) -> Unit,
onClickSortAlphabetically: () -> Unit,
navigateUp: (() -> Unit)? = null,
) {
@ -68,25 +74,38 @@ fun SourceFeedOrderScreen(
val lazyListState = rememberLazyListState()
val feeds = state.items
.filterIsInstance<SourceFeedUI.SourceSavedSearch>()
var reorderableList by remember { mutableStateOf(feeds) }
val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to ->
reorderableList = reorderableList.toMutableList().apply {
changeOrder(reorderableList[from.index].feed, to.index - from.index)
add(to.index, removeAt(from.index))
}
}
LaunchedEffect(feeds) {
if (!reorderableLazyColumnState.isAnyItemDragging) {
reorderableList = feeds
}
}
LazyColumn(
state = lazyListState,
contentPadding = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
itemsIndexed(
items = feeds,
key = { _, feed -> "feed-${feed.feed.id}" },
) { index, feed ->
FeedOrderListItem(
modifier = Modifier.animateItem(),
title = feed.title,
canMoveUp = index != 0,
canMoveDown = index != feeds.lastIndex,
onMoveUp = { onClickMoveUp(feed.feed) },
onMoveDown = { onClickMoveDown(feed.feed) },
onDelete = { onClickDelete(feed.feed) },
)
items(
items = reorderableList,
key = { it.feed.key },
) { feed ->
ReorderableItem(reorderableLazyColumnState, feed.feed.key) {
FeedOrderListItem(
modifier = Modifier.animateItem(),
title = feed.title,
onDelete = { onClickDelete(feed.feed) },
)
}
}
}
}

View file

@ -2,7 +2,7 @@ package eu.kanade.presentation.browse
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.TopAppBarScrollBehavior
@ -94,7 +94,7 @@ fun SourceFeedScreen(
onClickSavedSearch: (SavedSearch) -> Unit,
// KMK -->
// onClickDelete: (FeedSavedSearch) -> Unit,
onLongClickFeed: (SourceFeedUI.SourceSavedSearch, Boolean, Boolean) -> Unit,
onLongClickFeed: (SourceFeedUI.SourceSavedSearch) -> Unit,
// KMK <--
onClickManga: (Manga) -> Unit,
onClickSearch: (String) -> Unit,
@ -200,7 +200,7 @@ fun SourceFeedList(
onClickSavedSearch: (SavedSearch) -> Unit,
// KMK -->
// onClickDelete: (FeedSavedSearch) -> Unit,
onLongClickFeed: (SourceFeedUI.SourceSavedSearch, Boolean, Boolean) -> Unit,
onLongClickFeed: (SourceFeedUI.SourceSavedSearch) -> Unit,
// KMK <--
onClickManga: (Manga) -> Unit,
// KMK -->
@ -212,10 +212,10 @@ fun SourceFeedList(
contentPadding = paddingValues + topSmallPaddingValues,
) {
// KMK -->
itemsIndexed(
items(
items,
key = { _, it -> "source-feed-${it.id}" },
) { index, item ->
key = { "source-feed-${it.id}" },
) { item ->
// KMK <--
GlobalSearchResultItem(
modifier = Modifier.animateItem(),
@ -231,11 +231,7 @@ fun SourceFeedList(
onLongClick = if (item is SourceFeedUI.SourceSavedSearch) {
{
// KMK -->
onLongClickFeed(
item,
index != items.size - items.filterIsInstance<SourceFeedUI.SourceSavedSearch>().size,
index != items.lastIndex,
)
onLongClickFeed(item)
// KMK <--
}
} else {

View file

@ -1,14 +1,11 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.ArrowDropUp
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Splitscreen
import androidx.compose.material.icons.outlined.DragHandle
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -17,18 +14,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import sh.calvin.reorderable.ReorderableCollectionItemScope
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun FeedOrderListItem(
fun ReorderableCollectionItemScope.FeedOrderListItem(
title: String,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -39,52 +32,26 @@ fun FeedOrderListItem(
modifier = Modifier
.fillMaxWidth()
.padding(
start = MaterialTheme.padding.medium,
top = MaterialTheme.padding.medium,
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.medium,
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Splitscreen,
imageVector = Icons.Outlined.DragHandle,
contentDescription = null,
modifier = Modifier
.padding(MaterialTheme.padding.medium)
.draggableHandle(),
)
Text(
text = title,
modifier = Modifier
.padding(start = MaterialTheme.padding.medium),
.weight(1f),
)
}
Row {
IconButton(
onClick = onMoveUp,
enabled = canMoveUp,
) {
Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null)
}
IconButton(
onClick = onMoveDown,
enabled = canMoveDown,
) {
Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onDelete) {
Icon(imageVector = Icons.Outlined.Delete, contentDescription = stringResource(MR.strings.action_delete))
}
}
}
}
@Preview
@Composable
private fun FeedOrderListItemPreview() {
FeedOrderListItem(
title = "Feed 1",
canMoveUp = true,
canMoveDown = true,
onMoveUp = {},
onMoveDown = {},
onDelete = {},
)
}

View file

@ -11,11 +11,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@ -99,12 +96,8 @@ private val TitlePadding = PaddingValues(bottom = 16.dp, top = 8.dp)
fun FeedActionsDialog(
feed: FeedSavedSearch,
title: String,
canMoveUp: Boolean,
canMoveDown: Boolean,
onDismissRequest: () -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
onMoveUp: (FeedSavedSearch) -> Unit,
onMoveDown: (FeedSavedSearch) -> Unit,
modifier: Modifier = Modifier,
) {
val minHeight = LocalPreferenceMinHeight.current
@ -129,32 +122,6 @@ fun FeedActionsDialog(
Spacer(Modifier.height(PaddingSize))
if (canMoveUp) {
TextPreferenceWidget(
title = stringResource(KMR.strings.action_move_up),
icon = Icons.Outlined.ArrowUpward,
onPreferenceClick = {
onDismissRequest()
onMoveUp(feed)
},
)
HorizontalDivider()
}
if (canMoveDown) {
TextPreferenceWidget(
title = stringResource(KMR.strings.action_move_down),
icon = Icons.Outlined.ArrowDownward,
onPreferenceClick = {
onDismissRequest()
onMoveDown(feed)
},
)
HorizontalDivider()
}
TextPreferenceWidget(
title = stringResource(MR.strings.action_delete),
icon = Icons.Outlined.Delete,
@ -200,12 +167,8 @@ private fun FeedActionsDialogPreview() {
feedOrder = 0,
),
title = "Feed 1",
canMoveUp = true,
canMoveDown = true,
onDismissRequest = { },
onClickDelete = { },
onMoveUp = { },
onMoveDown = { },
)
}

View file

@ -44,7 +44,6 @@ fun ReorderableCollectionItemScope.CategoryListItem(
modifier = Modifier
.fillMaxWidth()
.clickable { onRename() }
.padding(vertical = MaterialTheme.padding.small)
.padding(
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.medium,

View file

@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
@ -161,16 +160,12 @@ open class FeedScreenModel(
// KMK -->
fun openActionsDialog(
feed: FeedItemUI,
canMoveUp: Boolean,
canMoveDown: Boolean,
) {
screenModelScope.launchIO {
mutableState.update { state ->
state.copy(
dialog = Dialog.FeedActions(
feedItem = feed,
canMoveUp = canMoveUp,
canMoveDown = canMoveDown,
),
)
}
@ -221,15 +216,9 @@ open class FeedScreenModel(
}
// KMK -->
fun moveUp(feed: FeedSavedSearch) {
fun changeOrder(feed: FeedSavedSearch, newOrder: Int) {
screenModelScope.launch {
reorderFeed.moveUp(feed)
}
}
fun moveDown(feed: FeedSavedSearch) {
screenModelScope.launch {
reorderFeed.moveDown(feed)
reorderFeed.changeOrder(feed, newOrder)
}
}
@ -392,8 +381,6 @@ open class FeedScreenModel(
// KMK -->
data class FeedActions(
val feedItem: FeedItemUI,
val canMoveUp: Boolean,
val canMoveDown: Boolean,
) : Dialog()
data object SortAlphabetically : Dialog()

View file

@ -142,8 +142,7 @@ fun feedTab(
FeedOrderScreen(
state = state,
onClickDelete = screenModel::openDeleteDialog,
onClickMoveUp = screenModel::moveUp,
onClickMoveDown = screenModel::moveDown,
changeOrder = screenModel::changeOrder,
)
} else {
// KMK <--
@ -249,12 +248,8 @@ fun feedTab(
FeedActionsDialog(
feed = dialog.feedItem.feed,
title = dialog.feedItem.title,
canMoveUp = dialog.canMoveUp,
canMoveDown = dialog.canMoveDown,
onDismissRequest = onDismissRequest,
onClickDelete = { screenModel.openDeleteDialog(it) },
onMoveUp = { screenModel.moveUp(it) },
onMoveDown = { screenModel.moveDown(it) },
)
}
is FeedScreenModel.Dialog.SortAlphabetically -> {

View file

@ -105,8 +105,7 @@ class SourceFeedScreen(val sourceId: Long) : Screen() {
SourceFeedOrderScreen(
state = state,
onClickDelete = screenModel::openDeleteFeed,
onClickMoveUp = screenModel::moveUp,
onClickMoveDown = screenModel::moveDown,
changeOrder = screenModel::changeOrder,
onClickSortAlphabetically = {
screenModel.showDialog(SourceFeedScreenModel.Dialog.SortAlphabetically)
},
@ -221,12 +220,8 @@ class SourceFeedScreen(val sourceId: Long) : Screen() {
FeedActionsDialog(
feed = dialog.feedItem.feed,
title = dialog.feedItem.title,
canMoveUp = dialog.canMoveUp,
canMoveDown = dialog.canMoveDown,
onDismissRequest = screenModel::dismissDialog,
onClickDelete = { screenModel.openDeleteFeed(it) },
onMoveUp = { screenModel.moveUp(it) },
onMoveDown = { screenModel.moveDown(it) },
)
}
is SourceFeedScreenModel.Dialog.SortAlphabetically -> {

View file

@ -156,15 +156,9 @@ open class SourceFeedScreenModel(
}
// KMK -->
fun moveUp(feed: FeedSavedSearch) {
fun changeOrder(feed: FeedSavedSearch, newOrder: Int) {
screenModelScope.launch {
reorderFeed.moveUp(feed, false)
}
}
fun moveDown(feed: FeedSavedSearch) {
screenModelScope.launch {
reorderFeed.moveDown(feed, false)
reorderFeed.changeOrder(feed, newOrder, false)
}
}
@ -391,16 +385,12 @@ open class SourceFeedScreenModel(
// KMK -->
fun openActionsDialog(
feed: SourceFeedUI.SourceSavedSearch,
canMoveUp: Boolean,
canMoveDown: Boolean,
) {
screenModelScope.launchIO {
mutableState.update { state ->
state.copy(
dialog = Dialog.FeedActions(
feedItem = feed,
canMoveUp = canMoveUp,
canMoveDown = canMoveDown,
),
)
}
@ -432,8 +422,6 @@ open class SourceFeedScreenModel(
// KMK -->
data class FeedActions(
val feedItem: SourceFeedUI.SourceSavedSearch,
val canMoveUp: Boolean,
val canMoveDown: Boolean,
) : Dialog()
data object SortAlphabetically : Dialog()

View file

@ -27,7 +27,7 @@ class HideCategory(
}
sealed class Result {
object Success : Result()
data object Success : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View file

@ -8,7 +8,6 @@ import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.model.FeedSavedSearch
import tachiyomi.domain.source.model.FeedSavedSearchUpdate
import tachiyomi.domain.source.repository.FeedSavedSearchRepository
import java.util.Collections
class ReorderFeed(
private val feedSavedSearchRepository: FeedSavedSearchRepository,
@ -16,11 +15,7 @@ class ReorderFeed(
private val mutex = Mutex()
suspend fun moveUp(feed: FeedSavedSearch, global: Boolean = true): Result = awaitGlobal(feed, MoveTo.UP, global)
suspend fun moveDown(feed: FeedSavedSearch, global: Boolean = true): Result = awaitGlobal(feed, MoveTo.DOWN, global)
private suspend fun awaitGlobal(feed: FeedSavedSearch, moveTo: MoveTo, global: Boolean = true) = withNonCancellableContext {
suspend fun changeOrder(feed: FeedSavedSearch, newOrder: Int, global: Boolean = true) = withNonCancellableContext {
mutex.withLock {
val feeds = if (global) {
feedSavedSearchRepository.getGlobal()
@ -35,13 +30,10 @@ class ReorderFeed(
return@withNonCancellableContext Result.Unchanged
}
val newPosition = when (moveTo) {
MoveTo.UP -> currentIndex - 1
MoveTo.DOWN -> currentIndex + 1
}.toInt()
val newPosition = currentIndex + newOrder
try {
Collections.swap(feeds, currentIndex, newPosition)
feeds.add(newPosition, feeds.removeAt(currentIndex))
val updates = feeds.mapIndexed { index, feed ->
FeedSavedSearchUpdate(
@ -77,9 +69,4 @@ class ReorderFeed(
data object Unchanged : Result
data class InternalError(val error: Throwable) : Result
}
private enum class MoveTo {
UP,
DOWN,
}
}

View file

@ -12,5 +12,7 @@ data class FeedSavedSearchUpdate(
// If the feed is a global (FeedScreen) or source specific feed (SourceFeedScreen)
val global: Boolean? = null,
// Local index in global feed if it's a global one or in source feed if it's a source one
val feedOrder: Long? = null,
)