Support searching and pagination.

This commit is contained in:
Onuray Sahin 2020-09-24 10:22:26 +03:00 committed by Benoit Marty
parent 5e56e7cf82
commit 62449ee543
2 changed files with 182 additions and 5 deletions

View File

@ -19,12 +19,24 @@ package im.vector.app.features.home.room.detail.search
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.View import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.mvrx.args import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.trackItemsVisibilityChange
import im.vector.app.core.platform.StateView
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.StringProvider
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import timber.log.Timber import kotlinx.android.synthetic.main.fragment_search.*
import org.matrix.android.sdk.api.session.events.model.Event
import javax.inject.Inject import javax.inject.Inject
@Parcelize @Parcelize
@ -33,19 +45,94 @@ data class SearchArgs(
) : Parcelable ) : Parcelable
class SearchFragment @Inject constructor( class SearchFragment @Inject constructor(
val viewModelFactory: SearchViewModel.Factory val viewModelFactory: SearchViewModel.Factory,
) : VectorBaseFragment() { val controller: SearchResultController,
val stringProvider: StringProvider
) : VectorBaseFragment(), StateView.EventCallback, SearchResultController.Listener {
private val fragmentArgs: SearchArgs by args() private val fragmentArgs: SearchArgs by args()
private val searchViewModel: SearchViewModel by fragmentViewModel() private val searchViewModel: SearchViewModel by fragmentViewModel()
private var pendingScrollToPosition: Int? = null
override fun getLayoutResId() = R.layout.fragment_search override fun getLayoutResId() = R.layout.fragment_search
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
stateView.contentView = searchResultRecycler
stateView.eventCallback = this
configureRecyclerView()
searchViewModel.observeViewEvents {
when (it) {
is SearchViewEvents.Failure -> {
stateView.state = StateView.State.Error(errorFormatter.toHumanReadable(it.throwable))
}
is SearchViewEvents.Loading -> {
stateView.state = StateView.State.Loading
}
}.exhaustive
}
}
private fun configureRecyclerView() {
searchResultRecycler.trackItemsVisibilityChange()
searchResultRecycler.configureWith(controller, showDivider = false)
controller.listener = this
controller.addModelBuildListener {
pendingScrollToPosition?.let {
searchResultRecycler.scrollToPosition(it)
}
}
searchResultRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
// Load next batch when scrolled to the top
if (newState == RecyclerView.SCROLL_STATE_IDLE
&& (searchResultRecycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() == 0) {
searchViewModel.handle(SearchAction.ScrolledToTop)
}
}
})
}
override fun onDestroy() {
super.onDestroy()
searchResultRecycler?.cleanup()
controller.listener = null
}
override fun invalidate() = withState(searchViewModel) { state ->
if (state.searchResult?.results?.isNotEmpty() == true) {
stateView.state = StateView.State.Content
controller.setData(state)
val lastBatchSize = state.lastBatch?.results?.size ?: 0
val scrollPosition = if (lastBatchSize > 0) lastBatchSize - 1 else 0
pendingScrollToPosition = scrollPosition
} else {
stateView.state = StateView.State.Empty(
title = stringProvider.getString(R.string.search_no_results),
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_search)
)
}
} }
fun search(query: String) { fun search(query: String) {
Timber.d(query) view?.hideKeyboard()
searchViewModel.handle(SearchAction.SearchWith(fragmentArgs.roomId, query))
}
override fun onRetryClicked() {
searchViewModel.handle(SearchAction.Retry)
}
override fun onItemClicked(event: Event) {
event.roomId ?: return
navigator.openRoom(requireContext(), event.roomId!!, event.eventId)
} }
} }

View File

@ -21,10 +21,15 @@ import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject import com.squareup.inject.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.search.SearchResult
class SearchViewModel @AssistedInject constructor( class SearchViewModel @AssistedInject constructor(
@Assisted private val initialState: SearchViewState @Assisted private val initialState: SearchViewState,
private val session: Session
) : VectorViewModel<SearchViewState, SearchAction, SearchViewEvents>(initialState) { ) : VectorViewModel<SearchViewState, SearchAction, SearchViewEvents>(initialState) {
@AssistedInject.Factory @AssistedInject.Factory
@ -42,5 +47,90 @@ class SearchViewModel @AssistedInject constructor(
} }
override fun handle(action: SearchAction) { override fun handle(action: SearchAction) {
when (action) {
is SearchAction.SearchWith -> handleSearchWith(action)
is SearchAction.ScrolledToTop -> handleScrolledToTop()
is SearchAction.Retry -> handleRetry()
}.exhaustive
}
private fun handleSearchWith(action: SearchAction.SearchWith) {
if (action.searchTerm.length > 1) {
setState {
copy(searchTerm = action.searchTerm, roomId = action.roomId, isNextBatch = false)
}
startSearching()
}
}
private fun handleScrolledToTop() {
setState {
copy(isNextBatch = true)
}
startSearching(true)
}
private fun handleRetry() {
startSearching()
}
private fun startSearching(scrolledToTop: Boolean = false) = withState { state ->
if (state.roomId == null || state.searchTerm == null) return@withState
// There is no batch to retrieve
if (scrolledToTop && state.searchResult?.nextBatch == null) return@withState
_viewEvents.post(SearchViewEvents.Loading())
session
.getRoom(state.roomId)
?.search(
searchTerm = state.searchTerm,
nextBatch = state.searchResult?.nextBatch,
orderByRecent = true,
beforeLimit = 0,
afterLimit = 0,
includeProfile = true,
limit = 20,
callback = object : MatrixCallback<SearchResult> {
override fun onFailure(failure: Throwable) {
onSearchFailure(failure)
}
override fun onSuccess(data: SearchResult) {
onSearchResultSuccess(data)
}
}
)
}
private fun onSearchFailure(failure: Throwable) {
setState {
copy(searchResult = null)
}
_viewEvents.post(SearchViewEvents.Failure(failure))
}
private fun onSearchResultSuccess(searchResult: SearchResult) = withState { state ->
val accumulatedResult = SearchResult(
nextBatch = searchResult.nextBatch,
results = searchResult.results,
highlights = searchResult.highlights
)
// Accumulate results if it is the next batch
if (state.isNextBatch) {
if (state.searchResult != null) {
accumulatedResult.results = accumulatedResult.results?.plus(state.searchResult.results!!)
}
if (state.searchResult?.highlights != null) {
accumulatedResult.highlights = accumulatedResult.highlights?.plus(state.searchResult.highlights!!)
}
}
setState {
copy(searchResult = accumulatedResult, lastBatch = searchResult)
}
} }
} }