Grid mode option for sources list

This commit is contained in:
Koitharu
2023-02-05 08:03:42 +02:00
parent 1daa02af52
commit 85d09dc48c
16 changed files with 241 additions and 28 deletions

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.base.ui.util
import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.parsers.util.toIntUp
import kotlin.math.abs
class SpanSizeResolver(
private val recyclerView: RecyclerView,
@Px private val minItemWidth: Int,
) : View.OnLayoutChangeListener {
fun attach() {
recyclerView.addOnLayoutChangeListener(this)
}
fun detach() {
recyclerView.removeOnLayoutChangeListener(this)
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
invalidateInternal(abs(right - left))
}
fun invalidate() {
invalidateInternal(recyclerView.width)
}
private fun invalidateInternal(width: Int) {
if (width <= 0) {
return
}
val lm = recyclerView.layoutManager as? GridLayoutManager ?: return
val estimatedCount = (width / minItemWidth.toFloat()).toIntUp()
if (lm.spanCount != estimatedCount) {
lm.spanCount = estimatedCount
lm.spanSizeLookup?.run {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
}
}

View File

@@ -205,6 +205,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -376,6 +380,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,8 +1,13 @@
package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlin.coroutines.CoroutineContext
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer()
@@ -33,3 +38,13 @@ fun <T> AppSettings.observeAsLiveData(
}
}
}
fun <T> AppSettings.observeAsStateFlow(
key: String,
scope: CoroutineScope,
valueProducer: AppSettings.() -> T,
): StateFlow<T> = observe().transform {
if (it == key) {
emit(valueProducer())
}
}.stateIn(scope, SharingStarted.Eagerly, valueProducer())

View File

@@ -9,6 +9,8 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
@@ -19,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.base.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -63,6 +66,7 @@ class ExploreFragment :
with(binding.recyclerView) {
adapter = exploreAdapter
setHasFixedSize(true)
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing
}
@@ -72,6 +76,7 @@ class ExploreFragment :
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
}
override fun onDestroyView() {
@@ -149,6 +154,16 @@ class ExploreFragment :
snackbar.show()
}
private fun onGridModeChanged(isGrid: Boolean) {
binding.recyclerView.layoutManager = if (isGrid) {
GridLayoutManager(requireContext(), 4).also { lm ->
lm.spanSizeLookup = ExploreGridSpanSizeLookup(checkNotNull(exploreAdapter), lm)
}
} else {
LinearLayoutManager(requireContext())
}
}
private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener {

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.explore.ui
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
class ExploreGridSpanSizeLookup(
private val adapter: ExploreAdapter,
private val layoutManager: GridLayoutManager,
) : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val itemType = adapter.getItemViewType(position)
return if (itemType == ExploreAdapter.ITEM_TYPE_SOURCE_GRID) 1 else layoutManager.spanCount
}
}

View File

