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