Improved 'Search' Functionality:

* Added a loading progress bar at the end of the RecyclerView when loading more search results as the user scrolls to the bottom. This indicator informs users that additional results are being loaded. The progress bar appears if there are more results available for the search term, providing users with visibility into ongoing loading.
* Enhanced the search loading process for larger ZIM files by introducing coroutines. This background threading approach prevents the UI thread from being impacted and ensures a smooth scrolling experience for users.
This commit is contained in:
MohitMali 2023-08-09 18:45:35 +05:30
parent a1b5f36c59
commit 734885a527
4 changed files with 58 additions and 24 deletions

View File

@ -38,6 +38,9 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.BaseActivity import org.kiwix.kiwixmobile.core.base.BaseActivity
import org.kiwix.kiwixmobile.core.base.BaseFragment import org.kiwix.kiwixmobile.core.base.BaseFragment
@ -146,7 +149,12 @@ class SearchFragment : BaseFragment() {
if (isDataLoading) return if (isDataLoading) return
val safeStartIndex = searchAdapter?.itemCount ?: 0 val safeStartIndex = searchAdapter?.itemCount ?: 0
isDataLoading = true isDataLoading = true
fragmentSearchBinding?.loadingMoreDataIndicator?.isShowing(true)
CoroutineScope(Dispatchers.IO).launch {
val fetchMoreSearchResults = searchState?.getVisibleResults(safeStartIndex) val fetchMoreSearchResults = searchState?.getVisibleResults(safeStartIndex)
CoroutineScope(Dispatchers.Main).launch {
fragmentSearchBinding?.loadingMoreDataIndicator?.isShowing(false)
isDataLoading = when { isDataLoading = when {
fetchMoreSearchResults == null -> true fetchMoreSearchResults == null -> true
fetchMoreSearchResults.isEmpty() -> false fetchMoreSearchResults.isEmpty() -> false
@ -164,6 +172,8 @@ class SearchFragment : BaseFragment() {
} }
} }
} }
}
}
private fun handleBackPress() { private fun handleBackPress() {
activity?.onBackPressedDispatcher?.addCallback( activity?.onBackPressedDispatcher?.addCallback(
@ -248,15 +258,16 @@ class SearchFragment : BaseFragment() {
) )
} }
private fun render(state: SearchState) { private suspend fun render(state: SearchState) {
isDataLoading = false isDataLoading = false
searchState = state searchState = state
searchInTextMenuItem?.isVisible = state.searchOrigin == FromWebView searchInTextMenuItem?.isVisible = state.searchOrigin == FromWebView
searchInTextMenuItem?.isEnabled = state.searchTerm.isNotBlank() searchInTextMenuItem?.isEnabled = state.searchTerm.isNotBlank()
fragmentSearchBinding?.searchLoadingIndicator?.isShowing(state.isLoading) fragmentSearchBinding?.searchLoadingIndicator?.isShowing(state.isLoading)
val searchResult = state.getVisibleResults(0)
fragmentSearchBinding?.searchNoResults?.isVisible = fragmentSearchBinding?.searchNoResults?.isVisible =
state.getVisibleResults(0)?.isEmpty() == true searchResult?.isEmpty() == true
state.getVisibleResults(0)?.let { searchResult?.let {
searchAdapter?.items = it searchAdapter?.items = it
} }
} }

View File

@ -26,10 +26,13 @@ data class SearchState(
val recentResults: List<SearchListItem.RecentSearchListItem>, val recentResults: List<SearchListItem.RecentSearchListItem>,
val searchOrigin: SearchOrigin val searchOrigin: SearchOrigin
) { ) {
fun getVisibleResults(startIndex: Int): List<SearchListItem.RecentSearchListItem>? = private var isDataLoading = false
suspend fun getVisibleResults(startIndex: Int): List<SearchListItem.RecentSearchListItem>? =
if (searchTerm.isEmpty()) { if (searchTerm.isEmpty()) {
isDataLoading = false
recentResults recentResults
} else { } else {
isDataLoading = true
searchResultsWithTerm.suggestionSearch?.let { searchResultsWithTerm.suggestionSearch?.let {
val maximumResults = it.estimatedMatches val maximumResults = it.estimatedMatches
val safeEndIndex = val safeEndIndex =
@ -46,13 +49,15 @@ data class SearchState(
* We check this in SearchFragment to avoid unnecessary data loading * We check this in SearchFragment to avoid unnecessary data loading
* while scrolling to the end of the list when there are no items available. * while scrolling to the end of the list when there are no items available.
*/ */
isDataLoading = false
searchResults.ifEmpty { null } searchResults.ifEmpty { null }
} ?: kotlin.run { } ?: kotlin.run {
isDataLoading = false
recentResults recentResults
} }
} }
val isLoading = searchTerm != searchResultsWithTerm.searchTerm val isLoading get() = searchTerm != searchResultsWithTerm.searchTerm || isDataLoading
} }
enum class SearchOrigin { enum class SearchOrigin {

View File

@ -1,4 +1,5 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout 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" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@ -6,7 +7,7 @@
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"> android:orientation="vertical">
@ -15,11 +16,27 @@
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_list" android:id="@+id/search_list"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/loadingMoreDataIndicator"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="?actionBarSize"
tools:listitem="@layout/list_item_search" /> tools:listitem="@layout/list_item_search" />
</LinearLayout> <androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/loadingMoreDataIndicator"
style="?android:attr/progressBarStyleLarge"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView <TextView
android:id="@+id/searchNoResults" android:id="@+id/searchNoResults"

View File

@ -20,6 +20,7 @@ package org.kiwix.kiwixmobile.core.search.viewmodel
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
@ -28,7 +29,7 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
internal class SearchStateTest { internal class SearchStateTest {
@Test @Test
internal fun `visibleResults use searchResults when searchTerm is not empty`() { internal fun `visibleResults use searchResults when searchTerm is not empty`() = runTest {
val searchTerm = "notEmpty" val searchTerm = "notEmpty"
val suggestionSearchWrapper: SuggestionSearchWrapper = mockk() val suggestionSearchWrapper: SuggestionSearchWrapper = mockk()
val searchIteratorWrapper: SuggestionIteratorWrapper = mockk() val searchIteratorWrapper: SuggestionIteratorWrapper = mockk()
@ -58,7 +59,7 @@ internal class SearchStateTest {
} }
@Test @Test
internal fun `visibleResults use recentResults when searchTerm is empty`() { internal fun `visibleResults use recentResults when searchTerm is empty`() = runTest {
val results = listOf(RecentSearchListItem("")) val results = listOf(RecentSearchListItem(""))
assertThat( assertThat(
SearchState( SearchState(