Fix list items reorder

This commit is contained in:
Koitharu
2023-12-05 09:59:07 +02:00
parent 21639ddcbc
commit 357669d8b2
9 changed files with 184 additions and 127 deletions

View File

@@ -95,7 +95,6 @@ dependencies {
implementation 'androidx.activity:activity-ktx:1.8.1' implementation 'androidx.activity:activity-ktx:1.8.1'
implementation 'androidx.fragment:fragment-ktx:1.6.2' implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
// implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.6.2' implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.2' implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
@@ -126,8 +125,8 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.48.1' implementation 'com.google.dagger:hilt-android:2.49'
kapt 'com.google.dagger:hilt-compiler:2.48.1' kapt 'com.google.dagger:hilt-compiler:2.49'
implementation 'androidx.hilt:hilt-work:1.1.0' implementation 'androidx.hilt:hilt-work:1.1.0'
kapt 'androidx.hilt:hilt-compiler:1.1.0' kapt 'androidx.hilt:hilt-compiler:1.1.0'
@@ -156,6 +155,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.6.1' androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.49'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.49'
} }

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.core.ui
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Collections
import java.util.LinkedList
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
private val listListeners = LinkedList<ListListener<T>>()
override suspend fun emit(value: List<T>?) {
val oldList = items.orEmpty()
val newList = value.orEmpty()
val diffResult = withContext(Dispatchers.Default) {
val diffCallback = DiffCallback(oldList, newList)
DiffUtil.calculateDiff(diffCallback)
}
super.setItems(newList)
diffResult.dispatchUpdatesTo(this)
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
}
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
override fun setItems(items: List<T>?) {
super.setItems(items)
}
fun reorderItems(oldPos: Int, newPos: Int) {
Collections.swap(items ?: return, oldPos, newPos)
notifyItemMoved(oldPos, newPos)
}
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): ReorderableListAdapter<T> {
delegatesManager.addDelegate(type.ordinal, delegate)
return this
}
fun addListListener(listListener: ListListener<T>) {
listListeners.add(listListener)
}
fun removeListListener(listListener: ListListener<T>) {
listListeners.remove(listListener)
}
protected class DiffCallback<T : ListModel>(
val oldList: List<T>,
val newList: List<T>,
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem.areItemsTheSame(oldItem)
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem == oldItem
}
}
}

View File

@@ -142,7 +142,11 @@ class FavouriteCategoriesActivity :
} }
val fromPos = viewHolder.bindingAdapterPosition val fromPos = viewHolder.bindingAdapterPosition
val toPos = target.bindingAdapterPosition val toPos = target.bindingAdapterPosition
return fromPos != toPos && fromPos != RecyclerView.NO_POSITION && toPos != RecyclerView.NO_POSITION if (fromPos == toPos || fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) {
return false
}
adapter.reorderItems(fromPos, toPos)
return true
} }
override fun canDropOver( override fun canDropOver(
@@ -151,25 +155,16 @@ class FavouriteCategoriesActivity :
target: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType ): Boolean = current.itemViewType == target.itemViewType
override fun onMoved(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
fromPos: Int,
target: RecyclerView.ViewHolder,
toPos: Int,
x: Int,
y: Int,
) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderCategories(fromPos, toPos)
}
override fun isLongPressDragEnabled(): Boolean = false override fun isLongPressDragEnabled(): Boolean = false
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState) super.onSelectedChanged(viewHolder, actionState)
viewBinding.recyclerView.isNestedScrollingEnabled = viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE
actionState == ItemTouchHelper.ACTION_STATE_IDLE }
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewModel.saveOrder(adapter.items ?: return)
} }
} }

View File

