Skip to content

Commit db46dbd

Browse files
author
Axel
authored
Group search result into sections (#441)
1 parent 7242bd8 commit db46dbd

File tree

6 files changed

+197
-38
lines changed

6 files changed

+197
-38
lines changed

test-app/r2-testapp/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ import kotlinx.coroutines.launch
1818
import org.readium.r2.shared.Search
1919
import org.readium.r2.shared.UserException
2020
import org.readium.r2.shared.publication.Locator
21+
import org.readium.r2.shared.publication.LocatorCollection
2122
import org.readium.r2.shared.publication.Publication
2223
import org.readium.r2.shared.publication.services.search.SearchIterator
24+
import org.readium.r2.shared.publication.services.search.SearchTry
2325
import org.readium.r2.shared.publication.services.search.search
26+
import org.readium.r2.shared.util.Try
2427
import org.readium.r2.testapp.bookshelf.BookRepository
2528
import org.readium.r2.testapp.db.BookDatabase
2629
import org.readium.r2.testapp.domain.model.Highlight
2730
import org.readium.r2.testapp.utils.EventChannel
31+
import org.readium.r2.testapp.search.SearchPagingSource
2832
import org.readium.r2.navigator.epub.Highlight as NavigatorHighlight
2933

3034
@OptIn(Search::class)
@@ -85,11 +89,13 @@ class ReaderViewModel(context: Context, arguments: ReaderContract.Input) : ViewM
8589
}
8690

8791
fun search(query: String) = viewModelScope.launch {
92+
_searchLocators.clear()
8893
searchIterator = publication.search(query)
8994
.onFailure { channel.send(Event.Failure(it)) }
9095
.getOrNull()
9196

9297
pagingSourceFactory.invalidate()
98+
channel.send(Event.StartNewSearch)
9399
}
94100

95101
fun cancelSearch() {
@@ -100,38 +106,28 @@ class ReaderViewModel(context: Context, arguments: ReaderContract.Input) : ViewM
100106
}
101107
}
102108

109+
val searchLocators: List<Locator> get() = _searchLocators
110+
private var _searchLocators = mutableListOf<Locator>()
111+
103112
private var searchIterator: SearchIterator? = null
104113

105114
private val pagingSourceFactory = InvalidatingPagingSourceFactory {
106-
SearchPagingSource(searchIterator)
115+
SearchPagingSource(listener = PagingSourceListener())
107116
}
108117

