Pages thumbnails on details screen
parent
2f2a5b868d
commit
5e6da9bb1c
@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.details.ui.ChaptersFragment
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
|
||||
|
||||
class DetailsPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity),
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
|
||||
override fun getItemCount(): Int = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> ChaptersFragment()
|
||||
1 -> PagesFragment()
|
||||
else -> throw IllegalArgumentException("Invalid position $position")
|
||||
}
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
tab.setText(
|
||||
when (position) {
|
||||
0 -> R.string.chapters
|
||||
1 -> R.string.pages
|
||||
else -> 0
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.plus
|
||||
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PagesFragment :
|
||||
BaseFragment<FragmentPagesBinding>(),
|
||||
OnListItemClickListener<PageThumbnail> {
|
||||
|
||||
private val detailsViewModel by activityViewModels<DetailsViewModel>()
|
||||
private val viewModel by viewModels<PagesViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var thumbnailsAdapter: PageThumbnailAdapter? = null
|
||||
private var spanResolver: MangaListSpanResolver? = null
|
||||
private var scrollListener: ScrollListener? = null
|
||||
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
private val listCommitCallback = Runnable {
|
||||
spanSizeLookup.invalidateCache()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
combine(
|
||||
detailsViewModel.details,
|
||||
detailsViewModel.history,
|
||||
detailsViewModel.selectedBranch,
|
||||
) { details, history, branch ->
|
||||
if (details?.isLoaded == true) {
|
||||
PagesViewModel.State(details, history, branch)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.observe(this, viewModel::updateState)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding {
|
||||
return FragmentPagesBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
spanResolver = MangaListSpanResolver(binding.root.resources)
|
||||
thumbnailsAdapter = PageThumbnailAdapter(
|
||||
coil = coil,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
clickListener = this@PagesFragment,
|
||||
)
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
adapter = thumbnailsAdapter
|
||||
addOnLayoutChangeListener(spanResolver)
|
||||
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
||||
addOnScrollListener(ScrollListener().also { scrollListener = it })
|
||||
(layoutManager as GridLayoutManager).let {
|
||||
it.spanSizeLookup = spanSizeLookup
|
||||
it.spanCount = checkNotNull(spanResolver).spanCount
|
||||
}
|
||||
}
|
||||
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
|
||||
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
|
||||
viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
spanResolver = null
|
||||
scrollListener = null
|
||||
thumbnailsAdapter = null
|
||||
spanSizeLookup.invalidateCache()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
override fun onItemClick(item: PageThumbnail, view: View) {
|
||||
val manga = detailsViewModel.manga.value ?: return
|
||||
val state = ReaderState(item.page.chapterId, item.page.index, 0)
|
||||
val intent = IntentBuilder(view.context).manga(manga).state(state).build()
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun onThumbnailsChanged(list: List<ListModel>) {
|
||||
val adapter = thumbnailsAdapter ?: return
|
||||
if (adapter.itemCount == 0) {
|
||||
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
||||
if (position > 0) {
|
||||
val spanCount = spanResolver?.spanCount ?: 0
|
||||
val offset = if (position > spanCount + 1) {
|
||||
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
|
||||
} else {
|
||||
position = 0
|
||||
0
|
||||
}
|
||||
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
|
||||
adapter.setItems(list, listCommitCallback + scrollCallback)
|
||||
} else {
|
||||
adapter.setItems(list, listCommitCallback)
|
||||
}
|
||||
} else {
|
||||
adapter.setItems(list, listCommitCallback)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ScrollListener : BoundsScrollListener(3, 3) {
|
||||
|
||||
override fun onScrolledToStart(recyclerView: RecyclerView) {
|
||||
viewModel.loadPrevChapter()
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd(recyclerView: RecyclerView) {
|
||||
viewModel.loadNextChapter()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
||||
|
||||
init {
|
||||
isSpanIndexCacheEnabled = true
|
||||
isSpanGroupIndexCacheEnabled = true
|
||||
}
|
||||
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
|
||||
return when (thumbnailsAdapter?.getItemViewType(position)) {
|
||||
ListItemType.PAGE_THUMB.ordinal -> 1
|
||||
else -> total
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateCache() {
|
||||
invalidateSpanGroupIndexCache()
|
||||
invalidateSpanIndexCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.firstNotNull
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PagesViewModel @Inject constructor(
|
||||
private val chaptersLoader: ChaptersLoader,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
private var loadingPrevJob: Job? = null
|
||||
private var loadingNextJob: Job? = null
|
||||
|
||||
private val state = MutableStateFlow<State?>(null)
|
||||
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
|
||||
val isLoadingUp = MutableStateFlow(false)
|
||||
val isLoadingDown = MutableStateFlow(false)
|
||||
|
||||
init {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
val firstState = state.firstNotNull()
|
||||
doInit(firstState)
|
||||
launchJob(Dispatchers.Default) {
|
||||
state.collectLatest {
|
||||
if (it != null) {
|
||||
doInit(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateState(newState: State?) {
|
||||
if (newState != null) {
|
||||
state.value = newState
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPrevChapter() {
|
||||
if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
loadingPrevJob = loadPrevNextChapter(isNext = false)
|
||||
}
|
||||
|
||||
fun loadNextChapter() {
|
||||
if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
loadingNextJob = loadPrevNextChapter(isNext = true)
|
||||
}
|
||||
|
||||
private suspend fun doInit(state: State) {
|
||||
chaptersLoader.init(state.details)
|
||||
val initialChapterId = state.history?.chapterId ?: state.details.allChapters.firstOrNull()?.id ?: return
|
||||
chaptersLoader.loadSingleChapter(initialChapterId)
|
||||
updateList(state.history)
|
||||
}
|
||||
|
||||
private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) {
|
||||
val indicator = if (isNext) isLoadingDown else isLoadingUp
|
||||
indicator.value = true
|
||||
try {
|
||||
val currentState = state.firstNotNull()
|
||||
val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId
|
||||
chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext)
|
||||
updateList(currentState.history)
|
||||
} finally {
|
||||
indicator.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateList(history: MangaHistory?) {
|
||||
val snapshot = chaptersLoader.snapshot()
|
||||
val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
|
||||
var previousChapterId = 0L
|
||||
for (page in snapshot) {
|
||||
if (page.chapterId != previousChapterId) {
|
||||
chaptersLoader.peekChapter(page.chapterId)?.let {
|
||||
add(ListHeader(it.name))
|
||||
}
|
||||
previousChapterId = page.chapterId
|
||||
}
|
||||
this += PageThumbnail(
|
||||
isCurrent = history?.let {
|
||||
page.chapterId == it.chapterId && page.index == it.page
|
||||
} ?: false,
|
||||
page = page,
|
||||
)
|
||||
}
|
||||
}
|
||||
thumbnails.value = pages
|
||||
}
|
||||
|
||||
data class State(
|
||||
val details: MangaDetails,
|
||||
val history: MangaHistory?,
|
||||
val branch: String?
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.list.fastscroll.FastScrollRecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
app:bubbleSize="small"
|
||||
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
|
||||
tools:listitem="@layout/item_page_thumb"
|
||||
tools:spanCount="3" />
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_holder"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="@dimen/margin_normal"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:layout_marginEnd="@dimen/margin_normal"
|
||||
android:layout_marginBottom="@dimen/margin_normal"
|
||||
android:gravity="center"
|
||||
android:text="@string/chapters_empty"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar_top"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:hideAnimationBehavior="outward"
|
||||
app:showAnimationBehavior="inward"
|
||||
app:trackCornerRadius="0dp"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar_bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:hideAnimationBehavior="inward"
|
||||
app:showAnimationBehavior="outward"
|
||||
app:trackCornerRadius="0dp"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</FrameLayout>
|
||||
Loading…
Reference in New Issue