Handle nested scroll state in shelf

This commit is contained in:
Koitharu
2023-04-14 18:22:40 +03:00
parent 277d575485
commit c4ba311087
6 changed files with 94 additions and 5 deletions

View File

@@ -0,0 +1,64 @@
package org.koitharu.kotatsu.base.ui.list
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.core.os.BundleCompat
import androidx.core.view.doOnNextLayout
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
import java.util.WeakHashMap
class NestedScrollStateHandle(
savedInstanceState: Bundle?,
private val key: String,
) {
private val storage: SparseArray<Parcelable?> = savedInstanceState?.let {
BundleCompat.getSparseParcelableArray(it, key, Parcelable::class.java)
} ?: SparseArray<Parcelable?>()
private val controllers = Collections.newSetFromMap<Controller>(WeakHashMap())
fun attach(recycler: RecyclerView) = Controller(recycler).also(controllers::add)
fun onSaveInstanceState(outState: Bundle) {
controllers.forEach {
it.saveState()
}
outState.putSparseParcelableArray(key, storage)
}
inner class Controller(
private val recycler: RecyclerView
) {
private var lastPosition: Int = -1
fun onBind(position: Int) {
if (position != lastPosition) {
saveState()
lastPosition = position
storage[position]?.let {
restoreState(it)
}
}
}
fun onRecycled() {
saveState()
lastPosition = -1
}
fun saveState() {
if (lastPosition != -1) {
storage[lastPosition] = recycler.layoutManager?.onSaveInstanceState()
}
}
private fun restoreState(state: Parcelable) {
recycler.doOnNextLayout {
recycler.layoutManager?.onRestoreInstanceState(state)
}
}
}
}

View File

@@ -14,6 +14,7 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.NestedScrollStateHandle
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
@@ -47,6 +48,7 @@ class ShelfFragment :
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
private var nestedScrollStateHandle: NestedScrollStateHandle? = null
private val viewModel by viewModels<ShelfViewModel>() private val viewModel by viewModels<ShelfViewModel>()
private var adapter: ShelfAdapter? = null private var adapter: ShelfAdapter? = null
private var selectionController: SectionedSelectionController<ShelfSectionModel>? = null private var selectionController: SectionedSelectionController<ShelfSectionModel>? = null
@@ -60,6 +62,7 @@ class ShelfFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
nestedScrollStateHandle = NestedScrollStateHandle(savedInstanceState, KEY_NESTED_SCROLL)
val sizeResolver = ItemSizeResolver(resources, settings) val sizeResolver = ItemSizeResolver(resources, settings)
selectionController = SectionedSelectionController( selectionController = SectionedSelectionController(
activity = requireActivity(), activity = requireActivity(),
@@ -72,6 +75,7 @@ class ShelfFragment :
listener = this, listener = this,
sizeResolver = sizeResolver, sizeResolver = sizeResolver,
selectionController = checkNotNull(selectionController), selectionController = checkNotNull(selectionController),
nestedScrollStateHandle = checkNotNull(nestedScrollStateHandle),
) )
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
@@ -82,10 +86,16 @@ class ShelfFragment :
viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
nestedScrollStateHandle?.onSaveInstanceState(outState)
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
adapter = null adapter = null
selectionController = null selectionController = null
nestedScrollStateHandle = null
} }
override fun onItemClick(item: Manga, section: ShelfSectionModel, view: View) { override fun onItemClick(item: Manga, section: ShelfSectionModel, view: View) {
@@ -133,6 +143,8 @@ class ShelfFragment :
companion object { companion object {
private const val KEY_NESTED_SCROLL = "nested_scroll"
fun newInstance() = ShelfFragment() fun newInstance() = ShelfFragment()
} }
} }

View File

@@ -11,14 +11,16 @@ class ScrollKeepObserver(
get() = recyclerView.layoutManager as LinearLayoutManager get() = recyclerView.layoutManager as LinearLayoutManager
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
val position = minOf(toPosition, fromPosition) // if items are swapping positions may be swapped too val position = minOf(toPosition, fromPosition) // if items are swapping positions may be swapped too
if (position == 0 || position < layoutManager.findFirstVisibleItemPosition()) { if (firstVisiblePosition != RecyclerView.NO_POSITION && (position == 0 || position < firstVisiblePosition)) {
postScroll(position) postScroll(position)
} }
} }
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 || positionStart < layoutManager.findFirstVisibleItemPosition()) { val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION && (positionStart == 0 || positionStart < firstVisiblePosition)) {
postScroll(positionStart) postScroll(positionStart)
} }
} }