109-
val searchResult: Flow<PagingData<Locator>> =
110-
Pager(PagingConfig(pageSize = 20), pagingSourceFactory = pagingSourceFactory)
111-
.flow.cachedIn(viewModelScope)
112-
113-
class SearchPagingSource(private val iterator: SearchIterator?) : PagingSource<Unit, Locator>() {
114-
override val keyReuseSupported: Boolean get() = true
115-
override fun getRefreshKey(state: PagingState<Unit, Locator>): Unit? = null
116-
117-
override suspend fun load(params: LoadParams<Unit>): LoadResult<Unit, Locator> {
118-
iterator ?:
119-
return LoadResult.Page(data = emptyList(), prevKey = null, nextKey = null)
120-
121-
return try {
122-
val page = iterator.next().getOrThrow()
123-
LoadResult.Page(
124-
data = page?.locators ?: emptyList(),
125-
prevKey = null,
126-
nextKey = if (page == null) null else Unit,
127-
)
128-
129-
} catch (e: Exception) {
130-
LoadResult.Error(e)
118+
inner class PagingSourceListener : SearchPagingSource.Listener {
119+
override suspend fun next(): SearchTry<LocatorCollection?> {
120+
val iterator = searchIterator ?: return Try.success(null)
121+
return iterator.next().onSuccess {
122+
_searchLocators.addAll(it?.locators ?: emptyList())
131123
}
132124
}
133125
}
134126

127+
val searchResult: Flow<PagingData<Locator>> =
128+
Pager(PagingConfig(pageSize = 20), pagingSourceFactory = pagingSourceFactory)
129+
.flow.cachedIn(viewModelScope)
130+
135131
class Factory(private val context: Context, private val arguments: ReaderContract.Input)
136132
: ViewModelProvider.NewInstanceFactory() {
137133

@@ -143,6 +139,7 @@ class ReaderViewModel(context: Context, arguments: ReaderContract.Input) : ViewM
143139
sealed class Event {
144140
object OpenOutlineRequested : Event()
145141
object OpenDrmManagementRequested : Event()
142+
object StartNewSearch : Event()
146143
class Failure(val error: UserException) : Event()
147144
}
148145

@@ -151,4 +148,3 @@ class ReaderViewModel(context: Context, arguments: ReaderContract.Input) : ViewM
151148
object BookmarkFailed : FeedbackEvent()
152149
}
153150
}
154-

test-app/r2-testapp/src/main/java/org/readium/r2/testapp/search/SearchFragment.kt

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import androidx.fragment.app.Fragment
1414
import androidx.fragment.app.activityViewModels
1515
import androidx.fragment.app.setFragmentResult
1616
import androidx.lifecycle.lifecycleScope
17+
import androidx.recyclerview.widget.DividerItemDecoration
1718
import androidx.recyclerview.widget.LinearLayoutManager
1819
import kotlinx.coroutines.flow.launchIn
1920
import kotlinx.coroutines.flow.onEach
2021
import org.readium.r2.shared.publication.Locator
2122
import org.readium.r2.testapp.R
2223
import org.readium.r2.testapp.databinding.FragmentSearchBinding
2324
import org.readium.r2.testapp.reader.ReaderViewModel
25+
import org.readium.r2.testapp.utils.SectionDecoration
2426

2527
class SearchFragment : Fragment(R.layout.fragment_search) {
2628

@@ -34,7 +36,7 @@ class SearchFragment : Fragment(R.layout.fragment_search) {
3436

3537
val viewScope = viewLifecycleOwner.lifecycleScope
3638

37-
val adapter = SearchResultAdapter(object : SearchResultAdapter.Listener {
39+
val searchAdapter = SearchResultAdapter(object : SearchResultAdapter.Listener {
3840
override fun onItemClicked(v: View, locator: Locator) {
3941
val result = Bundle().apply {
4042
putParcelable(SearchFragment::class.java.name, locator)
@@ -44,12 +46,36 @@ class SearchFragment : Fragment(R.layout.fragment_search) {
4446
})
4547

4648
viewModel.searchResult
47-
.onEach { adapter.submitData(it) }
49+
.onEach { searchAdapter.submitData(it) }
4850
.launchIn(viewScope)
4951

50-
binding.searchListView.apply {
51-
this.adapter = adapter
52+
viewModel.channel
53+
.receive(viewLifecycleOwner) { event ->
54+
when (event) {
55+
ReaderViewModel.Event.StartNewSearch ->
56+
binding.searchRecyclerView.scrollToPosition(0)
57+
else -> {}
58+
}
59+
}
60+
61+
binding.searchRecyclerView.apply {
62+
adapter = searchAdapter
5263
layoutManager = LinearLayoutManager(activity)
64+
addItemDecoration(SectionDecoration(context, object : SectionDecoration.Listener {
65+
override fun isStartOfSection(itemPos: Int): Boolean =
66+
viewModel.searchLocators.run {
67+
when {
68+
itemPos == 0 -> true
69+
itemPos < 0 -> false
70+
itemPos >= size -> false
71+
else -> getOrNull(itemPos)?.title != getOrNull(itemPos-1)?.title
72+
}
73+
}
74+
75+
override fun sectionTitle(itemPos: Int): String =
76+
viewModel.searchLocators.getOrNull(itemPos)?.title ?: ""
77+
}))
78+
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
5379
}
5480
}
5581

@@ -63,7 +89,7 @@ class SearchFragment : Fragment(R.layout.fragment_search) {
6389
}
6490

6591
override fun onDestroyView() {
66-
_binding = null
6792
super.onDestroyView()
93+
_binding = null
6894
}
6995
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2021 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.testapp.search
8+
9+
import androidx.paging.PagingSource
10+
import androidx.paging.PagingState
11+
import org.readium.r2.shared.Search
12+
import org.readium.r2.shared.publication.Locator
13+
import org.readium.r2.shared.publication.LocatorCollection
14+
import org.readium.r2.shared.publication.services.search.SearchIterator
15+
import org.readium.r2.shared.publication.services.search.SearchTry
16+
17+
@OptIn(Search::class)
18+
class SearchPagingSource(
19+
private val listener: Listener?
20+
) : PagingSource<Unit, Locator>() {
21+
22+
interface Listener {
23+
suspend fun next(): SearchTry<LocatorCollection?>
24+
}
25+
26+
override val keyReuseSupported: Boolean get() = true
27+
28+
override fun getRefreshKey(state: PagingState<Unit, Locator>): Unit? = null
29+
30+
override suspend fun load(params: LoadParams<Unit>): LoadResult<Unit, Locator> {
31+
listener ?: return LoadResult.Page(data = emptyList(), prevKey = null, nextKey = null)
32+
33+
return try {
34+
val page = listener.next().getOrThrow()
35+
LoadResult.Page(
36+
data = page?.locators ?: emptyList(),
37+
prevKey = null,
38+
nextKey = if (page == null) null else Unit
39+
)
40+
} catch (e: Exception) {
41+
LoadResult.Error(e)
42+
}
43+
}
44+
}

test-app/r2-testapp/src/main/java/org/readium/r2/testapp/search/SearchResultAdapter.kt

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import org.readium.r2.testapp.databinding.ItemRecycleSearchBinding
1919
import org.readium.r2.testapp.utils.singleClick
2020

2121
/**
22-
* This class is an adapter for Search results' list view
22+
* This class is an adapter for Search results' recycler view.
2323
*/
2424
class SearchResultAdapter(private var listener: Listener) :
2525
PagingDataAdapter<Locator, SearchResultAdapter.ViewHolder>(ItemCallback()) {
@@ -32,19 +32,18 @@ class SearchResultAdapter(private var listener: Listener) :
3232
)
3333
}
3434

35-
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
35+
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
3636
val locator = getItem(position) ?: return
37-
val title = locator.title?.let { "<h6>$it</h6>" }
3837
val html =
39-
"$title\n${locator.text.before}<span style=\"background:yellow;\"><b>${locator.text.highlight}</b></span>${locator.text.after}"
40-
viewHolder.textView.text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
38+
"${locator.text.before}<span style=\"background:yellow;\"><b>${locator.text.highlight}</b></span>${locator.text.after}"
39+
holder.textView.text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
4140
Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT)
4241
} else {
4342
@Suppress("DEPRECATION")
4443
Html.fromHtml(html)
4544
}
4645

47-
viewHolder.itemView.singleClick { v ->
46+
holder.itemView.singleClick { v ->
4847
listener.onItemClicked(v, locator)
4948
}
5049
}
@@ -66,5 +65,4 @@ class SearchResultAdapter(private var listener: Listener) :
6665
override fun areContentsTheSame(oldItem: Locator, newItem: Locator): Boolean =
6766
oldItem == newItem
6867
}
69-
70-
}
68+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2021 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.testapp.utils
8+
9+
import android.content.Context
10+
import android.graphics.Canvas
11+
import android.graphics.Rect
12+
import android.view.LayoutInflater
13+
import android.view.View
14+
import android.view.ViewGroup
15+
import android.widget.TextView
16+
import androidx.core.view.children
17+
import androidx.recyclerview.widget.RecyclerView
18+
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
19+
import org.readium.r2.testapp.databinding.SectionHeaderBinding
20+
21+
class SectionDecoration(
22+
private val context: Context,
23+
private val listener: Listener
24+
) : RecyclerView.ItemDecoration() {
25+
26+
interface Listener {
27+
fun isStartOfSection(itemPos: Int): Boolean
28+
fun sectionTitle(itemPos: Int): String
29+
}
30+
31+
private lateinit var headerView: View
32+
private lateinit var sectionTitle: TextView
33+
34+
private val headerHeight get() = headerView.height
35+
36+
override fun getItemOffsets(
37+
outRect: Rect,
38+
view: View,
39+
parent: RecyclerView,
40+
state: RecyclerView.State
41+
) {
42+
super.getItemOffsets(outRect, view, parent, state)
43+
val pos = parent.getChildAdapterPosition(view)
44+
if (listener.isStartOfSection(pos))
45+
outRect.top = headerHeight
46+
}
47+
48+
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
49+
super.onDrawOver(c, parent, state)
50+
SectionHeaderBinding.inflate(
51+
LayoutInflater.from(context),
52+
parent,
53+
false
54+
).apply {
55+
headerView = root
56+
sectionTitle = header
57+
}
58+
fixLayoutSize(headerView, parent)
59+
60+
val children = parent.children.toList()
61+
children.forEach { child ->
62+
val pos = parent.getChildAdapterPosition(child)
63+
if (pos != NO_POSITION && (listener.isStartOfSection(pos) || isTopChild(child, children))) {
64+
sectionTitle.text = listener.sectionTitle(pos)
65+
drawHeader(c, child, headerView)
66+
}
67+
}
68+
}
69+
70+
private fun fixLayoutSize(v: View, parent: ViewGroup) {
71+
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
72+
val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
73+
val childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.paddingStart + parent.paddingEnd, v.layoutParams.width)
74+
val childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.paddingTop + parent.paddingBottom, v.layoutParams.height)
75+
v.measure(childWidth, childHeight)
76+
v.layout(0, 0, v.measuredWidth, v.measuredHeight)
77+
}
78+
79+
private fun drawHeader(c: Canvas, child: View, headerView: View) {
80+
c.run {
81+
save()
82+
translate(0F, maxOf(0, child.top - headerView.height).toFloat())
83+
headerView.draw(this)
84+
restore()
85+
}
86+
}
87+
88+
private fun isTopChild(child: View, children: List<View>): Boolean {
89+
var tmp = child.top
90+
children.forEach { c ->
91+
tmp = minOf(c.top, tmp)
92+
}
93+
return child.top == tmp
94+
}
95+
}

test-app/r2-testapp/src/main/res/layout/fragment_search.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
android:background="@color/colorAccent">
1717

1818
<androidx.recyclerview.widget.RecyclerView
19-
android:id="@+id/search_listView"
19+
android:id="@+id/search_recyclerView"
2020
android:layout_width="0dp"
2121
android:layout_height="0dp"
2222
android:background="@android:color/white"

0 commit comments

Comments
 (0)