@@ -1,13 +1,14 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.yield import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -19,7 +20,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.util.move
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -30,17 +30,9 @@ class FavouritesCategoriesViewModel @Inject constructor(
private var commitJob: Job? = null private var commitJob: Job? = null
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState)) val content = repository.observeCategoriesWithCovers()
.map { it.toUiList() }
init { .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
launchJob(Dispatchers.Default) {
repository.observeCategoriesWithCovers()
.collectLatest {
commitJob?.join()
updateContent(it)
}
}
}
fun deleteCategories(ids: Set<Long>) { fun deleteCategories(ids: Set<Long>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -54,11 +46,17 @@ class FavouritesCategoriesViewModel @Inject constructor(
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel } fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
fun reorderCategories(oldPos: Int, newPos: Int) { fun saveOrder(snapshot: List<ListModel>) {
val snapshot = content.requireValue().toMutableList() val prevJob = commitJob
snapshot.move(oldPos, newPos) commitJob = launchJob {
content.value = snapshot prevJob?.cancelAndJoin()
commit(snapshot) val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
if (ids.isNotEmpty()) {
repository.reorderCategories(ids)
}
}
} }
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) { fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
@@ -76,36 +74,21 @@ class FavouritesCategoriesViewModel @Inject constructor(
} }
} }
private fun commit(snapshot: List<ListModel>) { private fun Map<FavouriteCategory, List<Cover>>.toUiList(): List<ListModel> = map { (category, covers) ->
val prevJob = commitJob CategoryListModel(
commitJob = launchJob { mangaCount = covers.size,
prevJob?.cancelAndJoin() covers = covers.take(3),
delay(500) category = category,
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) { isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
(it as? CategoryListModel)?.category?.id )
} }.ifEmpty {
repository.reorderCategories(ids) listOf(
yield() EmptyState(
} icon = R.drawable.ic_empty_favourites,
} textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) { actionStringRes = 0,
content.value = categories.map { (category, covers) -> ),
CategoryListModel( )
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}.ifEmpty {
listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
),
)
}
} }
} }

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.ReorderableListAdapter
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -15,7 +15,7 @@ class CategoriesAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
onItemClickListener: FavouriteCategoriesListListener, onItemClickListener: FavouriteCategoriesListListener,
listListener: ListStateHolderListener, listListener: ListStateHolderListener,
) : BaseListAdapter<ListModel>() { ) : ReorderableListAdapter<ListModel>() {
init { init {
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener)) addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))

View File

@@ -2,14 +2,14 @@ package org.koitharu.kotatsu.settings.sources.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.ReorderableListAdapter
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class SourceConfigAdapter( class SourceConfigAdapter(
listener: SourceConfigListener, listener: SourceConfigListener,
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) : BaseListAdapter<SourceConfigItem>() { ) : ReorderableListAdapter<SourceConfigItem>() {
init { init {
with(delegatesManager) { with(delegatesManager) {

View File

@@ -54,6 +54,7 @@ class SourcesManageFragment :
lateinit var shortcutManager: AppShortcutManager lateinit var shortcutManager: AppShortcutManager
private var reorderHelper: ItemTouchHelper? = null private var reorderHelper: ItemTouchHelper? = null
private var sourcesAdapter: SourceConfigAdapter? = null
private val viewModel by viewModels<SourcesManageViewModel>() private val viewModel by viewModels<SourcesManageViewModel>()
override val recyclerView: RecyclerView override val recyclerView: RecyclerView
@@ -69,7 +70,7 @@ class SourcesManageFragment :
savedInstanceState: Bundle?, savedInstanceState: Bundle?,
) { ) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner) sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
adapter = sourcesAdapter adapter = sourcesAdapter
@@ -77,7 +78,7 @@ class SourcesManageFragment :
it.attachToRecyclerView(this) it.attachToRecyclerView(this)
} }
} }
viewModel.content.observe(viewLifecycleOwner, sourcesAdapter) viewModel.content.observe(viewLifecycleOwner, checkNotNull(sourcesAdapter))
viewModel.onActionDone.observeEvent( viewModel.onActionDone.observeEvent(
viewLifecycleOwner, viewLifecycleOwner,
ReversibleActionObserver(binding.recyclerView), ReversibleActionObserver(binding.recyclerView),
@@ -91,6 +92,7 @@ class SourcesManageFragment :
} }
override fun onDestroyView() { override fun onDestroyView() {
sourcesAdapter = null
reorderHelper = null reorderHelper = null
super.onDestroyView() super.onDestroyView()
} }
@@ -204,7 +206,7 @@ class SourcesManageFragment :
y: Int, y: Int,
) { ) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderSources(fromPos, toPos) sourcesAdapter?.reorderItems(fromPos, toPos)
} }
override fun canDropOver( override fun canDropOver(
@@ -248,5 +250,10 @@ class SourcesManageFragment :
} }
override fun isLongPressDragEnabled() = true override fun isLongPressDragEnabled() = true
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewModel.saveSourcesOrder(sourcesAdapter?.items ?: return)
}
} }
} }

