Use Compose for Library list and grid (#7520)

This commit is contained in:
Andreas
2022-07-16 21:06:24 +02:00
committed by GitHub
parent 018ca71336
commit 905c96922b
23 changed files with 855 additions and 896 deletions

View File

@@ -3,14 +3,34 @@ package eu.kanade.tachiyomi.ui.library
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.domain.category.model.Category
import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.library.components.LibraryComfortableGrid
import eu.kanade.presentation.library.components.LibraryCompactGrid
import eu.kanade.presentation.library.components.LibraryCoverOnlyGrid
import eu.kanade.presentation.library.components.LibraryList
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.LibraryCategoryBinding
import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@@ -21,13 +41,15 @@ import uy.kohesive.injekt.api.get
*/
class LibraryAdapter(
private val controller: LibraryController,
private val presenter: LibraryPresenter,
private val onClickManga: (LibraryManga) -> Unit,
private val preferences: PreferencesHelper = Injekt.get(),
) : RecyclerViewPagerAdapter() {
/**
* The categories to bind in the adapter.
*/
var categories: List<Category> = emptyList()
var categories: List<Category> = mutableStateListOf()
private set
/**
@@ -38,19 +60,6 @@ class LibraryAdapter(
private var boundViews = arrayListOf<View>()
private val isPerCategory by lazy { preferences.categorizedDisplaySettings().get() }
private var currentDisplayMode = preferences.libraryDisplayMode().get()
init {
preferences.libraryDisplayMode()
.asFlow()
.drop(1)
.onEach {
currentDisplayMode = it
}
.launchIn(controller.viewScope)
}
/**
* Pair of category and size of category
*/
@@ -80,10 +89,8 @@ class LibraryAdapter(
* @return a new view.
*/
override fun inflateView(container: ViewGroup, viewType: Int): View {
val binding = LibraryCategoryBinding.inflate(LayoutInflater.from(container.context), container, false)
val view: LibraryCategoryView = binding.root
view.onCreate(controller, binding, viewType)
return view
val binding = ComposeControllerBinding.inflate(LayoutInflater.from(container.context), container, false)
return binding.root
}
/**
@@ -93,7 +100,89 @@ class LibraryAdapter(
* @param position the position in the adapter.
*/
override fun bindView(view: View, position: Int) {
(view as LibraryCategoryView).onBind(categories[position])
(view as ComposeView).apply {
consumeWindowInsets = false
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
TachiyomiTheme {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val category = presenter.categories[position]
val displayMode = presenter.getDisplayMode(index = position)
val mangaList by presenter.getMangaForCategory(categoryId = category.id)
val onClickManga = { manga: LibraryManga ->
if (presenter.hasSelection().not()) {
onClickManga(manga)
} else {
presenter.toggleSelection(manga)
}
}
val onLongClickManga = { manga: LibraryManga ->
presenter.toggleSelection(manga)
}
SwipeRefresh(
modifier = Modifier.nestedScroll(nestedScrollInterop),
state = rememberSwipeRefreshState(isRefreshing = false),
onRefresh = {
if (LibraryUpdateService.start(context, category)) {
context.toast(R.string.updating_category)
}
},
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
when (displayMode) {
DisplayModeSetting.LIST -> {
LibraryList(
items = mangaList,
columns = presenter.columns,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = {
presenter.toggleSelection(it)
},
)
}
DisplayModeSetting.COMPACT_GRID -> {
LibraryCompactGrid(
items = mangaList,
columns = presenter.columns,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = onLongClickManga,
)
}
DisplayModeSetting.COMFORTABLE_GRID -> {
LibraryComfortableGrid(
items = mangaList,
columns = presenter.columns,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = onLongClickManga,
)
}
DisplayModeSetting.COVER_ONLY_GRID -> {
LibraryCoverOnlyGrid(
items = mangaList,
columns = presenter.columns,
selection = presenter.selection,
onClick = onClickManga,
onLongClick = onLongClickManga,
)
}
}
}
}
}
}
}
boundViews.add(view)
}
@@ -104,7 +193,6 @@ class LibraryAdapter(
* @param position the position in the adapter.
*/
override fun recycleView(view: View, position: Int) {
(view as LibraryCategoryView).onRecycle()
boundViews.remove(view)
}
@@ -131,45 +219,5 @@ class LibraryAdapter(
}
}
/**
* Returns the position of the view.
*/
override fun getItemPosition(obj: Any): Int {
val view = obj as? LibraryCategoryView ?: return POSITION_NONE
val index = categories.indexOfFirst { it.id == view.category.id }
return if (index == -1) POSITION_NONE else index
}
/**
* Called when the view of this adapter is being destroyed.
*/
fun onDestroy() {
for (view in boundViews) {
if (view is LibraryCategoryView) {
view.onDestroy()
}
}
}
override fun getViewType(position: Int): Int {
val category = categories.getOrNull(position)
return if (isPerCategory && category?.id != 0L) {
if (DisplayModeSetting.fromFlag(category?.displayMode) == DisplayModeSetting.LIST) {
LIST_DISPLAY_MODE
} else {
GRID_DISPLAY_MODE
}
} else {
if (currentDisplayMode == DisplayModeSetting.LIST) {
LIST_DISPLAY_MODE
} else {
GRID_DISPLAY_MODE
}
}
}
companion object {
const val LIST_DISPLAY_MODE = 1
const val GRID_DISPLAY_MODE = 2
}
override fun getViewType(position: Int): Int = -1
}

