Kotlin ViewState Pattern

Kotlin ViewState Pattern

Often in an Android project, you want to load something from the network, display wait indicators, display errors, or display the resulting loaded data to the user on a screen or multiple screens. There have been many frameworks that provide ways of doing this, one that comes to mind is Mavericks. But such frameworks bring with them a whole load of assumptions and code that solve for various use cases that may not be needed in an app. So, the idea was to keep things simple. How could the same thing be accomplished using only a few Kotlin features?

In several projects, I have used the following pattern that I've been calling ViewState. I think the name came from an iOS project that I was hired to write an Android version of, and the co-developer Brandon Evans, had named the construct. It's as good a name as any.

ViewState Through Generic Sealed Classes

Kotlin has sealed classes, which have the distinct benefit of encouraging the developer to handle all of the specific cases in when statements. This is nice. So, let's define a short set of sealed classes for ViewState:

sealed class ViewState<out T : Any> {
    
    object Initial : ViewState<Nothing>()
    
    object Loading : ViewState<Nothing>()
    
    object Empty : ViewState<Nothing>()
    
    data class Error(
      val errorCode: ErrorCode,
      val actualError: Any? = null
    ) : ViewState<Nothing>()

    data class Populated<T : Any>(
      private val value: T
    ) : ViewState<T>() {
        operator fun invoke(): T = value
    }
}

With this definition, it's possible to have some data in your app transition from an initial state, through a loading state, and either return nothing (the empty state) or return a populated state. Sometimes there might be an error, and this can also be represented as ViewState. In this case errors are represented by an enumeration called ErrorCode. This implementation doesn't matter much actually, whatever you want to pass in the error data is really up to you.

Returning Populated ViewState

You probably want to be able to react when the ViewState is finally populated. One way of doing this is with a simple function that returns the populated item, or null.

fun <T : Any, R : Any?> ViewState<T>?.withState(block: (T) -> R?) =
    this?.let {
        when (it) {
            is ViewState.Populated -> {
                block(it())
            }
            else -> null
        }
    }

If you are using coroutines, you might want to put ViewState into a MutableStateFlow. An extension function could be written to get the values like this.

fun <T : Any, R : Any?> MutableStateFlow<ViewState<T>>.withState(block: (T) -> R?) =
    when (val value = this.value) {
        is ViewState.Populated -> {
            block(value())
        }
        else -> null
    }

Setting the ViewState value

The following function can be used to emit a value in a state flow based on the current value of the flow.

suspend fun <T : Any> MutableStateFlow<ViewState<T>>.setState(block: suspend T.() -> T) {
    when (val value = this.value) {
        is ViewState.Populated -> {
            emit(ViewState.Populated(block(value())))
        }
    }
}

Combining the Loading states of two ViewStates

Sometimes you'd want to tie a couple of ViewState loads together, so that both should finish before transitioning to the populated state. You could accomplish this with a function like the following:

/**
 * Combine the loading states of two ViewStates
 */
fun <T : Any, R : Any> combineViewStateLoading(
    first: ViewState<T>,
    second: ViewState<R>): ViewState<Pair<ViewState<T>,ViewState<R>>> {

    return when {
        first is ViewState.Initial || second is ViewState.Initial -> ViewState.Initial
        first is ViewState.Loading || second is ViewState.Loading -> ViewState.Loading
        first is ViewState.Error -> first
        second is ViewState.Error -> second
        else ->
            ViewState.Populated(Pair(first, second))
    }
}

Using ViewState with Jetpack Compose

Jetpack Compose is all the rage, and for good reason. It's a nice way to write Android apps. The following ViewStateScaffold definition provides a nice framework for hanging the various different states onto. It allows you to provide specific UI for each of the different states, or skip some if you don't need them. The main populated block would be given the populated data.

@Composable
fun <T : Any> ViewStateScaffold(
    viewState: ViewState<T>,
    modifier: Modifier = Modifier,
    initial: @Composable BoxScope.() -> Unit = {},
    loading: @Composable BoxScope.() -> Unit = {},
    empty: @Composable BoxScope.() -> Unit = {},
    error: @Composable BoxScope.(error: ViewState.Error) -> Unit = {},
    populated: @Composable BoxScope.(T) -> Unit,
) {
    Box(modifier) {
        when (viewState) {
            is ViewState.Loading -> loading()
            is ViewState.Empty -> empty()
            is ViewState.Error -> error(viewState)
            is ViewState.Populated -> populated(viewState())
            ViewState.Initial -> initial()
        }
    }
}

Data Flows using ViewState

You may be using a ViewModel, you might be using something else, really that doesn't matter much. Let's just imagine we have a flow defined as follows:

val dataFlow = MutableStateFlow<ViewState<List<DataItem>>>(ViewState.Initial)

This defines a flow of list of DataItem. Great. So you'd probably want to load some data into it.

    private fun loadData() {
        viewModelScope.launch {
            watchlistFlow.emit(ViewState.Loading)
            runCatching {
                obtainData()
            }.onSuccess {
                processResult(it)
            }.onFailure {
                dataFlow.emit(
                   ViewState.Error(ErrorCode.WATCHLIST_ERROR)
                )
            }
        }
    }

    private suspend fun processResult(data: List<DataItem>) {
        if (data.isEmpty()) {
            dataFlow.emit(ViewState.Empty)
        } else {
            dataFlow.emit(ViewState.Populated(data))
        }
    }

Here the function obtainData() is a suspend function that returns the data asynchronously. This could call a use case in your data layer. This could do some GraphQL call, or even just call some Retrofit API directly. It doesn't matter, you know what you need to load.

Here, runCatching is the Kotlin Result pattern, if you like try blocks better, use those. It's alright.

Conclusion

So, as you can see, sometimes simple constructs can lead to an abstraction that you can easily build user interfaces for. If there is demand, I may write a small example that uses these building blocks to demonstrate how they would be used in practice. I'm sure you already get the idea. Have fun, and don't worry about learning a new state management library.