View File

@@ -4,8 +4,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.removeObserverAsync import org.koitharu.kotatsu.core.db.removeObserverAsync
@@ -43,17 +41,19 @@ class SourcesManageViewModel @Inject constructor(
database.invalidationTracker.removeObserverAsync(listProducer) database.invalidationTracker.removeObserverAsync(listProducer)
} }
fun reorderSources(oldPos: Int, newPos: Int) { fun saveSourcesOrder(snapshot: List<SourceConfigItem>) {
val snapshot = content.value.toMutableList() val prevJob = commitJob
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { commitJob = launchJob(Dispatchers.Default) {
return prevJob?.cancelAndJoin()
val newSourcesList = snapshot.mapNotNull { x ->
if (x is SourceConfigItem.SourceItem && x.isDraggable) {
x.source
} else {
null
}
}
repository.setPositions(newSourcesList)
} }
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return
}
snapshot.move(oldPos, newPos)
content.value = snapshot
commit(snapshot)
} }
fun canReorder(oldPos: Int, newPos: Int): Boolean { fun canReorder(oldPos: Int, newPos: Int): Boolean {
@@ -72,28 +72,31 @@ class SourcesManageViewModel @Inject constructor(
} }
fun bringToTop(source: MangaSource) { fun bringToTop(source: MangaSource) {
var oldPos = -1
var newPos = -1
val snapshot = content.value val snapshot = content.value
for ((i, x) in snapshot.withIndex()) { launchJob(Dispatchers.Default) {
if (x !is SourceConfigItem.SourceItem) { var oldPos = -1
continue var newPos = -1
for ((i, x) in snapshot.withIndex()) {
if (x !is SourceConfigItem.SourceItem) {
continue
}
if (newPos == -1) {
newPos = i
}
if (x.source == source) {
oldPos = i
break
}
} }
if (newPos == -1) { @Suppress("KotlinConstantConditions")
newPos = i if (oldPos != -1 && newPos != -1) {
reorderSources(oldPos, newPos)
val revert = ReversibleAction(R.string.moved_to_top) {
reorderSources(newPos, oldPos)
}
commitJob?.join()
onActionDone.call(revert)
} }
if (x.source == source) {
oldPos = i
break
}
}
@Suppress("KotlinConstantConditions")
if (oldPos != -1 && newPos != -1) {
reorderSources(oldPos, newPos)
val revert = ReversibleAction(R.string.moved_to_top) {
reorderSources(newPos, oldPos)
}
onActionDone.call(revert)
} }
} }
@@ -113,20 +116,15 @@ class SourcesManageViewModel @Inject constructor(
} }
} }
private fun commit(snapshot: List<SourceConfigItem>) { private fun reorderSources(oldPos: Int, newPos: Int) {
val prevJob = commitJob val snapshot = content.value.toMutableList()
commitJob = launchJob { if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
prevJob?.cancelAndJoin() return
delay(500)
val newSourcesList = snapshot.mapNotNull { x ->
if (x is SourceConfigItem.SourceItem && x.isDraggable) {
x.source
} else {
null
}
}
repository.setPositions(newSourcesList)
yield()
} }
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return
}
snapshot.move(oldPos, newPos)
saveSourcesOrder(snapshot)
} }
} }

View File

@@ -6,7 +6,7 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.1.4' classpath 'com.android.tools.build:gradle:8.1.4'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21'
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.48.1' classpath 'com.google.dagger:hilt-android-gradle-plugin:2.49'
classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.21-1.0.15' classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.21-1.0.15'
} }
} }
@@ -22,5 +22,5 @@ allprojects {
} }
tasks.register('clean', Delete) { tasks.register('clean', Delete) {
delete rootProject.layout.buildDirectory delete rootProject.layout.buildDirectory
} }