Introduction
When building an Android application, most of the time what we are doing is mapping actions directly, or indirectly, actions to some state of the UI.
In the process of using Architecture Components, achieving this is quite easy with the help of LiveData + Coroutine + ViewModels, but it also requires a bit of source code to set up for it.
The reason is that in order to listen for the “states” of a LiveDat, we must write a wrapper of its values, as well as integrate actions around these states.
Let’s take an example, there is a list based on UI where:
- data is downloaded from an API.
- User can swipe-refresh and try calling API again if there is an error, etc.
To meet these requirements, actions will be:
- Load
- Swipe Refresh
- Retry
And based on these actions, the UI state can be one of these at any given moment:
- Success
- Loading
- Swipe-Refreshing
- Failure
- SwipeRefresh-Failure
- Retrying
The State Machine
If we had mapped the states to the actions mentioned above using an expression – It should look like this:
Actions can be clear or implicit. The difference is that clear actions (shown by blue arrows) are actions caused by the user and implicit actions are not.
Let’s code it out!
State
Starting with State, create a wrapper that represents states using sealed classes .
1 2 3 4 5 6 7 8 9 |
sealed class UIState<out R> { object Loading : UIState<Nothing>() object Retrying : UIState<Nothing>() object SwipeRefreshing : UIState<Nothing>() data class Success<T>(val data: T) : UIState<T>() data class Failure(val exception: Exception) : UIState<Nothing>() data class SwipeRefreshFailure(val exception: Exception) : UIState<Nothing>() } |
Actions
Similar to states, we will create a wrapper to represent actions
1 2 3 4 5 6 |
sealed class Action { object Load : Action() object SwipeRefresh : Action() object Retry : Action() } |
LiveData
We will need to re-customize LiveData to handle all actions and spit out appropriate states based on them (similar to what reducers do in Redux).
It is also advisable to make API calls using Coroutines and pass exceptions.
The implementation process is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
class ActionStateLiveData<T>( private val coroutineContext: CoroutineContext, fetchData: (suspend () -> Response<T>) ) { private val action = MutableLiveData<Action>() private var data: T? = null // backing data val state = action.switchMap { liveData(context = coroutineContext) { when (action.value) { Action.Load -> { emit(UIState.Loading) } Action.SwipeRefresh -> { emit(UIState.SwipeRefreshing) } Action.Retry -> { emit(UIState.Retrying) } } try { val response = fetchData() val body = response.body() when { response.isSuccessful && body != null -> { data = body emit(UIState.Success(body)) } action.value == Action.SwipeRefresh -> { emit(UIState.SwipeRefreshFailure(Exception())) } else -> { emit(UIState.Failure(Exception())) } } } catch (exception: Exception) { when { action.value == Action.SwipeRefresh -> { emit(UIState.SwipeRefreshFailure(Exception())) data?.let { // emit success with existing data emit(UIState.Success(it)) } } else -> { emit(UIState.Failure(Exception())) } } } } } // Helpers for triggering different actions fun retry() { action.value = Action.Retry } fun swipeRefresh() { action.value = Action.SwipeRefresh } fun load() { action.value = Action.Load } } |
Process using new liveData block (which is actually suspend block) and emit method – We can execute source code asynchronously and emit values.
The switchMap block is also a new syntax for implementing Transformations.switchMap () on a mutable LiveData.
The last part is the method for submitting actions.
ViewModel
As we discussed Coroutines, we will specify scope as viewModelScope and use Dispatchers.IO as the coroutine context.
1 2 3 4 |
val users = ActionStateLiveData(viewModelScope.coroutineContext + Dispatchers.IO) { userService.fetchUsers() } |
viewModelScope —> Attaches our Coroutine lifecycle to ViewModel lifecycle.
Dispatchers.IO —> Execute the coroutine block asynchronously.
UI (Fragment / Activity)
Once all is done – The UI is pretty easy, we initialize the viewModel to generate the initial load action and listen for the results.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
val viewModel: ProfileViewModel by viewModels()// In onCreate viewModel.users.load()swipeRefreshLayout.setOnRefreshListener { viewModel.users.swipeRefresh() }retryButton.setOnClickListener { viewModel.users.retry() }viewModel.users.state.observe(this) { state -> when (state) { Loading -> // show progress bar Success -> // load up data Failure -> // show error Retrying -> // a different loader SwipeRefreshing -> // show swipe refresh loader SwipeRefreshingFailure -> // show error } } |
So that’s how it is now – This is a basic example of how we can use LiveData + Coroutine + ViewModel to map actions to UI States. These make it a little more difficult to deal with with pagination and unorthodox UI rules. We will also generalize them in the future.
Source
https://android.jlelse.eu/architecture-components-easy-mapping-of-actions-and-ui-state-207663e3fdd
Reference
P / S
Posts on my viblo, if there is a Source section, then this is a translation from the source that is linked to the original post in this section. These are the articles I select + search + synthesized from Google in the process of handling issues when making real + projects useful and interesting for myself. => Translated as an article to rummage through when necessary. Therefore, when reading the article, everyone should note: