Use loading item instead of full screen loading.

This commit is contained in:
Onuray Sahin 2020-09-29 20:38:58 +03:00 committed by Benoit Marty
parent 0d16fe019e
commit 5d190a8137
7 changed files with 104 additions and 81 deletions

View File

@ -20,6 +20,6 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class SearchAction : VectorViewModelAction { sealed class SearchAction : VectorViewModelAction {
data class SearchWith(val searchTerm: String) : SearchAction() data class SearchWith(val searchTerm: String) : SearchAction()
object ScrolledToTop : SearchAction() object LoadMore : SearchAction()
object Retry : SearchAction() object Retry : SearchAction()
} }

View File

@ -21,14 +21,15 @@ import android.os.Parcelable
import android.view.View import android.view.View
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
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 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.cleanup
import im.vector.app.core.extensions.configureWith 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.hideKeyboard
import im.vector.app.core.extensions.trackItemsVisibilityChange import im.vector.app.core.extensions.trackItemsVisibilityChange
import im.vector.app.core.platform.StateView import im.vector.app.core.platform.StateView
@ -62,39 +63,19 @@ class SearchFragment @Inject constructor(
stateView.eventCallback = this stateView.eventCallback = this
configureRecyclerView() 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() { private fun configureRecyclerView() {
searchResultRecycler.trackItemsVisibilityChange() searchResultRecycler.trackItemsVisibilityChange()
searchResultRecycler.configureWith(controller, showDivider = false) searchResultRecycler.configureWith(controller, showDivider = false)
(searchResultRecycler.layoutManager as? LinearLayoutManager)?.stackFromEnd = true
controller.listener = this controller.listener = this
controller.addModelBuildListener { controller.addModelBuildListener {
pendingScrollToPosition?.let { pendingScrollToPosition?.let {
searchResultRecycler.scrollToPosition(it) searchResultRecycler.smoothScrollToPosition(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() { override fun onDestroy() {
@ -104,18 +85,26 @@ class SearchFragment @Inject constructor(
} }
override fun invalidate() = withState(searchViewModel) { state -> override fun invalidate() = withState(searchViewModel) { state ->
if (state.searchResult?.results?.isNotEmpty() == true) { if (state.searchResult?.results.isNullOrEmpty()) {
when (state.asyncEventsRequest) {
is Loading -> {
stateView.state = StateView.State.Loading
}
is Fail -> {
stateView.state = StateView.State.Error(errorFormatter.toHumanReadable(state.asyncEventsRequest.error))
}
is Success -> {
stateView.state = StateView.State.Empty(
title = getString(R.string.search_no_results),
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_search_no_results))
}
}
} else {
val lastBatchSize = state.lastBatch?.results?.size ?: 0
pendingScrollToPosition = if (lastBatchSize > 0) lastBatchSize - 1 else 0
stateView.state = StateView.State.Content stateView.state = StateView.State.Content
controller.setData(state) 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 = getString(R.string.search_no_results),
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_search_no_results)
)
} }
} }
@ -133,4 +122,8 @@ class SearchFragment @Inject constructor(
navigator.openRoom(requireContext(), event.roomId!!, event.eventId) navigator.openRoom(requireContext(), event.roomId!!, event.eventId)
} }
override fun loadMore() {
searchViewModel.handle(SearchAction.LoadMore)
}
} }

View File

@ -17,8 +17,10 @@
package im.vector.app.features.home.room.detail.search package im.vector.app.features.home.room.detail.search
import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.TypedEpoxyController
import com.airbnb.epoxy.VisibilityState
import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.epoxy.loadingItem
import im.vector.app.core.ui.list.genericItemHeader import im.vector.app.core.ui.list.genericItemHeader
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
@ -34,8 +36,11 @@ class SearchResultController @Inject constructor(
var listener: Listener? = null var listener: Listener? = null
private var idx = 0
interface Listener { interface Listener {
fun onItemClicked(event: Event) fun onItemClicked(event: Event)
fun loadMore()
} }
init { init {
@ -45,6 +50,18 @@ class SearchResultController @Inject constructor(
override fun buildModels(data: SearchViewState?) { override fun buildModels(data: SearchViewState?) {
data?.searchResult?.results ?: return data?.searchResult?.results ?: return
if (!data.searchResult.nextBatch.isNullOrEmpty()) {
loadingItem {
// Always use a different id, because we can be notified several times of visibility state changed
id("loadMore${idx++}")
onVisibilityStateChanged { _, _, visibilityState ->
if (visibilityState == VisibilityState.VISIBLE) {
listener?.loadMore()
}
}
}
}
buildSearchResultItems(data.searchResult.results!!) buildSearchResultItems(data.searchResult.results!!)
} }

View File

@ -20,5 +20,4 @@ import im.vector.app.core.platform.VectorViewEvents
sealed class SearchViewEvents : VectorViewEvents { sealed class SearchViewEvents : VectorViewEvents {
data class Failure(val throwable: Throwable) : SearchViewEvents() data class Failure(val throwable: Throwable) : SearchViewEvents()
data class Loading(val message: CharSequence? = null) : SearchViewEvents()
} }

View File

@ -16,16 +16,21 @@
package im.vector.app.features.home.room.detail.search package im.vector.app.features.home.room.detail.search
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
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.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import org.matrix.android.sdk.api.MatrixCallback import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.search.SearchResult import org.matrix.android.sdk.api.session.search.SearchResult
import org.matrix.android.sdk.internal.util.awaitCallback
class SearchViewModel @AssistedInject constructor( class SearchViewModel @AssistedInject constructor(
@Assisted private val initialState: SearchViewState, @Assisted private val initialState: SearchViewState,
@ -48,26 +53,22 @@ class SearchViewModel @AssistedInject constructor(
override fun handle(action: SearchAction) { override fun handle(action: SearchAction) {
when (action) { when (action) {
is SearchAction.SearchWith -> handleSearchWith(action) is SearchAction.SearchWith -> handleSearchWith(action)
is SearchAction.ScrolledToTop -> handleScrolledToTop() is SearchAction.LoadMore -> handleLoadMore()
is SearchAction.Retry -> handleRetry() is SearchAction.Retry -> handleRetry()
}.exhaustive }.exhaustive
} }
private fun handleSearchWith(action: SearchAction.SearchWith) { private fun handleSearchWith(action: SearchAction.SearchWith) {
if (action.searchTerm.length > 1) { if (action.searchTerm.length > 1) {
setState { setState {
copy(searchTerm = action.searchTerm, isNextBatch = false) copy(searchTerm = action.searchTerm)
} }
startSearching() startSearching()
} }
} }
private fun handleScrolledToTop() { private fun handleLoadMore() {
setState {
copy(isNextBatch = true)
}
startSearching(true) startSearching(true)
} }
@ -75,44 +76,51 @@ class SearchViewModel @AssistedInject constructor(
startSearching() startSearching()
} }
private fun startSearching(scrolledToTop: Boolean = false) = withState { state -> private fun startSearching(isNextBatch: Boolean = false) = withState { state ->
if (state.roomId == null || state.searchTerm == null) return@withState if (state.roomId == null || state.searchTerm == null) return@withState
// There is no batch to retrieve // There is no batch to retrieve
if (scrolledToTop && state.searchResult?.nextBatch == null) return@withState if (isNextBatch && state.searchResult?.nextBatch == null) return@withState
_viewEvents.post(SearchViewEvents.Loading()) // Show full screen loading just for the clean search
if (!isNextBatch) {
session setState {
.getRoom(state.roomId) copy(
?.search( asyncEventsRequest = Loading()
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 { viewModelScope.launch {
copy(searchResult = null) try {
val result = awaitCallback<SearchResult> {
session
.getRoom(state.roomId)
?.search(
searchTerm = state.searchTerm,
nextBatch = state.searchResult?.nextBatch,
orderByRecent = true,
beforeLimit = 0,
afterLimit = 0,
includeProfile = true,
limit = 20,
callback = it
)
}
onSearchResultSuccess(result, isNextBatch)
} catch (failure: Throwable) {
_viewEvents.post(SearchViewEvents.Failure(failure))
setState {
copy(
asyncEventsRequest = Fail(failure),
searchResult = null
)
}
}
} }
_viewEvents.post(SearchViewEvents.Failure(failure))
} }
private fun onSearchResultSuccess(searchResult: SearchResult) = withState { state -> private fun onSearchResultSuccess(searchResult: SearchResult, isNextBatch: Boolean) = withState { state ->
val accumulatedResult = SearchResult( val accumulatedResult = SearchResult(
nextBatch = searchResult.nextBatch, nextBatch = searchResult.nextBatch,
results = searchResult.results, results = searchResult.results,
@ -120,7 +128,7 @@ class SearchViewModel @AssistedInject constructor(
) )
// Accumulate results if it is the next batch // Accumulate results if it is the next batch
if (state.isNextBatch) { if (isNextBatch) {
if (state.searchResult != null) { if (state.searchResult != null) {
accumulatedResult.results = accumulatedResult.results?.plus(state.searchResult.results!!) accumulatedResult.results = accumulatedResult.results?.plus(state.searchResult.results!!)
} }
@ -130,7 +138,11 @@ class SearchViewModel @AssistedInject constructor(
} }
setState { setState {
copy(searchResult = accumulatedResult, lastBatch = searchResult) copy(
searchResult = accumulatedResult,
lastBatch = searchResult,
asyncEventsRequest = Success(Unit)
)
} }
} }
} }

View File

@ -16,7 +16,9 @@
package im.vector.app.features.home.room.detail.search package im.vector.app.features.home.room.detail.search
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import org.matrix.android.sdk.api.session.search.SearchResult import org.matrix.android.sdk.api.session.search.SearchResult
data class SearchViewState( data class SearchViewState(
@ -26,7 +28,8 @@ data class SearchViewState(
val lastBatch: SearchResult? = null, val lastBatch: SearchResult? = null,
val searchTerm: String? = null, val searchTerm: String? = null,
val roomId: String? = null, val roomId: String? = null,
val isNextBatch: Boolean = false // Current pagination request
val asyncEventsRequest: Async<Unit> = Uninitialized
) : MvRxState { ) : MvRxState {
constructor(args: SearchArgs) : this(roomId = args.roomId) constructor(args: SearchArgs) : this(roomId = args.roomId)

View File

@ -2,8 +2,7 @@
<im.vector.app.core.platform.StateView xmlns:android="http://schemas.android.com/apk/res/android" <im.vector.app.core.platform.StateView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/stateView" android:id="@+id/stateView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:background="?riotx_header_panel_background">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/searchResultRecycler" android:id="@+id/searchResultRecycler"