View File

@@ -6,6 +6,7 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.NestedScrollStateHandle
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.ItemSizeResolver
@@ -24,6 +25,7 @@ class ShelfAdapter(
listener: ShelfListEventListener, listener: ShelfListEventListener,
sizeResolver: ItemSizeResolver, sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<ShelfSectionModel>, selectionController: SectionedSelectionController<ShelfSectionModel>,
nestedScrollStateHandle: NestedScrollStateHandle,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()), FastScroller.SectionIndexer { ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()), FastScroller.SectionIndexer {
init { init {
@@ -37,6 +39,7 @@ class ShelfAdapter(
sizeResolver = sizeResolver, sizeResolver = sizeResolver,
selectionController = selectionController, selectionController = selectionController,
listener = listener, listener = listener,
nestedScrollStateHandle = nestedScrollStateHandle,
), ),
) )
.addDelegate(loadingStateAD()) .addDelegate(loadingStateAD())

View File

@@ -7,16 +7,17 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.NestedScrollStateHandle
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ItemListGroupBinding import org.koitharu.kotatsu.databinding.ItemListGroupBinding
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.utils.ext.removeItemDecoration import org.koitharu.kotatsu.utils.ext.removeItemDecoration
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
@@ -27,6 +28,7 @@ fun shelfGroupAD(
sizeResolver: ItemSizeResolver, sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<ShelfSectionModel>, selectionController: SectionedSelectionController<ShelfSectionModel>,
listener: ShelfListEventListener, listener: ShelfListEventListener,
nestedScrollStateHandle: NestedScrollStateHandle,
) = adapterDelegateViewBinding<ShelfSectionModel, ListModel, ItemListGroupBinding>( ) = adapterDelegateViewBinding<ShelfSectionModel, ListModel, ItemListGroupBinding>(
{ layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) },
) { ) {
@@ -48,21 +50,25 @@ fun shelfGroupAD(
MangaItemDiffCallback(), MangaItemDiffCallback(),
mangaGridItemAD(coil, lifecycleOwner, listenerAdapter, sizeResolver), mangaGridItemAD(coil, lifecycleOwner, listenerAdapter, sizeResolver),
) )
adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
adapter.registerAdapterDataObserver(ScrollKeepObserver(binding.recyclerView)) adapter.registerAdapterDataObserver(ScrollKeepObserver(binding.recyclerView))
binding.recyclerView.setRecycledViewPool(sharedPool) binding.recyclerView.setRecycledViewPool(sharedPool)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing)) val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
binding.recyclerView.addItemDecoration(spacingDecoration) binding.recyclerView.addItemDecoration(spacingDecoration)
binding.buttonMore.setOnClickListener(listenerAdapter) binding.buttonMore.setOnClickListener(listenerAdapter)
val stateController = nestedScrollStateHandle.attach(binding.recyclerView)
bind { bind {
selectionController.attachToRecyclerView(item, binding.recyclerView) selectionController.attachToRecyclerView(item, binding.recyclerView)
binding.textViewTitle.text = item.getTitle(context.resources) binding.textViewTitle.text = item.getTitle(context.resources)
binding.buttonMore.setTextAndVisible(item.showAllButtonText) binding.buttonMore.setTextAndVisible(item.showAllButtonText)
adapter.items = item.items adapter.items = item.items
stateController.onBind(bindingAdapterPosition)
} }
onViewRecycled { onViewRecycled {
stateController.onRecycled()
adapter.items = emptyList() adapter.items = emptyList()
binding.recyclerView.removeItemDecoration(AbstractSelectionItemDecoration::class.java) binding.recyclerView.removeItemDecoration(AbstractSelectionItemDecoration::class.java)
} }

View File

@@ -7,6 +7,8 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import androidx.core.content.IntentCompat
import androidx.core.os.BundleCompat
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
import java.io.Serializable import java.io.Serializable
@@ -14,11 +16,11 @@ import java.io.Serializable
// https://issuetracker.google.com/issues/240585930 // https://issuetracker.google.com/issues/240585930
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? { inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
return getParcelable(key) as T? return BundleCompat.getParcelable(this, key, T::class.java)
} }
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? { inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
return getParcelableExtra(key) as T? return IntentCompat.getParcelableExtra(this, key, T::class.java)
} }
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? { inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? {