View File

@@ -1,44 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.manga.model.Manga
/**
* Adapter storing a list of manga in a certain category.
*
* @param view the fragment containing this adapter.
*/
class LibraryCategoryAdapter(view: LibraryCategoryView) :
FlexibleAdapter<LibraryItem>(null, view, true) {
/**
* The list of manga in this category.
*/
private var mangas: List<LibraryItem> = emptyList()
/**
* Sets a list of manga in the adapter.
*
* @param list the list to set.
*/
fun setItems(list: List<LibraryItem>) {
// A copy of manga always unfiltered.
mangas = list.toList()
performFilter()
}
/**
* Returns the position in the adapter for the given manga.
*
* @param manga the manga to find.
*/
fun indexOf(manga: Manga): Int {
return currentItems.indexOfFirst { it.manga.id == manga.id }
}
fun performFilter() {
val s = getFilter(String::class.java) ?: ""
updateDataSet(mangas.filter { it.filter(s) })
}
}

View File

@@ -1,328 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.databinding.LibraryCategoryBinding
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.lang.plusAssign
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.inflate
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import rx.subscriptions.CompositeSubscription
import java.util.ArrayDeque
/**
* Fragment containing the library manga for a certain category.
*/
class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FrameLayout(context, attrs),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener {
private val scope = MainScope()
/**
* The fragment containing this view.
*/
private lateinit var controller: LibraryController
/**
* Category for this view.
*/
lateinit var category: Category
private set
/**
* Recycler view of the list of manga.
*/
private lateinit var recycler: AutofitRecyclerView
/**
* Adapter to hold the manga in this category.
*/
private lateinit var adapter: LibraryCategoryAdapter
/**
* Subscriptions while the view is bound.
*/
private var subscriptions = CompositeSubscription()
private var lastClickPositionStack = ArrayDeque(listOf(-1))
fun onCreate(controller: LibraryController, binding: LibraryCategoryBinding, viewType: Int) {
this.controller = controller
recycler = if (viewType == LibraryAdapter.LIST_DISPLAY_MODE) {
(binding.swipeRefresh.inflate(R.layout.library_list_recycler) as AutofitRecyclerView).apply {
spanCount = 1
}
} else {
(binding.swipeRefresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
spanCount = controller.mangaPerRow
}
}
recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = LibraryCategoryAdapter(this)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
binding.swipeRefresh.addView(recycler)
adapter.fastScroller = binding.fastScroller
recycler.scrollStateChanges()
.onEach {
// Disable swipe refresh when view is not at the top
val firstPos = (recycler.layoutManager as LinearLayoutManager)
.findFirstCompletelyVisibleItemPosition()
binding.swipeRefresh.isEnabled = firstPos <= 0
}
.launchIn(scope)
recycler.onAnimationsFinished {
(controller.activity as? MainActivity)?.ready = true
}
// Double the distance required to trigger sync
binding.swipeRefresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
binding.swipeRefresh.refreshes()
.onEach {
if (LibraryUpdateService.start(context, category)) {
context.toast(R.string.updating_category)
}
// It can be a very long operation, so we disable swipe refresh and show a toast.
binding.swipeRefresh.isRefreshing = false
}
.launchIn(scope)
}
fun onBind(category: Category) {
this.category = category
adapter.mode = if (controller.selectedMangas.isNotEmpty()) {
SelectableAdapter.Mode.MULTI
} else {
SelectableAdapter.Mode.SINGLE
}
subscriptions += controller.searchRelay
.doOnNext { adapter.setFilter(it) }
.skip(1)
.subscribe { adapter.performFilter() }
subscriptions += controller.libraryMangaRelay
.subscribe { onNextLibraryManga(it) }
subscriptions += controller.selectionRelay
.subscribe { onSelectionChanged(it) }
subscriptions += controller.selectAllRelay
.filter { it == category.id }
.subscribe {
adapter.currentItems.forEach { item ->
controller.setSelection(item.manga.toDomainManga()!!, true)
}
controller.invalidateActionMode()
}
subscriptions += controller.selectInverseRelay
.filter { it == category.id }
.subscribe {
adapter.currentItems.forEach { item ->
controller.toggleSelection(item.manga.toDomainManga()!!)
}
controller.invalidateActionMode()
}
}
fun onRecycle() {
adapter.setItems(emptyList())
adapter.clearSelection()
unsubscribe()
}
fun onDestroy() {
unsubscribe()
scope.cancel()
}
private fun unsubscribe() {
subscriptions.clear()
}
/**
* Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the
* adapter.
*
* @param event the event received.
*/
private fun onNextLibraryManga(event: LibraryMangaEvent) {
// Get the manga list for this category.
val mangaForCategory = event.getMangaForCategory(category).orEmpty()
// Update the category with its manga.
adapter.setItems(mangaForCategory)
if (adapter.mode == SelectableAdapter.Mode.MULTI) {
controller.selectedMangas.forEach { manga ->
val position = adapter.indexOf(manga)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id) as? LibraryHolder<*>)?.toggleActivation()
}
}
}
}
/**
* Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection
* depending on the type of event received.
*
* @param event the selection event received.
*/
private fun onSelectionChanged(event: LibrarySelectionEvent) {
when (event) {
is LibrarySelectionEvent.Selected -> {
if (adapter.mode != SelectableAdapter.Mode.MULTI) {
adapter.mode = SelectableAdapter.Mode.MULTI
}
findAndToggleSelection(event.manga)
}
is LibrarySelectionEvent.Unselected -> {
findAndToggleSelection(event.manga)
with(adapter.indexOf(event.manga)) {
if (this != -1) lastClickPositionStack.remove(this)
}
if (controller.selectedMangas.isEmpty()) {
adapter.mode = SelectableAdapter.Mode.SINGLE
}
}
is LibrarySelectionEvent.Cleared -> {
adapter.mode = SelectableAdapter.Mode.SINGLE
adapter.clearSelection()
lastClickPositionStack.clear()
lastClickPositionStack.push(-1)
}
}
}
/**
* Toggles the selection for the given manga and updates the view if needed.
*
* @param manga the manga to toggle.
*/
private fun findAndToggleSelection(manga: Manga) {
val position = adapter.indexOf(manga)
if (position != -1) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id) as? LibraryHolder<*>)?.toggleActivation()
}
}
/**
* Called when a manga is clicked.
*
* @param position the position of the element clicked.
* @return true if the item should be selected, false otherwise.
*/
override fun onItemClick(view: View?, position: Int): Boolean {
// If the action mode is created and the position is valid, toggle the selection.
val item = adapter.getItem(position) ?: return false
return if (adapter.mode == SelectableAdapter.Mode.MULTI) {
if (adapter.isSelected(position)) {
lastClickPositionStack.remove(position)
} else {
lastClickPositionStack.push(position)
}
toggleSelection(position)
true
} else {
openManga(item.manga.toDomainManga()!!)
false
}
}
/**
* Called when a manga is long clicked.
*
* @param position the position of the element clicked.
*/
override fun onItemLongClick(position: Int) {
controller.createActionModeIfNeeded()
val lastClickPosition = lastClickPositionStack.peek()!!
when {
lastClickPosition == -1 -> setSelection(position)
lastClickPosition > position ->
for (i in position until lastClickPosition)
setSelection(i)
lastClickPosition < position ->
for (i in lastClickPosition + 1..position)
setSelection(i)
else -> setSelection(position)
}
if (lastClickPosition != position) {
lastClickPositionStack.remove(position)
lastClickPositionStack.push(position)
}
}
/**
* Opens a manga.
*
* @param manga the manga to open.
*/
private fun openManga(manga: Manga) {
controller.openManga(manga)
}
/**
* Tells the presenter to toggle the selection for the given position.
*
* @param position the position to toggle.
*/
private fun toggleSelection(position: Int) {
val item = adapter.getItem(position) ?: return
controller.setSelection(item.manga.toDomainManga()!!, !adapter.isSelected(position))
controller.invalidateActionMode()
}
/**
* Tells the presenter to set the selection for the given position.
*
* @param position the position to toggle.
*/
private fun setSelection(position: Int) {
val item = adapter.getItem(position) ?: return
controller.setSelection(item.manga.toDomainManga()!!, true)
controller.invalidateActionMode()
}
}

