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
}

View File

@@ -42,6 +42,7 @@
android:background="@null"
android:focusable="true"
android:focusableInTouchMode="true"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
app:contentInsetStartWithNavigation="0dp"
app:titleTextAppearance="@style/TextAppearance.Kotatsu.PersistentToolbarTitle"
app:titleTextColor="?android:colorControlNormal"
@@ -52,6 +53,7 @@
style="@style/Widget.Kotatsu.SearchView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="2dp"
android:background="@null"
android:gravity="center_vertical"
android:hint="@string/search_manga"
@@ -59,7 +61,7 @@
android:importantForAutofill="no"
android:paddingBottom="1dp"
android:singleLine="true"
tools:drawableEnd="@drawable/ic_clear" />
tools:drawableEnd="@drawable/abc_ic_clear_material" />
</com.google.android.material.appbar.MaterialToolbar>

View File

@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:gravity="center_vertical"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingTop="2dp"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:paddingBottom="2dp">
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
android:id="@+id/imageView_cover"
android:layout_width="42dp"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@tools:sample/lorem[6]" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?android:textColorSecondary"
tools:text="@tools:sample/lorem[6]" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.CardView.Outlined"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:contentPadding="4dp"
tools:layout_height="@dimen/search_suggestions_manga_height">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical">
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
android:id="@+id/imageView_cover"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
android:orientation="vertical"
android:scaleType="centerCrop"
tools:src="@tools:sample/backgrounds/scenic" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elegantTextHeight="false"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?attr/textAppearanceLabelSmall"
tools:text="@tools:sample/lorem[6]" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:scrollbars="none"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_search_suggestion_manga_grid"
android:layout_height="@dimen/search_suggestions_manga_height" />

View File

@@ -19,6 +19,8 @@
<dimen name="list_footer_height_outer">48dp</dimen>
<dimen name="screen_padding">16dp</dimen>
<dimen name="search_suggestions_manga_height">124dp</dimen>
<dimen name="search_suggestions_manga_spacing">4dp</dimen>
<!--Text dimens-->
</resources>

View File

@@ -1,4 +1,4 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!--Toolbars-->
@@ -76,6 +76,10 @@
<item name="elevationOverlayEnabled">@bool/elevation_overlay_enabled</item>
</style>
<style name="ThemeOverlay.Kotatsu.MainToolbar" parent="">
<item name="colorControlHighlight">@color/selector_overlay</item>
</style>
<!-- TextAppearance -->
<style name="TextAppearance.Widget.Menu" parent="TextAppearance.AppCompat.Menu">
@@ -100,6 +104,10 @@
<item name="cornerSize">12dp</item>
</style>
<style name="ShapeAppearanceOverlay.Kotatsu.Cover.Small" parent="">
<item name="cornerSize">6dp</item>
</style>
<!--Preferences-->
<style name="PreferenceThemeOverlay.Kotatsu">