@@ -5,22 +5,26 @@ import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@@ -30,8 +34,15 @@ class ExploreViewModel @Inject constructor(
private val exploreRepository: ExploreRepository,
) : BaseViewModel() {
private val gridMode = settings.observeAsStateFlow(
key = AppSettings.KEY_SOURCES_GRID,
scope = viewModelScope + Dispatchers.IO,
valueProducer = { isSourcesGridMode },
)
val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>()
val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
if (loading) {
@@ -67,16 +78,16 @@ class ExploreViewModel @Inject constructor(
.onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) }
.distinctUntilChanged()
.map { buildList(it) }
.combine(gridMode) { content, grid -> buildList(content, grid) }
private fun buildList(sources: List<MangaSource>): List<ExploreItem> {
private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> {
val result = ArrayList<ExploreItem>(sources.size + 3)
result += ExploreItem.Buttons(
isSuggestionsEnabled = settings.isSuggestionsEnabled,
)
result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty())
if (sources.isNotEmpty()) {
sources.mapTo(result) { ExploreItem.Source(it) }
sources.mapTo(result) { ExploreItem.Source(it, isGrid) }
} else {
result += ExploreItem.EmptyHint(
icon = R.drawable.ic_empty_common,

View File

@@ -11,11 +11,25 @@ class ExploreAdapter(
lifecycleOwner: LifecycleOwner,
listener: ExploreListEventListener,
clickListener: OnListItemClickListener<ExploreItem.Source>,
) : AsyncListDifferDelegationAdapter<ExploreItem>(
ExploreDiffCallback(),
exploreButtonsAD(listener),
exploreSourcesHeaderAD(listener),
exploreSourceItemAD(coil, clickListener, lifecycleOwner),
exploreEmptyHintListAD(listener),
exploreLoadingAD(),
)
) : AsyncListDifferDelegationAdapter<ExploreItem>(ExploreDiffCallback()) {
init {
delegatesManager
.addDelegate(ITEM_TYPE_BUTTONS, exploreButtonsAD(listener))
.addDelegate(ITEM_TYPE_HEADER, exploreSourcesHeaderAD(listener))
.addDelegate(ITEM_TYPE_SOURCE_LIST, exploreSourceListItemAD(coil, clickListener, lifecycleOwner))
.addDelegate(ITEM_TYPE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
.addDelegate(ITEM_TYPE_HINT, exploreEmptyHintListAD(listener))
.addDelegate(ITEM_TYPE_LOADING, exploreLoadingAD())
}
companion object {
const val ITEM_TYPE_BUTTONS = 0
const val ITEM_TYPE_HEADER = 1
const val ITEM_TYPE_SOURCE_LIST = 2
const val ITEM_TYPE_SOURCE_GRID = 3
const val ITEM_TYPE_HINT = 4
const val ITEM_TYPE_LOADING = 5
}
}

View File

@@ -12,7 +12,8 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun exploreButtonsAD(
clickListener: View.OnClickListener,
) = adapterDelegateViewBinding<ExploreItem.Buttons, ExploreItem, ItemExploreButtonsBinding>(
{ layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) }
{ layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) },
) {
binding.buttonBookmarks.setOnClickListener(clickListener)
@@ -43,7 +44,7 @@ fun exploreButtonsAD(
fun exploreSourcesHeaderAD(
listener: ExploreListEventListener,
) = adapterDelegateViewBinding<ExploreItem.Header, ExploreItem, ItemHeaderButtonBinding>(
{ layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) }
{ layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) },
) {
val listenerAdapter = View.OnClickListener {
@@ -58,13 +59,44 @@ fun exploreSourcesHeaderAD(
}
}
fun exploreSourceItemAD(
fun exploreSourceListItemAD(
coil: ImageLoader,
listener: OnListItemClickListener<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceBinding>(
{ layoutInflater, parent -> ItemExploreSourceBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source }
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceListBinding>(
{ layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source && !item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener)
bind {
binding.textViewTitle.text = item.source.title
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
binding.imageViewIcon.newImageRequest(item.source.faviconUri())?.run {
fallback(fallbackIcon)
placeholder(fallbackIcon)
error(fallbackIcon)
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
}
onViewRecycled {
binding.imageViewIcon.disposeImageRequest()
}
}
fun exploreSourceGridItemAD(
coil: ImageLoader,
listener: OnListItemClickListener<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceGridBinding>(
{ layoutInflater, parent -> ItemExploreSourceGridBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source && item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
@@ -92,7 +124,7 @@ fun exploreSourceItemAD(
fun exploreEmptyHintListAD(
listener: ListStateHolderListener,
) = adapterDelegateViewBinding<ExploreItem.EmptyHint, ExploreItem, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }

View File

@@ -12,11 +12,13 @@ class ExploreDiffCallback : DiffUtil.ItemCallback<ExploreItem>() {
oldItem is ExploreItem.Loading && newItem is ExploreItem.Loading -> true
oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true
oldItem is ExploreItem.Source && newItem is ExploreItem.Source -> {
oldItem.source == newItem.source
oldItem.source == newItem.source && oldItem.isGrid == newItem.isGrid
}
oldItem is ExploreItem.Header && newItem is ExploreItem.Header -> {
oldItem.titleResId == newItem.titleResId
}
else -> false
}
}
@@ -24,4 +26,4 @@ class ExploreDiffCallback : DiffUtil.ItemCallback<ExploreItem>() {
override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -54,6 +54,7 @@ sealed interface ExploreItem : ListModel {
class Source(
val source: MangaSource,
val isGrid: Boolean,
) : ExploreItem {
override fun equals(other: Any?): Boolean {
@@ -63,12 +64,15 @@ sealed interface ExploreItem : ListModel {
other as Source
if (source != other.source) return false
if (isGrid != other.isGrid) return false
return true
}
override fun hashCode(): Int {
return source.hashCode()
var result = source.hashCode()
result = 31 * result + isGrid.hashCode()
return result
}
}

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
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:id="@+id/recyclerView"
android:layout_width="match_parent"
@@ -9,9 +8,8 @@
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingRight="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingRight="@dimen/list_spacing"
android:paddingBottom="@dimen/grid_spacing_outer"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_explore_source" />
tools:listitem="@layout/item_explore_source_list" />

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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="wrap_content"
android:background="@drawable/list_selector"
android:clipChildren="false"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="@dimen/list_spacing"
tools:layout_width="120dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_icon"
android:layout_width="72dp"
android:layout_height="72dp"
android:background="?colorControlHighlight"
android:labelFor="@id/textView_title"
android:scaleType="fitCenter"
app:shapeAppearance="?shapeAppearanceCornerMedium"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_spacing"
android:elegantTextHeight="false"
android:ellipsize="end"
android:gravity="center_horizontal"
android:singleLine="true"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@tools:sample/lorem[2]" />
</LinearLayout>

View File

@@ -31,6 +31,7 @@
<dimen name="reading_progress_text_size">10dp</dimen>
<dimen name="reader_bar_inset_fallback">8dp</dimen>
<dimen name="scrobbling_list_spacing">12dp</dimen>
<dimen name="explore_grid_width">120dp</dimen>
<dimen name="search_suggestions_manga_height">124dp</dimen>
<dimen name="search_suggestions_manga_spacing">4dp</dimen>

View File

@@ -411,4 +411,5 @@
<string name="theme_name_dynamic">Dynamic</string>
<string name="color_theme">Color scheme</string>
<string name="theme_name_october">October</string>
<string name="show_in_grid_view">Show in grid view</string>
</resources>

View File

@@ -9,10 +9,16 @@
android:key="remote_sources"
android:title="@string/remote_sources" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="sources_grid"
android:title="@string/show_in_grid_view" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.SuggestionsSettingsFragment"
android:key="suggestions"
android:title="@string/suggestions" />
android:title="@string/suggestions"
app:allowDividerAbove="true" />
<ListPreference
android:entries="@array/doh_providers"