diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/BaseAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/BaseAdapter.kt index 86b52e7e..fb48151a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/BaseAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/BaseAdapter.kt @@ -11,13 +11,12 @@ import android.annotation.SuppressLint import androidx.lifecycle.MutableLiveData import androidx.recyclerview.widget.AdapterListUpdateCallback import androidx.recyclerview.widget.AsyncDifferConfig -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.recyclerview.widget.AsyncListDiffer.ListListener import androidx.recyclerview.widget.DiffUtil import com.drakeet.multitype.MultiTypeAdapter import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.util.BoundedTreeSet +import org.moire.ultrasonic.util.SettableAsyncListDiffer import timber.log.Timber /** @@ -59,23 +58,33 @@ class BaseAdapter : MultiTypeAdapter(), FastScrollRecyclerView throw IllegalAccessException("You must use submitList() to add data to the Adapter") } - private var mDiffer: AsyncListDiffer = AsyncListDiffer( + private var mDiffer: SettableAsyncListDiffer = SettableAsyncListDiffer( AdapterListUpdateCallback(this), AsyncDifferConfig.Builder(diffCallback).build() ) - private val mListener = - ListListener { previousList, currentList -> - this@BaseAdapter.onCurrentListChanged( - previousList, - currentList - ) + private val mListener: SettableAsyncListDiffer.ListListener = + object : SettableAsyncListDiffer.ListListener { + override fun onCurrentListChanged(previousList: List, currentList: List) { + this@BaseAdapter.onCurrentListChanged( + previousList, + currentList + ) + } } init { mDiffer.addListListener(mListener) } + /** + * Sets the List to a new value without invoking the diffing + */ + fun setList(newList: List) { + Timber.v("SetList updated list in differ, size %s", newList.size) + mDiffer.setList(newList) + } + /** * Submits a new list to be diffed, and displayed. * diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 4260e939..e3309031 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -869,18 +869,21 @@ class PlayerFragment : val from = viewHolder.bindingAdapterPosition val to = target.bindingAdapterPosition - Timber.i("MOVING from %d to %d", from, to) - val newList = viewAdapter.getCurrentList().toMutableList() + // The item must be moved manually in the viewAdapter, because it must be + // moved synchronously, before this function returns. AsyncListDiffer would execute + // the move too late + val items = viewAdapter.getCurrentList().toMutableList() if (from < to) { for (i in from until to) { - Collections.swap(newList, i, i + 1) + Collections.swap(items, i, i + 1) } } else { for (i in from downTo to + 1) { - Collections.swap(newList, i, i - 1) + Collections.swap(items, i, i - 1) } } - viewAdapter.submitList(newList) + viewAdapter.setList(items) + viewAdapter.notifyItemMoved(from, to) endPosition = to // When the user moves an item, onMove may be called many times quickly, @@ -896,7 +899,12 @@ class PlayerFragment : override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val pos = viewHolder.bindingAdapterPosition val item = mediaPlayerController.getMediaItemAt(pos) - mediaPlayerController.removeFromPlaylist(pos) + + // Remove the item from the list quickly + val items = viewAdapter.getCurrentList().toMutableList() + items.removeAt(pos) + viewAdapter.setList(items) + viewAdapter.notifyItemRemoved(pos) val songRemoved = String.format( resources.getString(R.string.download_song_removed), @@ -904,6 +912,9 @@ class PlayerFragment : ) Util.toast(context, songRemoved) + + // Remove the item from the playlist + mediaPlayerController.removeFromPlaylist(pos) } override fun onSelectedChanged( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SettableAsyncListDiffer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SettableAsyncListDiffer.kt new file mode 100644 index 00000000..9e0dc12f --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SettableAsyncListDiffer.kt @@ -0,0 +1,313 @@ +/* + * SettableAsyncListDiffer.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.util + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import androidx.recyclerview.widget.AdapterListUpdateCallback +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DiffUtil.DiffResult +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import java.util.Collections +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executor + +/** + * This class is a variation of the AsyncListDiffer provided for the RecyclerView. + * It is possible to set its List to a new value without triggering the diffing. + * This is necessary when the changes in the List must be synchronous, and are + * executed manually elsewhere in the code. + * @see androidx.recyclerview.widget.AsyncListDiffer + * More discussion about the necessity of this class is in the merge request: + * https://gitlab.com/ultrasonic/ultrasonic/-/merge_requests/815 + * We opened an issue for Google and hope they will add an official solution, see: + * https://issuetracker.google.com/issues/247351552 + */ +class SettableAsyncListDiffer { + private var mUpdateCallback: ListUpdateCallback? = null + var mConfig: AsyncDifferConfig? = null + private var mMainThreadExecutor: Executor? = null + + private class MainThreadExecutor : Executor { + val mHandler = Handler(Looper.getMainLooper()) + override fun execute(command: Runnable) { + mHandler.post(command) + } + } + + // TODO: use MainThreadExecutor from supportlib once one exists + private val sMainThreadExecutor: Executor = MainThreadExecutor() + + /** + * Listener for when the current List is updated. + * + * @param Type of items in List + */ + interface ListListener { + /** + * Called after the current List has been updated. + * + * @param previousList The previous list. + * @param currentList The new current list. + */ + fun onCurrentListChanged(previousList: List, currentList: List) + } + + private val mListeners: MutableList> = CopyOnWriteArrayList() + + /** + * Convenience for + * `AsyncListDiffer(new AdapterListUpdateCallback(adapter), + * new AsyncDifferConfig.Builder().setDiffCallback(diffCallback).build());` + * + * @param adapter Adapter to dispatch position updates to. + * @param diffCallback ItemCallback that compares items to dispatch appropriate animations when + * + * @see DiffUtil.DiffResult.dispatchUpdatesTo + */ + constructor( + adapter: RecyclerView.Adapter<*>, + diffCallback: DiffUtil.ItemCallback + ) : this( + AdapterListUpdateCallback(adapter), + AsyncDifferConfig.Builder(diffCallback).build() + ) + + /** + * Create a AsyncListDiffer with the provided config, and ListUpdateCallback to dispatch + * updates to. + * + * @param listUpdateCallback Callback to dispatch updates to. + * @param config Config to define background work Executor, and DiffUtil.ItemCallback for + * computing List diffs. + * + * @see DiffUtil.DiffResult.dispatchUpdatesTo + */ + @SuppressLint("RestrictedApi") + constructor( + listUpdateCallback: ListUpdateCallback, + config: AsyncDifferConfig + ) { + mUpdateCallback = listUpdateCallback + mConfig = config + mMainThreadExecutor = if (config.mainThreadExecutor != null) { + config.mainThreadExecutor + } else { + sMainThreadExecutor + } + } + + private var mList: List? = null + + /** + * Non-null, unmodifiable version of mList. + * + * + * Collections.emptyList when mList is null, wrapped by Collections.unmodifiableList otherwise + */ + private var mReadOnlyList = emptyList() + + // Max generation of currently scheduled runnable + private var mMaxScheduledGeneration/* synthetic access */ = 0 + + /** + * Get the current List - any diffing to present this list has already been computed and + * dispatched via the ListUpdateCallback. + * + * + * If a `null` List, or no List has been submitted, an empty list will be returned. + * + * + * The returned list may not be mutated - mutations to content must be done through + * [.submitList]. + * + * @return current List. + */ + + val currentList: List + get() = mReadOnlyList + + /** + * Sets the List to a new value without invoking the diffing + */ + fun setList(newList: List) { + mList = newList + mReadOnlyList = Collections.unmodifiableList(newList) + } + + /** + * Pass a new List to the AdapterHelper. Adapter updates will be computed on a background + * thread. + * + * + * If a List is already present, a diff will be computed asynchronously on a background thread. + * When the diff is computed, it will be applied (dispatched to the [ListUpdateCallback]), + * and the new List will be swapped in. + * + * @param newList The new List. + */ + fun submitList(newList: List?) { + submitList(newList, null) + } + + /** + * Pass a new List to the AdapterHelper. Adapter updates will be computed on a background + * thread. + * + * + * If a List is already present, a diff will be computed asynchronously on a background thread. + * When the diff is computed, it will be applied (dispatched to the [ListUpdateCallback]), + * and the new List will be swapped in. + * + * + * The commit callback can be used to know when the List is committed, but note that it + * may not be executed. If List B is submitted immediately after List A, and is + * committed directly, the callback associated with List A will not be run. + * + * @param newList The new List. + * @param commitCallback Optional runnable that is executed when the List is committed, if + * it is committed. + */ + fun submitList( + newList: List?, + commitCallback: Runnable? + ) { + // incrementing generation means any currently-running diffs are discarded when they finish + val runGeneration = ++mMaxScheduledGeneration + if (newList === mList) { + // nothing to do (Note - still had to inc generation, since may have ongoing work) + commitCallback?.run() + return + } + val previousList = mReadOnlyList + + // fast simple remove all + if (newList == null) { + val countRemoved = mList!!.size + mList = null + mReadOnlyList = emptyList() + // notify last, after list is updated + mUpdateCallback!!.onRemoved(0, countRemoved) + onCurrentListChanged(previousList, commitCallback) + return + } + + // fast simple first insert + if (mList == null) { + mList = newList + mReadOnlyList = Collections.unmodifiableList(newList) + // notify last, after list is updated + mUpdateCallback!!.onInserted(0, newList.size) + onCurrentListChanged(previousList, commitCallback) + return + } + val oldList: List = mList!! + mConfig!!.backgroundThreadExecutor.execute { + val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int { + return oldList.size + } + + override fun getNewListSize(): Int { + return newList.size + } + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + return if (oldItem != null && newItem != null) { + mConfig!!.diffCallback.areItemsTheSame(oldItem, newItem) + } else oldItem == null && newItem == null + // If both items are null we consider them the same. + } + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + if (oldItem != null && newItem != null) { + return mConfig!!.diffCallback.areContentsTheSame(oldItem, newItem) + } + if (oldItem == null && newItem == null) { + return true + } + throw AssertionError() + } + + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + if (oldItem != null && newItem != null) { + return mConfig!!.diffCallback.getChangePayload(oldItem, newItem) + } + throw AssertionError() + } + }) + mMainThreadExecutor!!.execute { + if (mMaxScheduledGeneration == runGeneration) { + latchList(newList, result, commitCallback) + } + } + } + } + + private fun latchList( + newList: List, + diffResult: DiffResult, + commitCallback: Runnable? + ) { + val previousList = mReadOnlyList + mList = newList + // notify last, after list is updated + mReadOnlyList = Collections.unmodifiableList(newList) + diffResult.dispatchUpdatesTo(mUpdateCallback!!) + onCurrentListChanged(previousList, commitCallback) + } + + private fun onCurrentListChanged( + previousList: List, + commitCallback: Runnable? + ) { + // current list is always mReadOnlyList + for (listener in mListeners) { + listener.onCurrentListChanged(previousList, mReadOnlyList) + } + commitCallback?.run() + } + + /** + * Add a ListListener to receive updates when the current List changes. + * + * @param listener Listener to receive updates. + * + * @see .getCurrentList + * @see .removeListListener + */ + fun addListListener(listener: ListListener) { + mListeners.add(listener) + } + + /** + * Remove a previously registered ListListener. + * + * @param listener Previously registered listener. + * @see .getCurrentList + * @see .addListListener + */ + fun removeListListener(listener: ListListener) { + mListeners.remove(listener) + } +}