View File

@@ -1,61 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "item_source_grid" are available in this class.
*
* @param binding the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryComfortableGridHolder(
override val binding: SourceComfortableGridItemBinding,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
) : LibraryHolder<SourceComfortableGridItemBinding>(binding.root, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
binding.title.text = item.manga.title
// For rounded corners
binding.badges.leftBadges.clipToOutline = true
binding.badges.rightBadges.clipToOutline = true
// Update the unread count and its visibility.
with(binding.badges.unreadText) {
isVisible = item.unreadCount > 0
text = item.unreadCount.toString()
}
// Update the download count and its visibility.
with(binding.badges.downloadText) {
isVisible = item.downloadCount > 0
text = item.downloadCount.toString()
}
// Update the source language and its visibility
with(binding.badges.languageText) {
isVisible = item.sourceLanguage.isNotEmpty()
text = item.sourceLanguage
}
// set local visibility if its local manga
binding.badges.localText.isVisible = item.isLocal
// Update the cover.
binding.thumbnail.dispose()
binding.thumbnail.loadAutoPause(item.manga)
}
}

View File

@@ -1,72 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import androidx.core.view.isVisible
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "source_compact_grid_item" are available in this class.
*
* @param binding the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param coverOnly true if title should be hidden a.k.a cover only mode.
* @constructor creates a new library holder.
*/
class LibraryCompactGridHolder(
override val binding: SourceCompactGridItemBinding,
adapter: FlexibleAdapter<*>,
private val coverOnly: Boolean,
) : LibraryHolder<SourceCompactGridItemBinding>(binding.root, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
binding.title.text = item.manga.title
// For rounded corners
binding.badges.leftBadges.clipToOutline = true
binding.badges.rightBadges.clipToOutline = true
// Update the unread count and its visibility.
with(binding.badges.unreadText) {
isVisible = item.unreadCount > 0
text = item.unreadCount.toString()
}
// Update the download count and its visibility.
with(binding.badges.downloadText) {
isVisible = item.downloadCount > 0
text = item.downloadCount.toString()
}
// Update the source language and its visibility
with(binding.badges.languageText) {
isVisible = item.sourceLanguage.isNotEmpty()
text = item.sourceLanguage
}
// set local visibility if its local manga
binding.badges.localText.isVisible = item.isLocal
// Update the cover.
binding.thumbnail.dispose()
if (coverOnly) {
// Cover only mode: Hides title text unless thumbnail is unavailable
if (!item.manga.thumbnail_url.isNullOrEmpty()) {
binding.thumbnail.loadAutoPause(item.manga)
binding.title.isVisible = false
} else {
binding.title.text = item.manga.title
binding.title.isVisible = true
}
binding.thumbnail.foreground = null
} else {
binding.thumbnail.loadAutoPause(item.manga)
}
}
}

