Model-View-Intent and Download / Update Indicator
Good afternoon! Many Android applications download data from the server and at this time show a download indicator, and after that they allow you to update the data. There can be a dozen screens in the application, on almost all of them you need:
- when going to the screen, show the download indicator (
ProgressBar
) while the data is being loaded from the server; - in case of a download error, show the error message and the "Retry download" button;
- in case of a successful download, give the user the opportunity to update the data (
SwipeRefreshLayout
); - if an error occurred while updating the data, show the corresponding message (
Snackbar
).
When developing applications, I use the MVI architecture (Model-View-Intent) in the Mosby implementation , more about which you can read on Habré or find the original article on MVI on the mosby developer's site . In this article I am going to talk about creating base classes that would allow us to separate the load / update logic described above from other data actions.
The first thing we’ll start with the creation of base classes is the creation ViewState
, which plays a key role in MVI. ViewState
contains data on the current state of the View (which may be an activity, fragment or ViewGroup
). Given what the state of the screen may be, regarding downloads and updates, ViewState
it looks like this:
// Здесь и далее LR используется для сокращения Load-Refresh.
data class LRViewState>(
val loading: Boolean,
val loadingError: Throwable?,
val canRefresh: Boolean,
val refreshing: Boolean,
val refreshingError: Throwable?,
val model: M
)
The first two fields contain information about the current status of the download (whether the download is currently taking place and if an error has occurred). The following three fields contain information about updating the data (whether the user can update the data and whether the update is currently taking place and if an error has occurred). The last field is the model that is meant to be displayed on the screen after it is loaded.
The LRViewState
model implements the interface InitialModelHolder
, which I will talk about now.
Not all data that will be displayed on the screen or will still be used somehow within the screen must be downloaded from the server. For example, there is a model that consists of a list of people that is downloaded from the server, and several variables that determine the sort order or filter people in the list. The user can change the sorting and search parameters even before the list is downloaded from the server. In this case, the list is the initial (initial) part of the model, which takes a long time to load and which needs to be shown at boot time ProgressBar
. It is in order to highlight which part of the model is the source interface is used InitialModelHolder
.
interface InitialModelHolder {
fun changeInitialModel(i: I): InitialModelHolder
}
Here, the parameter I
shows what the initial part of the model will be, and the method changeInitialModel(i: I)
that the class model should implement allows you to create a new model object in which its initial (initial) part is replaced with the one that was passed to the method as a parameter i
.
It’s clear why you need to change some part of the model to another, if you recall one of the main advantages of MVI - State Reducer (more details here ). State Reducer allows you to apply to an existing object ViewState
partial changes ( the Partial Changes ) and thereby create a new instance of ViewState. In the future, the method changeInitialModel(i: I)
will be used in State Reducer in order to create a new ViewState instance with the loaded data.
Now is the time to talk about Partial Change. A partial change contains information about what needs to be changed ViewState
. All partial changes implement the interface PartialChange
. This interface is not part of Mosby and is designed so that all partial changes (those related to download / update and those that do not apply) have a common "root".
Partial changes are conveniently combined into sealed
classes. Further you can see the partial changes that can be applied to LRViewState
.
sealed class LRPartialChange : PartialChange {
object LoadingStarted : LRPartialChange() // загрузка началась
data class LoadingError(val t: Throwable) : LRPartialChange() // загрузка завершилась с ошибкой
object RefreshStarted : LRPartialChange() // обновление началось
data class RefreshError(val t: Throwable) : LRPartialChange() // обновление завершилось с ошибкой
// загрузка или обновления завершились успешно
data class InitialModelLoaded(val i: I) : LRPartialChange()
}
The next step is to create a basic interface for the View.
interface LRView> : MvpView {
fun load(): Observable
fun retry(): Observable
fun refresh(): Observable
fun render(vs: LRViewState)
}
Here, the parameter K
is the key that will help the presenter determine which data to download. The key can be, for example, an entity ID. The parameter M
determines the type of model (field type model
c LRViewState
). The first three methods are intents (in terms of MVI) and serve to transmit events from View
k Presenter
. The implementation of the method render
will display ViewState
.
Now that we have the LRViewState
interface LRView
, we can create it LRPresenter
. Let's consider it in parts.
abstract class LRPresenter, V : LRView>
: MviBasePresenter>() {
protected abstract fun initialModelSingle(key: K): Single
open protected val reloadIntent: Observable = Observable.never()
protected val loadIntent: Observable = intent { it.load() }
protected val retryIntent: Observable = intent { it.retry() }
protected val refreshIntent: Observable = intent { it.refresh() }
...
...
}
Parameters LRPresenter
are:
K
the key by which the initial part of the model is loaded;I
type of the initial part of the model;M
type of model;V
typeView
with which this one worksPresenter
.
The implementation of the method initialModelSingle
should return io.reactivex.Single
to load the initial part of the model with the passed key . The field reloadIntent
can be overridden by successor classes and is used to reload the initial part of the model (for example, after certain user actions). The following three fields create intentions for receiving events from View
.
The next LRPresenter
step is to create a method io.reactivex.Observable
that will transfer partial changes related to downloading or updating. In the following, it will be shown how successor classes can use this method.
protected fun loadRefreshPartialChanges(): Observable = Observable.merge(
Observable
.merge(
Observable.combineLatest(
loadIntent,
reloadIntent.startWith(Any()),
BiFunction { k, _ -> k }
),
retryIntent
)
.switchMap {
initialModelSingle(it)
.toObservable()
.map { LRPartialChange.InitialModelLoaded(it) }
.onErrorReturn { LRPartialChange.LoadingError(it) }
.startWith(LRPartialChange.LoadingStarted)
},
refreshIntent
.switchMap {
initialModelSingle(it)
.toObservable()
.map { LRPartialChange.InitialModelLoaded(it) }
.onErrorReturn { LRPartialChange.RefreshError(it) }
.startWith(LRPartialChange.RefreshStarted)
}
)
And the last part LRPresenter
is State Reducer , which applies to ViewState
partial changes related to downloading or updating (these partial changes were transferred from Observable
created in the method loadRefreshPartialChanges
).
@CallSuper
open protected fun stateReducer(viewState: LRViewState, change: PartialChange): LRViewState {
if (change !is LRPartialChange) throw Exception()
return when (change) {
LRPartialChange.LoadingStarted -> viewState.copy(
loading = true,
loadingError = null,
canRefresh = false
)
is LRPartialChange.LoadingError -> viewState.copy(
loading = false,
loadingError = change.t
)
LRPartialChange.RefreshStarted -> viewState.copy(
refreshing = true,
refreshingError = null
)
is LRPartialChange.RefreshError -> viewState.copy(
refreshing = false,
refreshingError = change.t
)
is LRPartialChange.InitialModelLoaded<*> -> {
@Suppress("UNCHECKED_CAST")
viewState.copy(
loading = false,
loadingError = null,
model = viewState.model.changeInitialModel(change.i as I) as M,
canRefresh = true,
refreshing = false
)
}
}
}
Now it remains to create a basic fragment or activity that will implement LRView
. In my applications, I follow the SingleActivityApplication approach, so let's create one LRFragment
.
To display the download and update indicators, as well as to receive events about the need to repeat the download and update, an interface was created LoadRefreshPanel
to which the LRFragment
display will delegate ViewState
and which will be the facade of the events. Thus, the successor fragments will not be required to have the SwipeRefreshLayout
“Retry download” button.
interface LoadRefreshPanel {
fun retryClicks(): Observable
fun refreshes(): Observable
fun render(vs: LRViewState<*>)
}
In the demo application, the LRPanelImpl class was created , which is a SwipeRefreshLayout
nested class ViewAnimator
. ViewAnimator
Allows you to display either ProgressBar
the error panel or the model.
Given LoadRefreshPanel
LRFragment
will look like this:
abstract class LRFragment, V : LRView, P : MviBasePresenter>> : MviFragment(), LRView {
protected abstract val key: K
protected abstract fun viewForSnackbar(): View
protected abstract fun loadRefreshPanel(): LoadRefreshPanel
override fun load(): Observable = Observable.just(key)
override fun retry(): Observable = loadRefreshPanel().retryClicks().map { key }
override fun refresh(): Observable = loadRefreshPanel().refreshes().map { key }
@CallSuper
override fun render(vs: LRViewState) {
loadRefreshPanel().render(vs)
if (vs.refreshingError != null) {
Snackbar.make(viewForSnackbar(), R.string.refreshing_error_text, Snackbar.LENGTH_SHORT)
.show()
}
}
}
As you can see from the above code, the download starts immediately after attaching the presenter, and everything else is delegated LoadRefreshPanel
.
Now creating a screen on which it is necessary to implement the download / update logic becomes a simple task. For example, consider a screen with details about a person (a rider, in our case).
The entity class is trivial.
data class Driver(
val id: Long,
val name: String,
val team: String,
val birthYear: Int
)
The model class for a screen with details consists of one entity:
data class DriverDetailsModel(
val driver: Driver
) : InitialModelHolder {
override fun changeInitialModel(i: Driver) = copy(driver = i)
}
Presenter class for a screen with details:
class DriverDetailsPresenter : LRPresenter() {
override fun initialModelSingle(key: Long): Single = Single
.just(DriversSource.DRIVERS)
.map { it.single { it.id == key } }
.delay(1, TimeUnit.SECONDS)
.flatMap {
if (System.currentTimeMillis() % 2 == 0L) Single.just(it)
else Single.error(Exception())
}
override fun bindIntents() {
val initialViewState = LRViewState(false, null, false, false, null,
DriverDetailsModel(Driver(-1, "", "", -1))
)
val observable = loadRefreshPartialChanges()
.scan(initialViewState, this::stateReducer)
.observeOn(AndroidSchedulers.mainThread())
subscribeViewState(observable, DriverDetailsView::render)
}
}
The method initialModelSingle
creates Single
to load the entity according to the transferred id
(approximately every 2nd time an error is thrown to show what the UI of the error looks like). The method bindIntents
uses the loadRefreshPartialChanges
from method LRPresenter
to create Observable
, transmitting partial changes.
Let's move on to creating a fragment with details.
class DriverDetailsFragment
: LRFragment(),
DriverDetailsView {
override val key by lazy { arguments.getLong(driverIdKey) }
override fun loadRefreshPanel() = object : LoadRefreshPanel {
override fun retryClicks(): Observable = RxView.clicks(retry_Button)
override fun refreshes(): Observable = Observable.never()
override fun render(vs: LRViewState<*>) {
retry_panel.visibility = if (vs.loadingError != null) View.VISIBLE else View.GONE
if (vs.loading) {
name_TextView.text = "...."
team_TextView.text = "...."
birthYear_TextView.text = "...."
}
}
}
override fun render(vs: LRViewState) {
super.render(vs)
if (!vs.loading && vs.loadingError == null) {
name_TextView.text = vs.model.driver.name
team_TextView.text = vs.model.driver.team
birthYear_TextView.text = vs.model.driver.birthYear.toString()
}
}
...
...
}
In this example, the key is stored in the fragment arguments. The model is displayed in the fragment method . An interface implementation is also created that is responsible for displaying the load. In the above example, it is not used for loading time , but instead data fields display dots, which symbolizes loading; appears in case of error, but update is not provided ( ).render(vs: LRViewState
LoadRefreshPanel
ProgressBar
retry_panel
Observable.never()
A demo application that uses the described classes can be found on GitHib .
Thanks for attention!