Enhance manga search suggestion

This commit is contained in:
Koitharu
2022-03-11 19:29:58 +02:00
parent abc2fb0e40
commit 25d52c5a61
17 changed files with 325 additions and 153 deletions

View File

@@ -59,6 +59,12 @@ class FilterCoordinator(
}
}
fun setTags(tags: Set<MangaTag>) {
currentState.update { oldValue ->
FilterState(oldValue.sortOrder, tags)
}
}
fun reset() {
currentState.update { oldValue ->
FilterState(oldValue.sortOrder, emptySet())

View File

@@ -110,6 +110,10 @@ class RemoteListViewModel(
fun resetFilter() = filter.reset()
fun applyFilter(tags: Set<MangaTag>) {
filter.setTags(tags)
}
private fun loadList(filterState: FilterState, append: Boolean) {
if (loadingJob?.isActive == true) {
return

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.databinding.ActivitySearchGlobalBinding
import org.koitharu.kotatsu.list.ui.filter.FilterState
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
@@ -63,7 +62,7 @@ class MangaListActivity : BaseActivity<ActivitySearchGlobalBinding>() {
val viewModel = fragment.getViewModel<RemoteListViewModel> {
parametersOf(tag.source)
}
viewModel.applyFilter(FilterState(viewModel.filter.sortOrder, setOf(tag)))
viewModel.applyFilter(setOf(tag))
}
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.search.ui.suggestion
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SEARCH_SUGGESTION_ITEM_TYPE_QUERY
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import org.koitharu.kotatsu.utils.ext.getItem
@@ -18,7 +18,7 @@ class SearchSuggestionItemCallback(
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
): Int = if (viewHolder.itemViewType == SearchSuggestionAdapter.ITEM_TYPE_QUERY) {
): Int = if (viewHolder.itemViewType == SEARCH_SUGGESTION_ITEM_TYPE_QUERY) {
movementFlags
} else {
0

View File

@@ -2,10 +2,8 @@ package org.koitharu.kotatsu.search.ui.suggestion
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.MangaSource
@@ -14,11 +12,9 @@ import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
private const val DEBOUNCE_TIMEOUT = 500L
private const val SEARCH_THRESHOLD = 3
private const val MAX_MANGA_ITEMS = 3
private const val MAX_MANGA_ITEMS = 6
private const val MAX_QUERY_ITEMS = 16
private const val MAX_TAGS_ITEMS = 8
private const val MAX_SUGGESTION_ITEMS = MAX_MANGA_ITEMS + MAX_QUERY_ITEMS + 2
class SearchSuggestionViewModel(
private val repository: MangaSearchRepository,
@@ -68,33 +64,49 @@ class SearchSuggestionViewModel(
private fun setupSuggestion() {
suggestionJob?.cancel()
suggestionJob = combine(
query
.debounce(DEBOUNCE_TIMEOUT)
.mapLatest { q ->
q to repository.getQuerySuggestion(q, MAX_QUERY_ITEMS)
},
query.debounce(DEBOUNCE_TIMEOUT),
source,
isLocalSearch
) { (q, queries), src, srcOnly ->
val result = ArrayList<SearchSuggestionItem>(MAX_SUGGESTION_ITEMS)
isLocalSearch,
::Triple,
).mapLatest { (searchQuery, src, srcOnly) ->
buildSearchSuggestion(searchQuery, src, srcOnly)
}.distinctUntilChanged()
.onEach {
suggestion.postValue(it)
}.launchIn(viewModelScope + Dispatchers.Default)
}
private suspend fun buildSearchSuggestion(
searchQuery: String,
src: MangaSource?,
srcOnly: Boolean,
): List<SearchSuggestionItem> = coroutineScope {
val queriesDeferred = async {
repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS)
}
val tagsDeferred = async {
repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, src.takeIf { srcOnly })
}
val mangaDeferred = async {
repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, src.takeIf { srcOnly })
}
val tags = tagsDeferred.await()
val mangaList = mangaDeferred.await()
val queries = queriesDeferred.await()
buildList(queries.size + 3) {
if (src != null) {
result += SearchSuggestionItem.Header(src, isLocalSearch)
add(SearchSuggestionItem.Header(src, isLocalSearch))
}
val tags = repository.getTagsSuggestion(q, MAX_TAGS_ITEMS, src.takeIf { srcOnly })
if (tags.isNotEmpty()) {
result.add(SearchSuggestionItem.Tags(mapTags(tags)))
add(SearchSuggestionItem.Tags(mapTags(tags)))
}
if (q.length >= SEARCH_THRESHOLD) {
repository.getMangaSuggestion(q, MAX_MANGA_ITEMS, src.takeIf { srcOnly })
.mapTo(result) {
SearchSuggestionItem.MangaItem(it)
}
if (mangaList.isNotEmpty()) {
add(SearchSuggestionItem.MangaList(mangaList))
}
queries.mapTo(result) { SearchSuggestionItem.RecentQuery(it) }
result
}.onEach {
suggestion.postValue(it)
}.launchIn(viewModelScope + Dispatchers.Default)
queries.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
}
}
private fun mapTags(tags: List<MangaTag>): List<ChipsView.ChipModel> = tags.map { tag ->

View File

@@ -8,6 +8,8 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import kotlin.jvm.internal.Intrinsics
const val SEARCH_SUGGESTION_ITEM_TYPE_QUERY = 0
class SearchSuggestionAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
@@ -15,10 +17,11 @@ class SearchSuggestionAdapter(
) : AsyncListDifferDelegationAdapter<SearchSuggestionItem>(DiffCallback()) {
init {
delegatesManager.addDelegate(ITEM_TYPE_MANGA, searchSuggestionMangaAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener))
.addDelegate(ITEM_TYPE_HEADER, searchSuggestionHeaderAD(listener))
.addDelegate(ITEM_TYPE_TAGS, searchSuggestionTagsAD(listener))
delegatesManager
.addDelegate(SEARCH_SUGGESTION_ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener))
.addDelegate(searchSuggestionHeaderAD(listener))
.addDelegate(searchSuggestionTagsAD(listener))
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
}
private class DiffCallback : DiffUtil.ItemCallback<SearchSuggestionItem>() {
@@ -27,15 +30,10 @@ class SearchSuggestionAdapter(
oldItem: SearchSuggestionItem,
newItem: SearchSuggestionItem,
): Boolean = when {
oldItem is SearchSuggestionItem.MangaItem && newItem is SearchSuggestionItem.MangaItem -> {
oldItem.manga.id == newItem.manga.id
}
oldItem is SearchSuggestionItem.RecentQuery && newItem is SearchSuggestionItem.RecentQuery -> {
oldItem.query == newItem.query
}
oldItem is SearchSuggestionItem.Header && newItem is SearchSuggestionItem.Header -> true
oldItem is SearchSuggestionItem.Tags && newItem is SearchSuggestionItem.Tags -> true
else -> false
else -> oldItem.javaClass == newItem.javaClass
}
override fun areContentsTheSame(
@@ -43,12 +41,4 @@ class SearchSuggestionAdapter(
newItem: SearchSuggestionItem,
): Boolean = Intrinsics.areEqual(oldItem, newItem)
}
companion object {
const val ITEM_TYPE_MANGA = 0
const val ITEM_TYPE_QUERY = 1
const val ITEM_TYPE_HEADER = 2
const val ITEM_TYPE_TAGS = 3
}
}

View File

@@ -1,46 +0,0 @@
package org.koitharu.kotatsu.search.ui.suggestion.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionMangaBinding
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun searchSuggestionMangaAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: SearchSuggestionListener,
) = adapterDelegateViewBinding<SearchSuggestionItem.MangaItem, SearchSuggestionItem, ItemSearchSuggestionMangaBinding>(
{ inflater, parent -> ItemSearchSuggestionMangaBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null
itemView.setOnClickListener {
listener.onMangaClick(item.manga)
}
bind {
imageRequest?.dispose()
imageRequest = binding.imageViewCover.newImageRequest(item.manga.coverUrl)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.allowRgb565(true)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
binding.textViewTitle.text = item.manga.title
binding.textViewSubtitle.textAndVisible = item.manga.altTitle
}
onViewRecycled {
imageRequest?.dispose()
binding.imageViewCover.setImageDrawable(null)
}
}

View File

@@ -0,0 +1,89 @@
package org.koitharu.kotatsu.search.ui.suggestion.adapter
import androidx.core.view.updatePadding
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import coil.request.Disposable
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionMangaGridBinding
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import org.koitharu.kotatsu.utils.ScrollResetCallback
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
fun searchSuggestionMangaListAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: SearchSuggestionListener,
) = adapterDelegate<SearchSuggestionItem.MangaList, SearchSuggestionItem>(R.layout.item_search_suggestion_manga_list) {
val adapter = AsyncListDifferDelegationAdapter(
SuggestionMangaDiffCallback(),
searchSuggestionMangaGridAD(coil, lifecycleOwner, listener),
)
val recyclerView = itemView as RecyclerView
recyclerView.adapter = adapter
val spacing = context.resources.getDimensionPixelOffset(R.dimen.search_suggestions_manga_spacing)
recyclerView.updatePadding(
left = recyclerView.paddingLeft - spacing,
right = recyclerView.paddingRight - spacing,
)
recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
val scrollResetCallback = ScrollResetCallback(recyclerView)
bind {
adapter.setItems(item.items, scrollResetCallback)
}
}
private fun searchSuggestionMangaGridAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: SearchSuggestionListener,
) = adapterDelegateViewBinding<Manga, Manga, ItemSearchSuggestionMangaGridBinding>(
{ layoutInflater, parent -> ItemSearchSuggestionMangaGridBinding.inflate(layoutInflater, parent, false) }
) {
var imageRequest: Disposable? = null
itemView.setOnClickListener {
listener.onMangaClick(item)
}
bind {
imageRequest?.dispose()
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.allowRgb565(true)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
binding.textViewTitle.text = item.title
}
onViewRecycled {
imageRequest?.dispose()
binding.imageViewCover.setImageDrawable(null)
}
}
private class SuggestionMangaDiffCallback : DiffUtil.ItemCallback<Manga>() {
override fun areItemsTheSame(oldItem: Manga, newItem: Manga): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Manga, newItem: Manga): Boolean {
return oldItem.title == newItem.title && oldItem.coverUrl == newItem.coverUrl
}
}

View File

@@ -4,23 +4,95 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.utils.ext.areItemsEquals
sealed interface SearchSuggestionItem {
data class MangaItem(
val manga: Manga,
) : SearchSuggestionItem
class MangaList(
val items: List<Manga>,
) : SearchSuggestionItem {
data class RecentQuery(
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaList
return items.areItemsEquals(other.items) { a, b ->
a.title == b.title && a.coverUrl == b.coverUrl
}
}
override fun hashCode(): Int {
return items.fold(0) { acc, t ->
var r = 31 * acc + t.title.hashCode()
r = 31 * r + t.coverUrl.hashCode()
r
}
}
}
class RecentQuery(
val query: String,
) : SearchSuggestionItem
) : SearchSuggestionItem {
data class Header(
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as RecentQuery
if (query != other.query) return false
return true
}
override fun hashCode(): Int {
return query.hashCode()
}
}
class Header(
val source: MangaSource,
val isChecked: MutableStateFlow<Boolean>,
) : SearchSuggestionItem
) : SearchSuggestionItem {
data class Tags(
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Header
if (source != other.source) return false
if (isChecked !== other.isChecked) return false
return true
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}
class Tags(
val tags: List<ChipsView.ChipModel>,
) : SearchSuggestionItem
) : SearchSuggestionItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Tags
if (tags != other.tags) return false
return true
}
override fun hashCode(): Int {
return tags.hashCode()
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.utils
import androidx.recyclerview.widget.RecyclerView
import java.lang.ref.WeakReference
class ScrollResetCallback(recyclerView: RecyclerView) : Runnable {
private val recyclerViewRef = WeakReference(recyclerView)
override fun run() {
recyclerViewRef.get()?.scrollToPosition(0)
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.utils.ext
import android.util.SparseArray
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import androidx.collection.LongSparseArray
@@ -82,4 +81,18 @@ fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
} else {
Collections.rotate(subList(targetIndex, sourceIndex + 1), 1)
}
}
inline fun <T> List<T>.areItemsEquals(other: List<T>, equals: (T, T) -> Boolean): Boolean {
if (size != other.size) {
return false
}
for (i in indices) {
val a = this[i]
val b = other[i]
if (!equals(a, b)) {
return false
}
}
return true
}