View File

@@ -14,16 +14,15 @@ import com.bluelinelabs.conductor.ControllerChangeType
import com.fredporciuncula.flow.preferences.Preference
import com.google.android.material.tabs.TabLayout
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.toDbCategory
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.LibraryControllerBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
@@ -33,7 +32,6 @@ import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.preference.asImmediateFlow
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
@@ -73,42 +71,11 @@ class LibraryController(
*/
private var actionMode: ActionModeWithToolbar? = null
/**
* Currently selected mangas.
*/
val selectedMangas = mutableSetOf<Manga>()
/**
* Relay to notify the UI of selection updates.
*/
val selectionRelay: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
/**
* Relay to notify search query changes.
*/
val searchRelay: BehaviorRelay<String> = BehaviorRelay.create()
/**
* Relay to notify the library's viewpager for updates.
*/
val libraryMangaRelay: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
/**
* Relay to notify the library's viewpager to select all manga
*/
val selectAllRelay: PublishRelay<Long> = PublishRelay.create()
/**
* Relay to notify the library's viewpager to select the inverse
*/
val selectInverseRelay: PublishRelay<Long> = PublishRelay.create()
/**
* Number of manga per row in grid mode.
*/
var mangaPerRow = 0
private set
/**
* Adapter of the view pager.
*/
@@ -174,7 +141,19 @@ class LibraryController(
override fun onViewCreated(view: View) {
super.onViewCreated(view)
adapter = LibraryAdapter(this)
adapter = LibraryAdapter(
controller = this,
presenter = presenter,
onClickManga = {
openManga(it.id!!)
},
)
getColumnsPreferenceForCurrentOrientation()
.asFlow()
.onEach { presenter.columns = it }
.launchIn(viewScope)
binding.libraryPager.adapter = adapter
binding.libraryPager.pageSelections()
.drop(1)
@@ -185,13 +164,7 @@ class LibraryController(
}
.launchIn(viewScope)
getColumnsPreferenceForCurrentOrientation().asImmediateFlow { mangaPerRow = it }
.drop(1)
// Set again the adapter to recalculate the covers height
.onEach { reattachAdapter() }
.launchIn(viewScope)
if (selectedMangas.isNotEmpty()) {
if (adapter!!.categories.isNotEmpty()) {
createActionModeIfNeeded()
}
@@ -219,6 +192,14 @@ class LibraryController(
.launchIn(viewScope)
}
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
preferences.portraitColumns()
} else {
preferences.landscapeColumns()
}
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
@@ -229,7 +210,6 @@ class LibraryController(
override fun onDestroyView(view: View) {
destroyActionModeIfNeeded()
adapter?.onDestroy()
adapter = null
settingsSheet?.sheetScope?.cancel()
settingsSheet = null
@@ -313,6 +293,12 @@ class LibraryController(
}
}
presenter.loadedManga.clear()
mangaMap.forEach {
presenter.loadedManga[it.key] = it.value
}
presenter.loadedMangaFlow.value = presenter.loadedManga
// Send the manga map to child fragments after the adapter is updated.
libraryMangaRelay.call(LibraryMangaEvent(mangaMap))
@@ -320,19 +306,6 @@ class LibraryController(
updateTitle()
}
/**
* Returns a preference for the number of manga per row based on the current orientation.
*
* @return the preference.
*/
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
preferences.portraitColumns()
} else {
preferences.landscapeColumns()
}
}
private fun onFilterChanged() {
presenter.requestFilterUpdate()
activity?.invalidateOptionsMenu()
@@ -400,7 +373,6 @@ class LibraryController(
}
private fun performSearch() {
searchRelay.call(presenter.query)
if (presenter.query.isNotEmpty()) {
binding.btnGlobalSearch.isVisible = true
binding.btnGlobalSearch.text =
@@ -455,7 +427,7 @@ class LibraryController(
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = selectedMangas.size
val count = presenter.selection.size
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
@@ -466,9 +438,9 @@ class LibraryController(
}
override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) {
if (selectedMangas.isEmpty()) return
if (presenter.hasSelection().not()) return
toolbar.findToolbarItem(R.id.action_download_unread)?.isVisible =
selectedMangas.any { it.source != LocalSource.ID }
presenter.selection.any { presenter.loadedManga.values.any { it.any { it.isLocal } } }
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
@@ -487,50 +459,18 @@ class LibraryController(
override fun onDestroyActionMode(mode: ActionMode) {
// Clear all the manga selections and notify child views.
selectedMangas.clear()
selectionRelay.call(LibrarySelectionEvent.Cleared)
presenter.clearSelection()
(activity as? MainActivity)?.showBottomNav(true)
actionMode = null
}
fun openManga(manga: Manga) {
fun openManga(mangaId: Long) {
// Notify the presenter a manga is being opened.
presenter.onOpenManga()
router.pushController(MangaController(manga.id))
}
/**
* Sets the selection for a given manga.
*
* @param manga the manga whose selection has changed.
* @param selected whether it's now selected or not.
*/
fun setSelection(manga: Manga, selected: Boolean) {
if (selected) {
if (selectedMangas.add(manga)) {
selectionRelay.call(LibrarySelectionEvent.Selected(manga))
}
} else {
if (selectedMangas.remove(manga)) {
selectionRelay.call(LibrarySelectionEvent.Unselected(manga))
}
}
}
/**
* Toggles the current selection state for a given manga.
*
* @param manga the manga whose selection to change.
*/
fun toggleSelection(manga: Manga) {
if (selectedMangas.add(manga)) {
selectionRelay.call(LibrarySelectionEvent.Selected(manga))
} else if (selectedMangas.remove(manga)) {
selectionRelay.call(LibrarySelectionEvent.Unselected(manga))
}
router.pushController(MangaController(mangaId))
}
/**
@@ -538,8 +478,7 @@ class LibraryController(
* invalidate the action mode to revert the top toolbar
*/
fun clearSelection() {
selectedMangas.clear()
selectionRelay.call(LibrarySelectionEvent.Cleared)
presenter.clearSelection()
invalidateActionMode()
}
@@ -549,15 +488,15 @@ class LibraryController(
private fun showMangaCategoriesDialog() {
viewScope.launchIO {
// Create a copy of selected manga
val mangas = selectedMangas.toList()
val mangas = presenter.selection.toList()
// Hide the default category because it has a different behavior than the ones from db.
val categories = presenter.categories.filter { it.id != 0L }
// Get indexes of the common categories to preselect.
val common = presenter.getCommonCategories(mangas)
val common = presenter.getCommonCategories(mangas.mapNotNull { it.toDomainManga() })
// Get indexes of the mix categories to preselect.
val mix = presenter.getMixCategories(mangas)
val mix = presenter.getMixCategories(mangas.mapNotNull { it.toDomainManga() })
val preselected = categories.map {
when (it) {
in common -> QuadStateTextView.State.CHECKED.ordinal
@@ -566,26 +505,27 @@ class LibraryController(
}
}.toTypedArray()
launchUI {
ChangeMangaCategoriesDialog(this@LibraryController, mangas, categories, preselected)
ChangeMangaCategoriesDialog(this@LibraryController, mangas.mapNotNull { it.toDomainManga() }, categories, preselected)
.showDialog(router)
}
}
}
private fun downloadUnreadChapters() {
val mangas = selectedMangas.toList()
presenter.downloadUnreadChapters(mangas)
val mangas = presenter.selection.toList()
presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() })
destroyActionModeIfNeeded()
}
private fun markReadStatus(read: Boolean) {
val mangas = selectedMangas.toList()
presenter.markReadStatus(mangas, read)
val mangas = presenter.selection.toList()
presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read)
destroyActionModeIfNeeded()
}
private fun showDeleteMangaDialog() {
DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router)
val mangas = presenter.selection.toList()
DeleteLibraryMangasDialog(this, mangas.mapNotNull { it.toDomainManga() }).showDialog(router)
}
override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
@@ -599,21 +539,18 @@ class LibraryController(
}
private fun selectAllCategoryManga() {
adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.id?.let {
selectAllRelay.call(it)
}
presenter.selectAll(binding.libraryPager.currentItem)
}
private fun selectInverseCategoryManga() {
adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.id?.let {
selectInverseRelay.call(it)
}
presenter.invertSelection(binding.libraryPager.currentItem)
}
override fun onSearchViewQueryTextChange(newText: String?) {
// Ignore events if this controller isn't at the top to avoid query being reset
if (router.backstack.lastOrNull()?.controller == this) {
presenter.query = newText ?: ""
presenter.searchQuery = newText ?: ""
performSearch()
}
}

View File

@@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import androidx.viewbinding.ViewBinding
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
/**
* Generic class used to hold the displayed data of a manga in the library.
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to the single tap and long tap events.
*/
abstract class LibraryHolder<VB : ViewBinding>(
view: View,
adapter: FlexibleAdapter<*>,
) : FlexibleViewHolder(view, adapter) {
abstract val binding: VB
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
abstract fun onSetValues(item: LibraryItem)
}

View File

@@ -1,27 +1,13 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.fredporciuncula.flow.preferences.Preference
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class LibraryItem(
val manga: LibraryManga,
private val shouldSetFromCategory: Preference<Boolean>,
private val defaultLibraryDisplayMode: Preference<DisplayModeSetting>,
) :
AbstractFlexibleItem<LibraryHolder<*>>(), IFilterable<String> {
) {
private val sourceManager: SourceManager = Injekt.get()
@@ -31,55 +17,13 @@ class LibraryItem(
var isLocal = false
var sourceLanguage = ""
private fun getDisplayMode(): DisplayModeSetting {
return if (shouldSetFromCategory.get() && manga.category != 0) {
DisplayModeSetting.fromFlag(displayMode)
} else {
defaultLibraryDisplayMode.get()
}
}
override fun getLayoutRes(): Int {
return when (getDisplayMode()) {
DisplayModeSetting.COMPACT_GRID, DisplayModeSetting.COVER_ONLY_GRID -> R.layout.source_compact_grid_item
DisplayModeSetting.COMFORTABLE_GRID -> R.layout.source_comfortable_grid_item
DisplayModeSetting.LIST -> R.layout.source_list_item
}
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): LibraryHolder<*> {
return when (getDisplayMode()) {
DisplayModeSetting.COMPACT_GRID -> {
LibraryCompactGridHolder(SourceCompactGridItemBinding.bind(view), adapter, coverOnly = false)
}
DisplayModeSetting.COVER_ONLY_GRID -> {
LibraryCompactGridHolder(SourceCompactGridItemBinding.bind(view), adapter, coverOnly = true)
}
DisplayModeSetting.COMFORTABLE_GRID -> {
LibraryComfortableGridHolder(SourceComfortableGridItemBinding.bind(view), adapter)
}
DisplayModeSetting.LIST -> {
LibraryListHolder(view, adapter)
}
}
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: LibraryHolder<*>,
position: Int,
payloads: List<Any?>?,
) {
holder.onSetValues(this)
}
/**
* Filters a manga depending on a query.
*
* @param constraint the query to apply.
* @return true if the manga should be included, false otherwise.
*/
override fun filter(constraint: String): Boolean {
fun filter(constraint: String): Boolean {
val sourceName by lazy { sourceManager.getOrStub(manga.source).name }
val genres by lazy { manga.getGenres() }
return manga.title.contains(constraint, true) ||

View File

@@ -1,67 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import coil.load
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.databinding.SourceListItemBinding
/**
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
* All the elements from the layout file "item_library_list" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryListHolder(
private val view: View,
private val adapter: FlexibleAdapter<*>,
) : LibraryHolder<SourceListItemBinding>(view, adapter) {
override val binding = SourceListItemBinding.bind(view)
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param item the manga item to bind.
*/
override fun onSetValues(item: LibraryItem) {
// Update the title of the manga.
binding.title.text = item.manga.title
// For rounded corners
binding.badges.clipToOutline = true
// Update the unread count and its visibility.
with(binding.unreadText) {
isVisible = item.unreadCount > 0
text = item.unreadCount.toString()
}
// Update the download count and its visibility.
with(binding.downloadText) {
isVisible = item.downloadCount > 0
text = "${item.downloadCount}"
}
// Update the source language and its visibility
with(binding.languageText) {
isVisible = item.sourceLanguage.isNotEmpty()
text = item.sourceLanguage
}
// show local text badge if local manga
binding.localText.isVisible = item.isLocal
// Create thumbnail onclick to simulate long click
binding.thumbnail.setOnClickListener {
// Simulate long click on this view to enter selection mode
onLongClick(itemView)
}
// Update the cover
binding.thumbnail.dispose()
binding.thumbnail.load(item.manga)
}
}

View File

@@ -1,6 +1,15 @@
package eu.kanade.tachiyomi.ui.library
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.util.fastAny
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.core.util.asObservable
import eu.kanade.data.DatabaseHandler
@@ -18,6 +27,7 @@ import eu.kanade.domain.manga.model.MangaUpdate
import eu.kanade.domain.manga.model.isLocal
import eu.kanade.domain.track.interactor.GetTracks
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@@ -26,6 +36,7 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
import eu.kanade.tachiyomi.util.lang.combineLatest
@@ -33,6 +44,12 @@ import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import rx.Observable
import rx.Subscription
@@ -80,9 +97,24 @@ class LibraryPresenter(
/**
* Categories of the library.
*/
var categories: List<Category> = emptyList()
var categories: List<Category> = mutableStateListOf()
private set
var loadedManga = mutableStateMapOf<Long, List<LibraryItem>>()
private set
val loadedMangaFlow = MutableStateFlow(loadedManga)
var searchQuery by mutableStateOf(query)
val selection: MutableList<LibraryManga> = mutableStateListOf()
val isPerCategory by mutableStateOf(preferences.categorizedDisplaySettings().get())
var columns by mutableStateOf(0)
var currentDisplayMode by mutableStateOf(preferences.libraryDisplayMode().get())
/**
* Relay used to apply the UI filters to the last emission of the library.
*/
@@ -105,6 +137,14 @@ class LibraryPresenter(
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
preferences.libraryDisplayMode()
.asFlow()
.drop(1)
.onEach {
currentDisplayMode = it
}
.launchIn(presenterScope)
subscribeLibrary()
}
@@ -416,11 +456,7 @@ class LibraryPresenter(
.map { list ->
list.map { libraryManga ->
// Display mode based on user preference: take it from global library setting or category
LibraryItem(
libraryManga,
shouldSetFromCategory,
defaultLibraryDisplayMode,
)
LibraryItem(libraryManga)
}.groupBy { it.manga.category.toLong() }
}
}
@@ -592,4 +628,68 @@ class LibraryPresenter(
}
}
}
@Composable
fun getMangaForCategory(categoryId: Long): androidx.compose.runtime.State<List<LibraryItem>> {
val unfiltered = loadedManga[categoryId] ?: emptyList()
return derivedStateOf {
val query = searchQuery
if (query.isNotBlank()) {
unfiltered.filter {
it.filter(query)
}
} else {
unfiltered
}
}
}
@Composable
fun getDisplayMode(index: Int): DisplayModeSetting {
val category = categories[index]
return remember {
if (isPerCategory.not() || category.id == 0L) {
currentDisplayMode
} else {
DisplayModeSetting.fromFlag(category.displayMode)
}
}
}
fun hasSelection(): Boolean {
return selection.isNotEmpty()
}
fun clearSelection() {
selection.clear()
}
fun toggleSelection(manga: LibraryManga) {
if (selection.fastAny { it.id == manga.id }) {
selection.remove(manga)
} else {
selection.add(manga)
}
view?.invalidateActionMode()
view?.createActionModeIfNeeded()
}
fun selectAll(index: Int) {
val category = categories[index]
val items = loadedManga[category.id] ?: emptyList()
selection.addAll(items.filterNot { it.manga in selection }.map { it.manga })
view?.createActionModeIfNeeded()
view?.invalidateActionMode()
}
fun invertSelection(index: Int) {
val category = categories[index]
val items = (loadedManga[category.id] ?: emptyList()).map { it.manga }
val invert = items.filterNot { it in selection }
selection.removeAll(items)
selection.addAll(invert)
view?.createActionModeIfNeeded()
view?.invalidateActionMode()
}
}

View File

@@ -1,9 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import eu.kanade.domain.manga.model.Manga
sealed class LibrarySelectionEvent {
class Selected(val manga: Manga) : LibrarySelectionEvent()
class Unselected(val manga: Manga) : LibrarySelectionEvent()
object Cleared : LibrarySelectionEvent()
}