18.
MVI Sample
Written by Aldo Olivares
In the previous chapter you learned all the theory behind the Model-View-Intent architecture pattern. You learned how MVI works and how each of its layers interact with each other.
Now you are going to use your new knowledge to rebuild the movies app using MVI. Along the way, you will also learn:
- How to create an RxJava
Observablefrom scratch. - How to create RxJava
Observables from previously created objects with methods such asjust(). - How to use a
PublishSubject. - How to use a
Disposableand aCompositeDisposable. - How to integrate Room with RxJava.
- How to integrate Retrofit with RxJava.
Ready? Lets get started.
Getting started
Start by opening the starter project for this chapter. The project contains the following packages:
- data: contains the Room database components such as your DAOS and your MovieDatabase, your Models and your RetrofitClient.
- domain: contains the MovieState class which you will use to represent the state of your App.
- view: contains the activities and adapters.
Take some time to familiarize yourself with the code because you will be spending a lot of time with each file during this chapter.
Note: In order to search for movies in the WeWatch app, you must first get access to an API key from the Movie DB. To get your API own key, sign up for an account at www.themoviedb.org. Then, navigate to your account settings on the website, view your settings for the API, and register for a developer API key. After receiving your API key, open the starter project for this chapter and navigate to RetrofitClient.kt. There, you can replace the existing value for
API_KEYwith your own.
Build and Run the App to verify that everything is working properly.
Right now it is just an empty canvas, but that is about to change.
Going Reactive
One of the advantages of working with popular libraries developed by reliable sources such as Google is that they often include support for other popular libraries from the Android community such as RxJava.
For the WeWatch App you are using two main libraries for your backend: Retrofit and Room.
Room was developed by Google as the data persistence solution for their new Architecture Components suite of libraries for Jetpack. Since the developers behind Room are well aware that most Android Developers will use Room alongside RxJava they took the time to develop an extension that lets you return Observable callbacks when the user creates, reads, updates or deletes a record in the database.
On the other hand, since Retrofit is probably the most popular library to interact with web services, the developers at Square created an adapter github.com/JakeWharton/retrofit2-rxjava2-adapter to integrate Retrofit with RxJava a long time ago. However, thanks to the popularity of his adapter, when Retrofit 2.2 came out it included first party support for RxJava as well.
In both cases, you only need to add a few lines of code to your app level build.gradle file to take advantage of the reactive capabilities of RxJava.
Add the following to dependencies block of your app level build.gradle file:
implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion"
implementation "androidx.room:room-rxjava2:$roomVersion"
Now that Room and Retrofit are ready to work with RxJava you need to modify your classes to return Observables.
Let’s start with Retrofit. Open the RetrofitClient.kt file under the data/net package and modify your Retrofit.Builder() call:
val retrofit = Retrofit.Builder()
.baseUrl(TMDB_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(okHttpClient)
.build()
This adds a RxJava2CallAdapterFactory which let’s you return an Observables from your service methods.
Now open the MovieApi.kt file and modify the searchMovies() method inside the same file to return an Observable:
@GET("search/movie")
fun searchMovie(@Query("api_key") api_key: String, @Query("query") q: String): Observable<MoviesResponse>
Next, return to the RetrofitClient.kt file and update the searchMovies() method there to return an Observable as well:
fun searchMovies(query: String): Observable<MoviesResponse> {
return moviesApi.searchMovie(API_KEY, query)
}
That is all you need to do to make Retrofit return an Observable each time your user calls the searchMovies() method. It is time for Room to be reactive too!
The Room integration with RxJava allows different return types according to the operation being performed in your Database like Insert, Delete, Update or Query.
Insert operations in Room allow you three different return types:
-
CompletablewhereonComplete()is called as soon as the insertion is successful. This is useful when you don’t need to know the ID of the new item inserted and there are no extra actions that need to be performed. -
Single<Long>orMaybe<Long>whereonSuccess()is called as soon as the insertion is successful and theLongvalue represents the ID of the item inserted. This is useful when you want to perform an additional operation immediately after the item is added to your database. -
Single<List<Long>>orMaybe<List<Long>same as above, but the value emitted ononSuccess()is the list of rows inserted.
Update/Delete operations allow you two different return types:
-
CompletablewhereonComplete()is called as soon as the update/delete is completed with no additional value returned. -
Single<Integer>orMaybe<Integer>whereonSuccess()is called as soon as the insertion/update is completed and theIntegervalue emitted represents the number of rows affected.
Query operations allow you three different return types:
-
Maybewhere the value emitted ononSuccess()is the object returned from your database. If there are no records in your database that match your queryMaybewill complete. If the object is later updated nothing will happen. -
Singlewhere the value emitted ononSuccess()is the object returned from your database. If there are no records in your database that match your querySinglewill callonError(EmptyResultSetException.class). If the object is updated nothing will happen. -
Flowable/Observablewhere the value emitted ononNext()is the object returned from your database. If there are no records in your database that match your queryFlowable/Observablewon’t emit anything, neitheronNext(), noronError(). If the object is later updatedObservable/Flowablewill automatically emit the updated object ononNext(), allowing you to take the appropriate action in your App.
Note: Pay special attention to the Query operation because depending on the return type the behavior will be very different. For example, if you only need to retrieve a single value from your database and display it to your user
Maybemight be the best choice. On the other hand, if you need to keep your UI updatedFlowable/Observablemight be the way to go since yourObservablewill keep emitting the new objects being added or modified in your database.
If you want to learn more about the return types of your Room database this article medium.com/androiddevelopers/understanding-migrations-with-room-f01e04b07929 from Google’s Android Developers blog might be very useful for you.
Open the MovieDao.kt file and modify the class methods like below:
//1
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(movie: Movie): Single<Long>
//2
@Query("select * from movie")
fun getAll(): Observable<List<Movie>>
//3
@Delete
fun delete(movie: Movie): Completable
Let’s walk through the above methods:
-
insert()helps you create a new movie record in your Database and returns aSinglewith the id of the new record created. -
getAll()returns all the movies stored in your database inside anObservable. Since this is aQueryoperation every time a new record is inserted, you will receive a notification throughonNext()and you can update your list of movies accordingly. -
delete()deletes the movie object passed as a parameter from your database and callsonComplete()as soon as the row has been deleted.
Now that Room and Retrofit are reactive and ready to be used it is time to create your Interactor.
Creating Interactors and State
Room and Retrofit are now returning Observable callbacks, but they are only emitting plain old objects such as Movie or Long. In the last chapter you learned that one of the main concepts of MVI is that you need to represent the State of your App such as Loading, Error or Data based on your models. You will use an Interactor as a way to interact with Retrofit and Room and to transform the responses into the corresponding State.
Open the MovieState.kt file under the data/domain package and add the following inside your MovieState class:
sealed class MovieState {
object LoadingState : MovieState() //1
data class DataState(val data: List<Movie>) : MovieState() //2
data class ErrorState(val data: String) : MovieState() //3
data class ConfirmationState(val movie: Movie) : MovieState() //4
object FinishState : MovieState() //5
}
Here you are using Kotlin’s sealed classes to represent 5 different and mutually exclusive States of your App:
- LoadingState as the name implies, represents the State in which your App is loading data, either from Room or from the TMDB API using Retrofit.
- DataState represents the State in which your App is ready to display some data to the user, in this case a list of movies.
- ErrorState represents an error in your App such as a Network Error.
- ConfirmState is used to confirm that the user wants to save a new movie in your Room database.
- FinishState used when a View is done with its tasks.
Now that your MovieState class is ready, it’s time to create your Interactor.
Create a new class under the data package, name it MovieInteractor and make it implement the Interactor interface like below:
class MovieInteractor : Interactor
Make sure to implement all the missing members using Ctrl+ I.
Add the following properties:
private val retrofitClient = RetrofitClient()
private val movieDao = db.movieDao()
This creates instances of your RetrofitClient and your MovieDao classes to interact with your Room database and the TMDB API.
Now implement the overridden methods like this:
//1
override fun getMovieList(): Observable<MovieState> {
return movieDao.getAll()
.map<MovieState> { MovieState.DataState(it) }
.onErrorReturn {
MovieState.ErrorState("Error")
}
}
//2
override fun deleteMovie(movie: Movie): Observable<Unit> = movieDao.delete(movie).toObservable()
//3
override fun searchMovies(title: String): Observable<MovieState> = retrofitClient.searchMovies(title)
.observeOn(Schedulers.io())
.map<MovieState> { it.results?.let { MovieState.DataState(it) } }
.onErrorReturn { MovieState.ErrorState("Error") }
//4
override fun addMovie(movie: Movie): Observable<MovieState> = movieDao.insert(movie)
.map<MovieState> {
MovieState.FinishState
}.toObservable()
Step by step:
-
getMovieList()uses yourmovieDao.getAll()method to retrieve the list of movies from your database. Themap()method helps you transform the list of movies to aMovieState.DataState. In case of an error you return aMovieState.ErrorState. -
deleteMovie()uses yourmovieDao.delete()method to delete aMovieobject from your database and transforms theCompletecallback to anObservablecallback. -
searchMovies()uses yourRetrofitClient’ssearchMovies()method to retrieve a list of movies from the TMDB API that match the given title passed as a parameter. Themap()operator transforms the response into a MovieState. Just likegetMovieList(), you return aMovieState.ErrorStatein case of a problem with the web service. -
addMovie()uses yourmovieDao.insert()method to insert a new movie in your database. As soon as the insertion is successful you return anObservablewith aMovieState.FinishState.
And that’s it! You now have a MovieInteractor that transforms your callbacks into the appropriate State for your App. The next step is to create your Presenters.
Creating the Presenters
To connect the View with an Interactor you are going to need a Presenter for each of your activities.
Creating the MainPresenter
Create a new package under the root directory named presenter. Inside this package create a new Kotlin class named MainPresenter.
MainPresenter should accept a MovieInteractor as a constructor argument like below:
class MainPresenter(private val movieInteractor: MovieInteractor)
Then, add the following properties:
private lateinit var view: MainView
private val compositeDisposable = CompositeDisposable()
In order to render the MovieState, you’ll need a reference to a MainView. The CompositeDisposable is needed to dispose of your Disposabless once your MainPresenter is destroyed.
What is a Disposable?
You will be reading the word Disposable a lot over the next sections, but what is a Disposable? A Disposable is the object returned by the subscribe() method each time you subscribe to an Observable. This object represents a reference to a disposable resource with which the caller can use to stop receiving events.
To put it simply, you can use this object to tell your subscribers that they can stop receiving items even if the Observable is still emitting objects.
Next, add the following code to your MainPresenter:
//1
fun bind(view: MainView) {
this.view = view
compositeDisposable.add(observeMovieDeleteIntent())
compositeDisposable.add(observeMovieDisplay())
}
//2
fun unbind() {
if (!compositeDisposable.isDisposed) {
compositeDisposable.dispose()
}
}
//3
private fun observeMovieDeleteIntent() = view.deleteMovieIntent()
.subscribeOn(AndroidSchedulers.mainThread())
.observeOn(Schedulers.io())
.flatMap<Unit> { movieInteractor.deleteMovie(it) }
.subscribe()
//4
private fun observeMovieDisplay() = movieInteractor.getMovieList()
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { view.render(MovieState.LoadingState) }
.doOnNext { view.render(it) }
.subscribe()
Taking each commented section in turn:
-
bind()lets you bind the correspondingMainViewto thisMainPresenterin your Activity’sonCreate()method. This method is also used to add yourDisposable’s to yourCompositeDisposable. -
unbind()is called when yourMainViewis destroyed. This method disposes of all theDisposable's added to yourCompositeDisposable. -
observeMovieDeleteIntent()is called when theMainViewsends an Intent to delete a movie. This method uses RxJavaflatMap()operator to call themovieInteractor.deleteMovie()method to delete a movie from the database. As you can see, you used theobserveOn()operator to specify that delete operation of your database has to be done on a worker thread rather than the UI thread. -
observeMovieDisplay()calls themainInteractor.getMovieList()method to retrieve the list of saved movies from your database. When you subscribe to thisObservableyou will use thedoOnSubscribe()method to tell the View to render theMovieState.LoadingState. As soon as there is a response with aMovieStateyou will call theview.render()method. Since yourMainViewhas to render the State on the main thread you have to specify it using theobserveOn()operator.
Note: You might notice that you didn’t have to specify a worker thread for the
movieInteractor.getMovieList()method. This is because thegetAll()method of yourMovieDaoreturns anObservableand according to the documentation all Observable queries are done off the main thread. On the other hand for Insert, Update and Delete operations that returnSingle,CompletableorMaybeyou need to use theobserveOn()operator to specify the Scheduler on which the operation will take place.
Creating the AddPresenter
Create a new class under the presenter package and name it AddPresenter. Replace everything inside with the following:
class AddPresenter(private val movieInteractor: MovieInteractor) {
//1
private val compositeDisposable = CompositeDisposable()
private lateinit var view: AddView
//2
fun bind(view: AddView) {
this.view = view
compositeDisposable.add(observeAddMovieIntent())
}
//3
fun unbind() {
if (!compositeDisposable.isDisposed) {
compositeDisposable.dispose()
}
}
//
fun observeAddMovieIntent() = view.addMovieIntent()
.observeOn(Schedulers.io())
.flatMap<MovieState> { movieInteractor.addMovie(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { view.render(it) }
}
The code for the AddPresenter class is very similar to the MainPresenter:
- You need an instance of your
AddViewto render the state of your App and aCompositeDisposableto dispose of yourDisposables when yourAddViewis destroyed. -
bind()is used to get an instance of yourAddViewand to add yourDisposablesto yourCompositeDisposable. -
unbind()disposes of all your activeDisposables when yourAddViewis destroyed. -
observeAddMovieIntent()is called whenAddViewsends an Intent to save a movie. TheflatMap()operator is used to call theaddMovie()method of yourMovieInteractorto add a record to your database.observeOn()is used to specify thataddMovie()should be called on a worker thread whileview.render()should be executed on the UI thread.
Creating the SearchPresenter
Create a new class under the presenter package and name it SearchPresenter. Replace everything inside with the following:
class SearchPresenter(private val movieInteractor: MovieInteractor) {
//1
private val compositeDisposable = CompositeDisposable()
private lateinit var view: SearchView
//2
fun bind(view: SearchView) {
this.view = view
compositeDisposable.add(observeMovieDisplayIntent())
compositeDisposable.add(observeAddMovieIntent())
compositeDisposable.add(observeConfirmIntent())
}
//3
fun unbind() {
if (!compositeDisposable.isDisposed) {
compositeDisposable.dispose()
}
}
//4
private fun observeConfirmIntent() = view.confirmIntent()
.observeOn(Schedulers.io())
.flatMap<MovieState> { movieInteractor.addMovie(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { view.render(it) }
//5
private fun observeAddMovieIntent() = view.addMovieIntent()
.map<MovieState> { MovieState.ConfirmationState(it) }
.subscribe { view.render(it) }
//6
private fun observeMovieDisplayIntent() = view.displayMoviesIntent()
.flatMap<MovieState> { movieInteractor.searchMovies(it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { view.render(MovieState.LoadingState) }
.subscribe { view.render(it) }
}
- You need an instance of your
SearchViewto render the State and aCompositeDisposable. -
bind()attaches yourSearchViewinstance to this class and adds all yourDisposables to yourCompositeDisposable. -
unbind()is called whenSearchViewis destroyed and uses yourCompositeDisposableto dispose of all theDisposables added to it. -
observeConfirmIntent()is called when theSearchViewsends a confirmation Intent. TheflatMap()operator is used to call theaddMovie()method of yourMovieInteractorand map the result to aMovieState. -
observeAddMovieIntent()is called when yourSearchViewsends an Intent that the user wants to add a new movie to the database. Themap()operator is used to transform the result into aConfirmationState. -
observeMovieDisplayIntent()is called when yourSearchViewwants to display a list of movies from the TMDB API that match the title passed as a parameter. Theflatmap()operator is used to call thesearchMovies()method of yourMovieInteractorand map the result to aMovieState.
Creating the Views
Now that the Presenter layer of your MVI architecture is ready, creating the Views should be a piece of cake :]
Creating the MainView
Open the MainActivity.kt file under the view/activity package. Right now this is an empty activity with a toolbar and a simple method to navigate to the AddMovieActivity once the user presses the + button, but that is about to change.
Start by making your MainActivity class implement the MainView interface like below:
class MainActivity : BaseActivity(), MainView {
The MainView has a simple contract given below:
interface MainView {
fun render(state: MovieState)
fun deleteMovieIntent(): Observable<Movie>
}
Make sure to implement all missing members by pressing Ctrl + I on your keyboard.
Add the following property to store a reference to your MainPresenter:
private lateinit var presenter: MainPresenter
And initialize it inside your onCreate() method:
presenter = MainPresenter(MovieInteractor())
presenter.bind(this)
The above code creates a new MainPresenter instance and uses its bind() method to send a reference to this View.
Next you’ll add some interactive controls to the list. You’ll make use of the left or right swipe gestures to delete a movie. Add the following code inside the deleteMovieIntent() method:
//1
val observable = Observable.create<Movie> { emitter ->
//2
val helper = ItemTouchHelper(
object : ItemTouchHelper.SimpleCallback(
0,
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
) {
//3
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return false
}
//4
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.getAdapterPosition()
val movie = (moviesRecyclerView.adapter as MovieListAdapter).getMoviesAtPosition(position)
emitter.onNext(movie)
}
})
//5
helper.attachToRecyclerView(moviesRecyclerView)
}
return observable
The above code creates a new Observable and emits the movie item that needs to be deleted every time the user swipes left or right. Step by step:
- You use the
create()method to create a newObservableinstance that emits aMovieobject. - Creates a new
ItemTouchHelperobject that can be attached to aRecyclerViewto add swipe and move capabilities to each one of the items being displayed. - For this app you are not implementing the
onMove()method. - Here you are overriding the
onSwiped()method that lets you know when a certain item is being swiped left or right in yourRecyclerView. In this case you use theEmitteronNext()method to send the item being deleted to your observers. - Finally, you attach the
ItemTouchHelperto yourMoviesRecyclerViewusing theattachToRecyclerView()method.
Add the following code inside your render() method:
when (state) {
is MovieState.LoadingState -> renderLoadingState()
is MovieState.DataState -> renderDataState(state)
is MovieState.ErrorState -> renderErrorState(state)
}
For the MainView you are handling three different States: Loading, Data and Error. Each one of them will be handled by a different function that you will implement.
You might notice that Android Studio is telling you that the methods called on render() have not been created. Lets do that right now by adding the following code:
//1
private fun renderLoadingState() {
moviesRecyclerView.isEnabled = false
progressBar.visibility = View.VISIBLE
}
//2
private fun renderDataState(dataState: MovieState.DataState) {
progressBar.visibility = View.GONE
moviesRecyclerView.apply {
isEnabled = true
(adapter as MovieListAdapter).setMovies(dataState.data)
}
}
//3
private fun renderErrorState(dataState: MovieState.ErrorState) = longToast(dataState.data)
-
renderLoadingState()will display aprogressBarand hide yourMoviesRecyclerView. -
renderDataState()will display yourMoviesRecyclerViewand hide theprogressBar. -
renderErrorState()will display a toast with the error message received from yourMainPresenter.
The only step left to finish implementing your MainView is to call your MainPresenter’s unbind() method when this activity is no longer going to be used. To do this override your MainActivitys onStop() method by adding the following code:
override fun onStop() {
presenter.unbind()
super.onStop()
}
This will tell your MainPresenter that your Disposable’s can be disposed since your Observables will no longer emit any new items.
Creating the AddView
Open the AddMovieActivity.kt file under the view/activity package.
Similar to what you did for your MainActivity, make your AddMovieActivity class implement the AddView interface and implement the missing members by pressing Ctrl + I. The AddView should already be given in the starter project, but is repeated below for clarity:
interface AddView {
fun render(state: MovieState)
fun addMovieIntent(): Observable<Movie>
}
Add the following property to store a reference to your AddPresenter:
private lateinit var presenter: AddPresenter
And initialize your AddPresenter inside onCreate():
presenter = AddPresenter(MovieInteractor())
presenter.bind(this)
The previous code just initializes the Presenter by creating an instance of your MovieInteractor, and binds it to this AddView.
Add this code inside render():
when (state) {
is MovieState.FinishState -> renderFinishState()
}
For AddView you are only handling one State, the Finish State.
Implement the Finish state by adding the following method:
private fun renderFinishState() = startActivity(intentFor<MainActivity>().newTask().clearTask())
This code simply returns to the MainActivity after your new movie has been inserted into the database.
Now you only need to implement the Intent to add a new movie to the database. Start by adding the following property at the top of your class:
private val publishSubject: PublishSubject<Movie> = PublishSubject.create()
What is a PublishSubject? Well according to the documentation:
A Subject is a sort of bridge or proxy that is available in some implementations of ReactiveX that acts both as an observer and as an Observable. Because it is an observer, it can subscribe to one or more Observables, and because it is an Observable, it can pass through the items it observes by reemitting them, and it can also emit new items.
A PublishSubject in RxJava is basically the fusion of an Observable with an observer, it can subscribe to another Observable and emit items to its own subscribers. It is very useful in some circumstances where you can’t easily modify the return type of certain functions and it can also help you simplify the code to create a new Observable.
In this case, your PublishSubject will emit Movie objects that have to be inserted in your Room database.
Use your new PublishSubject by adding the following code:
fun addMovieClick(view: View) {
if (titleEditText.text.toString().isNotBlank()) {
publishSubject.onNext(Movie(title = titleEditText.text.toString(), releaseDate = yearEditText.text.toString()))
} else {
showMessage("You must enter a title")
}
}
This method is executed when the user clicks the AddMovie button in your UI. If titleEditText is not blank it will tell your PublishSubject to emit the new Movie object on onNext() to its subscribers, otherwise it will display an error message.
Your PublishSubject is ready to emit new Movie objects when the user presses the Add Movie button. Now you only need to return your PublishSubject by adding the following code to your addMovieIntent() method:
override fun addMovieIntent() = publishSubject
Finally, just like you did for your MainView, you need to call your AddPresenter’s unbind() method inside onStop():
override fun onStop() {
super.onStop()
presenter.unbind()
}
Creating the SearchActivity
Open the SearchMovieActivity.kt file under the view/activity package. Make your SearchMovieActivity class implement the SearchView interface and implement all the missing members.
Add the following properties:
private lateinit var presenter: SearchPresenter
private val publishSubject: PublishSubject<Movie> = PublishSubject.create<Movie>()
This creates a SearchPresenter instance and a PublishSubject that emits Movie objects.
Add this code inside onCreate() to initialize your SearchPresenter and bind your SearchView:
presenter = SearchPresenter(MovieInteractor())
presenter.bind(this)
Now, add the following inside render():
when (state) {
is MovieState.LoadingState -> renderLoadingState()
is MovieState.DataState -> renderDataState(state)
is MovieState.ErrorState -> renderErrorState(state)
is MovieState.ConfirmationState -> renderConfirmationState(state)
is MovieState.FinishState -> renderFinishState()
}
For the SearchView you are handling 5 different States: Loading, Data, Error, Confirm and Finish.
Implement the functionality of each of the above States by adding the following methods:
//1
private fun renderFinishState() = startActivity(intentFor<MainActivity>().newTask().clearTask())
//2
private fun renderLoadingState() {
searchRecyclerView.isEnabled = false
searchProgressBar.visibility = View.VISIBLE
}
//3
private fun renderConfirmationState(confirmationState: MovieState.ConfirmationState) {
searchLayout.snack("Add ${confirmationState.movie.title} to your list?", Snackbar.LENGTH_LONG) {
action(getString(R.string.ok)) {
publishSubject.onNext(confirmationState.movie)
}
}
}
//4
private fun renderDataState(dataState: MovieState.DataState) {
searchProgressBar.visibility = View.GONE
searchRecyclerView.apply {
isEnabled = true
(adapter as SearchListAdapter).setMovies(dataState.data)
}
}
//5
private fun renderErrorState(errorState: MovieState.ErrorState) {
searchProgressBar.visibility = View.GONE
longToast(errorState.data)
}
-
renderFinishState()terminates the current activity and returns to theMainActivity. -
renderLoadingState()displays theprogressBarand hides thesearchRecyclerView. -
renderConfirmationState()displays aSnackBarasking the user to confirm the selected movie. If the user clicks the OK button in yourSnackbaryourPublishSubjectemits aMovieobject ononNext(). -
renderDataState()hides theprogressBarand displays the list of movies retrieved from the TMDB API in yourSearchRecyclerView. -
renderErrorState()displays a toast to the user with an error message.
The only step remaining to complete the SearchView is to create the Intents with the Observables for your subscribers.
Add the following methods to implement all the Intents for this View:
//1
override fun displayMoviesIntent(): Observable<String> = Observable.just(intent.extras.getString("title"))
//2
override fun addMovieIntent(): Observable<Movie> = (searchRecyclerView.adapter as SearchListAdapter).getViewClickObservable()
//3
override fun confirmIntent(): Observable<Movie> = publishSubject
-
displayMoviesIntent()creates anObservablethat emits the movie title passed to this Activity using thejust()method. -
addMovieIntent()creates anObservablethat emits aMovieobject each time an item is clicked in yourSearchRecyclerView. -
confirmIntent()returns yourPublishSubject.
And finally, override your Activity’s onStop() method to call your SearchPresenter’s unbind() just like you did for the previous Views:
override fun onStop() {
super.onStop()
presenter.unbind()
}
Your App is finally ready! Build and Run the App to see it in action:
Final thoughts
Take a closer look at all your Activities. As you can see, there is only one render() method that receives the current State from the Presenter and multiple Intents that represent the actions taken in your UI.
Also notice the unidirectional flow of information that this architecture provides by taking a look at your SearchView and the execution of displayMoviesIntent():
Having a structure like this makes it very easy to debug your App and guarantees that there is only one State at any given moment.
Also, you might remember from the previous chapter that State Reducers are an important concept of the MVI architecture. However, we didn’t have to use any State Reducers because the WeWatch App is rather simple and there were no States that relied on a previous State to be built.
Key points
-
Retrofit and Room include native RxJava support. This allows you to create
Observablecallbacks to which you can subscribe and react. -
The Room integration with RxJava allows different return types according to the operation being performed in your Database.
-
Insert operations in Room allow you three different return types:
Completable,Single<Long>andSingle<List<Long>>. -
Update and Delete operations in Room allow you two different return types:
CompletableandSingle<Integer>. -
Query operations in Room allow you three different return types:
Maybe,SingleandObservable. -
Views in MVI have a single
render()method that receives a state and renders it to the UI. -
Views in MVI have multiple Intent methods that represent an intention to do something.
Where to go from here?
For didactic purposes, you created most of your Observables for this App from scratch, but there are many libraries out there that help you create Observables from almost any kind of object.
One of the most popular libraries to create Observables from Views in Android is RxBinding github.com/JakeWharton/RxBinding. This library is very useful to create RxJava bindings for Android UI widgets such as buttons or textviews.
For example, if you wanted to create an Observable from a button click with traditional RxJava code you would do it like this:
Observable.create { emitter ->
button.setOnClickListener {
emitter.onNext(queryEditText.text.toString())
}
}
Instead, with RxBinding it is much simpler and easy to read:
button.clicks()
Another popular library is RxKotlin github.com/ReactiveX/RxKotlin, which adds convenient extension functions to RxJava. For example, take a look at the following code taken from the official repository:
val list = listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon")
list.toObservable() // extension function for Iterables
.filter { it.length >= 5 }
.subscribeBy( // named arguments for lambda Subscribers
onNext = { println(it) },
onError = { it.printStackTrace() },
onComplete = { println("Done!") }
)
You can instantly convert any Iterable (like a List) to an Observable using the toObservable() extension function!
Why don’t you take some time to rewrite some of the code in the App to use both libraries? It might be a fun challenge and you will learn a lot.