Fix list items reorder

pull/581/head
Koitharu 2 years ago
parent 21639ddcbc
commit 357669d8b2
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -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'
} }

@ -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
}
}
}

@ -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)
} }
} }

@ -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,21 +74,7 @@ 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
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
repository.reorderCategories(ids)
yield()
}
}
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) {
content.value = categories.map { (category, covers) ->
CategoryListModel( CategoryListModel(
mangaCount = covers.size, mangaCount = covers.size,
covers = covers.take(3), covers = covers.take(3),
@ -107,5 +91,4 @@ class FavouritesCategoriesViewModel @Inject constructor(
), ),
) )
} }
}
} }

@ -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))

@ -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) {

@ -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)
}
} }
} }

@ -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
} }
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return
} }
snapshot.move(oldPos, newPos) repository.setPositions(newSourcesList)
content.value = snapshot }
commit(snapshot)
} }
fun canReorder(oldPos: Int, newPos: Int): Boolean { fun canReorder(oldPos: Int, newPos: Int): Boolean {
@ -72,9 +72,10 @@ class SourcesManageViewModel @Inject constructor(
} }
fun bringToTop(source: MangaSource) { fun bringToTop(source: MangaSource) {
val snapshot = content.value
launchJob(Dispatchers.Default) {
var oldPos = -1 var oldPos = -1
var newPos = -1 var newPos = -1
val snapshot = content.value
for ((i, x) in snapshot.withIndex()) { for ((i, x) in snapshot.withIndex()) {
if (x !is SourceConfigItem.SourceItem) { if (x !is SourceConfigItem.SourceItem) {
continue continue
@ -93,9 +94,11 @@ class SourcesManageViewModel @Inject constructor(
val revert = ReversibleAction(R.string.moved_to_top) { val revert = ReversibleAction(R.string.moved_to_top) {
reorderSources(newPos, oldPos) reorderSources(newPos, oldPos)
} }
commitJob?.join()
onActionDone.call(revert) onActionDone.call(revert)
} }
} }
}
fun disableAll() { fun disableAll() {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@ -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) if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
yield() return
} }
snapshot.move(oldPos, newPos)
saveSourcesOrder(snapshot)
} }
} }

@ -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'
} }
} }

Loading…
Cancel
Save