From 357669d8b2c59e81edc3c00d86546afd528dd276 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 5 Dec 2023 09:59:07 +0200 Subject: [PATCH] Fix list items reorder --- app/build.gradle | 9 +- .../kotatsu/core/ui/ReorderableListAdapter.kt | 75 ++++++++++++++++ .../categories/FavouriteCategoriesActivity.kt | 27 +++--- .../FavouritesCategoriesViewModel.kt | 87 ++++++++---------- .../categories/adapter/CategoriesAdapter.kt | 4 +- .../sources/adapter/SourceConfigAdapter.kt | 4 +- .../sources/manage/SourcesManageFragment.kt | 13 ++- .../sources/manage/SourcesManageViewModel.kt | 88 +++++++++---------- build.gradle | 4 +- 9 files changed, 184 insertions(+), 127 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt diff --git a/app/build.gradle b/app/build.gradle index dee9651f9..684244f28 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -95,7 +95,6 @@ dependencies { implementation 'androidx.activity:activity-ktx:1.8.1' implementation 'androidx.fragment:fragment-ktx:1.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-process:2.6.2' 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-viewbinding:4.3.2' - implementation 'com.google.dagger:hilt-android:2.48.1' - kapt 'com.google.dagger:hilt-compiler:2.48.1' + implementation 'com.google.dagger:hilt-android:2.49' + kapt 'com.google.dagger:hilt-compiler:2.49' implementation 'androidx.hilt:hilt-work: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 'com.squareup.moshi:moshi-kotlin:1.15.0' - androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1' - kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1' + androidTestImplementation 'com.google.dagger:hilt-android-testing:2.49' + kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.49' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt new file mode 100644 index 000000000..06a744fd9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt @@ -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 : ListDelegationAdapter>(), FlowCollector?> { + + private val listListeners = LinkedList>() + + override suspend fun emit(value: List?) { + 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?) { + super.setItems(items) + } + + fun reorderItems(oldPos: Int, newPos: Int) { + Collections.swap(items ?: return, oldPos, newPos) + notifyItemMoved(oldPos, newPos) + } + + fun addDelegate(type: ListItemType, delegate: AdapterDelegate>): ReorderableListAdapter { + delegatesManager.addDelegate(type.ordinal, delegate) + return this + } + + fun addListListener(listListener: ListListener) { + listListeners.add(listListener) + } + + fun removeListListener(listListener: ListListener) { + listListeners.remove(listListener) + } + + protected class DiffCallback( + val oldList: List, + val newList: List, + ) : 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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt index 7de530222..5e1f0982f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt @@ -142,7 +142,11 @@ class FavouriteCategoriesActivity : } val fromPos = viewHolder.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( @@ -151,25 +155,16 @@ class FavouriteCategoriesActivity : target: RecyclerView.ViewHolder, ): 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 onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { super.onSelectedChanged(viewHolder, actionState) - viewBinding.recyclerView.isNestedScrollingEnabled = - actionState == ItemTouchHelper.ACTION_STATE_IDLE + viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewModel.saveOrder(adapter.items ?: return) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt index dcc9b1097..27fa877ee 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt @@ -1,13 +1,14 @@ package org.koitharu.kotatsu.favourites.ui.categories +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.yield +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory 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.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.parsers.util.move import javax.inject.Inject @HiltViewModel @@ -30,17 +30,9 @@ class FavouritesCategoriesViewModel @Inject constructor( private var commitJob: Job? = null - val content = MutableStateFlow>(listOf(LoadingState)) - - init { - launchJob(Dispatchers.Default) { - repository.observeCategoriesWithCovers() - .collectLatest { - commitJob?.join() - updateContent(it) - } - } - } + val content = repository.observeCategoriesWithCovers() + .map { it.toUiList() } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) fun deleteCategories(ids: Set) { launchJob(Dispatchers.Default) { @@ -54,11 +46,17 @@ class FavouritesCategoriesViewModel @Inject constructor( fun isEmpty(): Boolean = content.value.none { it is CategoryListModel } - fun reorderCategories(oldPos: Int, newPos: Int) { - val snapshot = content.requireValue().toMutableList() - snapshot.move(oldPos, newPos) - content.value = snapshot - commit(snapshot) + fun saveOrder(snapshot: List) { + val prevJob = commitJob + commitJob = launchJob { + prevJob?.cancelAndJoin() + val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) { + (it as? CategoryListModel)?.category?.id + } + if (ids.isNotEmpty()) { + repository.reorderCategories(ids) + } + } } fun setIsVisible(ids: Set, isVisible: Boolean) { @@ -76,36 +74,21 @@ class FavouritesCategoriesViewModel @Inject constructor( } } - private fun commit(snapshot: List) { - 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>) { - 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, - ), - ) - } + private fun Map>.toUiList(): List = 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, + ), + ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt index 46111fbe0..43658d3a3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter import androidx.lifecycle.LifecycleOwner 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.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener @@ -15,7 +15,7 @@ class CategoriesAdapter( lifecycleOwner: LifecycleOwner, onItemClickListener: FavouriteCategoriesListListener, listListener: ListStateHolderListener, -) : BaseListAdapter() { +) : ReorderableListAdapter() { init { addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt index 5f7505fad..a27037fa4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt @@ -2,14 +2,14 @@ package org.koitharu.kotatsu.settings.sources.adapter import androidx.lifecycle.LifecycleOwner 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 class SourceConfigAdapter( listener: SourceConfigListener, coil: ImageLoader, lifecycleOwner: LifecycleOwner, -) : BaseListAdapter() { +) : ReorderableListAdapter() { init { with(delegatesManager) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt index 059824fce..f5a7e051a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt @@ -54,6 +54,7 @@ class SourcesManageFragment : lateinit var shortcutManager: AppShortcutManager private var reorderHelper: ItemTouchHelper? = null + private var sourcesAdapter: SourceConfigAdapter? = null private val viewModel by viewModels() override val recyclerView: RecyclerView @@ -69,7 +70,7 @@ class SourcesManageFragment : savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) - val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner) + sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner) with(binding.recyclerView) { setHasFixedSize(true) adapter = sourcesAdapter @@ -77,7 +78,7 @@ class SourcesManageFragment : it.attachToRecyclerView(this) } } - viewModel.content.observe(viewLifecycleOwner, sourcesAdapter) + viewModel.content.observe(viewLifecycleOwner, checkNotNull(sourcesAdapter)) viewModel.onActionDone.observeEvent( viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView), @@ -91,6 +92,7 @@ class SourcesManageFragment : } override fun onDestroyView() { + sourcesAdapter = null reorderHelper = null super.onDestroyView() } @@ -204,7 +206,7 @@ class SourcesManageFragment : y: Int, ) { super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) - viewModel.reorderSources(fromPos, toPos) + sourcesAdapter?.reorderItems(fromPos, toPos) } override fun canDropOver( @@ -248,5 +250,10 @@ class SourcesManageFragment : } override fun isLongPressDragEnabled() = true + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewModel.saveSourcesOrder(sourcesAdapter?.items ?: return) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt index 5ad9d147c..daa7108ab 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt @@ -4,8 +4,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.yield import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.removeObserverAsync @@ -43,17 +41,19 @@ class SourcesManageViewModel @Inject constructor( database.invalidationTracker.removeObserverAsync(listProducer) } - fun reorderSources(oldPos: Int, newPos: Int) { - val snapshot = content.value.toMutableList() - if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { - return - } - if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { - return + fun saveSourcesOrder(snapshot: List) { + val prevJob = commitJob + commitJob = launchJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val newSourcesList = snapshot.mapNotNull { x -> + if (x is SourceConfigItem.SourceItem && x.isDraggable) { + x.source + } else { + null + } + } + repository.setPositions(newSourcesList) } - snapshot.move(oldPos, newPos) - content.value = snapshot - commit(snapshot) } fun canReorder(oldPos: Int, newPos: Int): Boolean { @@ -72,28 +72,31 @@ class SourcesManageViewModel @Inject constructor( } fun bringToTop(source: MangaSource) { - var oldPos = -1 - var newPos = -1 val snapshot = content.value - for ((i, x) in snapshot.withIndex()) { - if (x !is SourceConfigItem.SourceItem) { - continue - } - if (newPos == -1) { - newPos = i - } - if (x.source == source) { - oldPos = i - break + launchJob(Dispatchers.Default) { + var oldPos = -1 + 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 + } } - } - @Suppress("KotlinConstantConditions") - if (oldPos != -1 && newPos != -1) { - reorderSources(oldPos, newPos) - val revert = ReversibleAction(R.string.moved_to_top) { - reorderSources(newPos, oldPos) + @Suppress("KotlinConstantConditions") + if (oldPos != -1 && newPos != -1) { + reorderSources(oldPos, newPos) + val revert = ReversibleAction(R.string.moved_to_top) { + reorderSources(newPos, oldPos) + } + commitJob?.join() + onActionDone.call(revert) } - onActionDone.call(revert) } } @@ -113,20 +116,15 @@ class SourcesManageViewModel @Inject constructor( } } - private fun commit(snapshot: List) { - val prevJob = commitJob - commitJob = launchJob { - prevJob?.cancelAndJoin() - delay(500) - val newSourcesList = snapshot.mapNotNull { x -> - if (x is SourceConfigItem.SourceItem && x.isDraggable) { - x.source - } else { - null - } - } - repository.setPositions(newSourcesList) - yield() + private fun reorderSources(oldPos: Int, newPos: Int) { + val snapshot = content.value.toMutableList() + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { + return } + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { + return + } + snapshot.move(oldPos, newPos) + saveSourcesOrder(snapshot) } } diff --git a/build.gradle b/build.gradle index e6eac2132..da65f3c35 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.1.4' 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' } } @@ -22,5 +22,5 @@ allprojects { } tasks.register('clean', Delete) { - delete rootProject.layout.buildDirectory + delete rootProject.layout.buildDirectory }