Representing UI state using ViewModel and Repository
This article is related to Organizing API services and repositories using Retrofit.
Make sure to read it first!
It is strongly suggested you start using Jetpack Compose — this implies using declarative UI → call your composables with an updated state, whether it is coming from a local object instantiation, local-storage, or network call.
Thus, you need some wrappers to represent these states easily.
For legacy applications still using imperative UI (XML, DataBinding, ViewBinding, and the old android.widget.*
toolkit) the same principle should apply → based on the state, you will update the view.
UI vs. State
Ideally, every single UI-artifact should represent the state of “something” — a result of some operation. In most cases, such operation is performed by a ViewModel that exposes some data in the form of an observable.
This usually was represented through LiveData
. This has changed and the recommended observable is StateFlow
— there are several resources around this topic. Start with this one.
The whole idea is to streamline all the internal mechanisms while maintaining two main principles: Single Source of Truth (SSoT) and Separation of Concerns (SoC). This can be simplified as:
- How data is retrieved, changed: Repository ← → ViewModel
- How data is manipulated within the app: ViewModel → observables
- How data is displayed in UI: observables → composables / views
- How data may change the UI: observables → composables / views + logic
The ViewModel
coordinates and decouples most of the mechanisms, and pulishes updates through observables — and this is the key for everything!
You have two choices:
ViewModel
exposes observables as pure raw data-classes: your code will then use the values to upate the views → this implies you may need several observables — things can get messy!ViewModel
exposes observables as UI-state: the data-classes provide complete UI information, or there’s a full understanding on what needs to be rendered and how using a Data Model can fit all these UI requirements → this is highly preferred for Jetpack Compose.
In any case, you will need structures to reflect different states:
- Loading / Processing: signal UI that something is ongoing and show a spinner
- Success: signal UI that new data is available
- Empty: signal UI that no data is available
- Failure: signal UI that last operation has errors
These different states can be represented as:
/** Represents a result from a repository.
* Note that this rseult could be coming from:
* - An API call
* - Room Database
* - Function
* - Or, an arbitrary instance creation
*
* Similar to [kotlin.Result], but adds 2 more states: [Processing], [Empty]
*/
sealed class RepoResult<out T> {
/** Represents the "processing" state. */
class Processing : RepoResult<Nothing>()
/** Represents a success with payload [T]. */
data class Success<out T>(
val data: T,
) : RepoResult<T>()
/** Represents a success without any payload. */
data object Empty : RepoResult<Nothing>()
/** Represents a failure. */
data class Failure(
// Ideally, you should have a standard throwable to represent errors
val error: Throwable,
) : RepoResult<Nothing>()
}
Note that I’ve called this class RepoResult
— main reason is because it is the Repository
that should always return any result. Whether you use Retrofit, Room, or any other library, the Repository
should be wrapping using RepoResult
, making other components (ViewModel
, Composables
) to manipulate all the “data states” and “data values” in the same way, promoting reusability.
Thus, it is fairly simple to define a common mechanisms within a ViewModel
to publish the results and use standard patterns on how to update the view:
val uiState: RepoResult<SomeDataClass> = ...
when (uiState) {
is RepoResult.Empty -> { ... }
is RepoResult.Failure -> { ... }
is RepoResult.Processing -> { ... }
is RepoResult.Success -> { ... }
}
To simplify our examples, let’s define our UI-state model:
data class TestUiState(
/** Just some text that will be displayed. */
val text: String,
/** An amount already formatted. */
val amount: String,
)
Traditional Approach: Imperative UI
First, check Decoupling Binding — in summary:
Good! Now let’s go through the old legacy approach using Fragment
and ViewBinding
:
class TestViewModel : ViewModel() {
private val _uiState = MutableLiveData<RepoResult<TestUiState>>()
val uiState: LiveData<RepoResult<TestUiState>> = _uiState
/** Any arbitrary asynchronous operation.
* Ideally, this should be running within a coroutine.
* Any changes will be notified to `uiState` observers.
*/
fun doOperation() = viewModelScope.launch {
// You may want to...
// _uiState.postValue(RepoResult.Processing())
// ... to show some "I'm busy" message to the user
_uiState.postValue(...)
/* May be:
* _uiState.postValue(callApi())
* _uiState.postValue(queryRoom())
* _uiState.postValue(callAnotherFunction())
* or just
* _uiState.postValue(RepoResult.Success(TestUiState(...)))
* _uiState.postValue(RepoResult.Empty))
* _uiState.postValue(RepoResult.Processing))
* _uiState.postValue(RepoResult.Failure(Error()))
*/
}
}
/** If using imperative UI - XML+Fragment
* A Fragment is just the glue between Android system, the "view", and ViewModel:
* - Initialize ViewModel
* - Observe state
* - Update the view
*/
class TestFragment : Fragment() {
/** Auto-genearated ViewBinding for `fragment_test.xml`.
* Contains:
* - 2 TextViews: `title` and `amount` (to simplify things)
* - One Button `action` to trigger a state change
*/
private var binding: FragmentTestBinding? = null
private val viewModel: TestViewModel by viewModels<TestViewModel>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
FragmentTestBinding.inflate(inflater, container, false).also {
binding = it
}.root
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding?.setup(viewModel, viewLifecycleOwner)
}
override fun onDestroy() {
super.onDestroy()
binding = null
}
}
/** Setup the view. */
private fun FragmentTestBinding.setup(viewModel: TestViewModel, owner: LifecycleOwner) {
this.action.setOnClickListener {
viewModel.doOperation()
}
viewModel.uiState.observe(viewLifecycleOwner) {
binding?.updateUI(it)
}
}
/** Decouples all UI related operations. */
private fun FragmentTestBinding.updateUI(uiState: RepoResult<TestUiState>) {
when (uiState) {
is RepoResult.Empty -> {
this.title.text = this.root.context.getString(R.string.empty)
this.amount.isVisible = false
}
is RepoResult.Failure -> {
// Show a banner, toast, dialog
}
is RepoResult.Processing -> {
// Show a spinner
}
is RepoResult.Success -> {
// Update the view
this.title.text = uiState.data.text
this.amount.text = uiState.data.amount
this.amount.isVisible = true
}
}
}
This pattern will save you a lot of headaches — you probably should be refactoring your code — migrating over Jetpack Compose will be much easier in the future.
Modern Approach: Declarative UI
You must be familiar with Jetpack Compose
and StateFlows
.
Instead of LiveData
, we will use StateFlow
:
The main difference is that composables will “collect” states that are “emitted” through StateFlows
from the ViewModel
— simple as that.
UiStateViewModel
generalizes these concepts, defining all the scafolding, provided you follow the UI-as-state pattern — that is, all the UI is based on composables observing one state:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
abstract class UiStateViewModel<T>(
initial: RepoResult<T> = RepoResult.Processing,
) : ViewModel() {
/** Internal UI-state - use `_uiState.emit(..)` to publish updates. */
private val _uiState: MutableStateFlow<RepoResult<T>> =
MutableStateFlow(initial)
/** Internal UI-state - use `_uiState.emit(..)` to publish updates. */
val uiState: StateFlow<RepoResult<T>> =
_uiState.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = initial,
)
/** Updates [uiState], based on the [block] return value.
*
* Concrete View-Model implementations should use this function when a suspend function is required.
*
* @param emitProcessing If `true`, it will emit [RepoResult.Processing]. Default: `false`.
* @param block Should return a new [RepoResult]. Receives current value.
*/
protected fun emitState(
emitProcessing: Boolean,
block: suspend (current: RepoResult<T>) -> RepoResult<T>,
): Job =
viewModelScope.launch {
val current = _uiState.value
if (emitProcessing) {
emitProcessing()
}
_uiState.emit(block.invoke(current))
}
/** Updates [uiState], based on the specified [value].
*
* @param value Value to emit.
*/
protected suspend fun emitState(value: RepoResult<T>) {
_uiState.emit(value)
}
/** Updates [uiState], based on the specified [value].
*
* @param value Value to emit as a [RepoResult.Success]. If `null`, then [RepoResult.Empty] is emitted.
*/
protected suspend fun emitState(value: T?) {
if (value == null) {
emitEmpty()
} else {
_uiState.emit(RepoResult.Success(value))
}
}
/** Emits [empty] state. */
protected suspend fun emitEmpty() {
_uiState.emit(RepoResult.Empty)
}
/** Emits [processing] state. */
protected suspend fun emitProcessing() {
_uiState.emit(RepoResult.Processing)
}
/** Updates [uiState], based on the specified throwable.
*
* @param e Any throwable.
*/
protected suspend fun emitFailure(e: Throwable) {
_uiState.emit(RepoResult.Failure(e))
}
}
// ======================================================
// Here starts your application code.
data class TestUiState(
/** Just some text that will be displayed. */
val text: String,
/** An amount already formatted. */
val amount: String,
)
class TestViewModel(
initial: RepoResult<TestUiState>,
) : UiStateViewModel<TestUiState>(initial) {
/** When doing an operation, you must emit a 'RepoResult<TestUiState>' by calling any inherited `emitXXX` functions.
* For this example, we are using the lambda version that will emit a [RepoResult.Processing] first.
*/
suspend fun doOperation() = emitState(emitProcessing = true) {
// There may be some steps that will take 1 or 2 seconds - UI already received a Processing state.
// Finally, we emit a new 'TestUiState'
RepoResult.Success(TestUiState("new state", "$1,000"))
}
}
/** Simple composable - just to put things together */
@Composable
fun TestUi(viewModel: TestViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is RepoResult.Empty -> {
EmptyTestUi()
}
is RepoResult.Failure -> {
// Show a banner, toast, dialog
FailedTestUi()
}
is RepoResult.Processing -> {
// Show a spinner
BusyTestUi()
}
is RepoResult.Success -> {
SuccessTestUi(uiState)
}
}
}
Each *TestUi
composable function will focus on render the proper state, ensuring SoC principle. And yes, this last example is very simple, since explaining Jetpack Compose will be way too long.
There are other techniques to represent all the UI using states — Dynamic Content. The paradigm shift may be confusing at first — make sure to read Thinking in Compose!