Overview of solutions for describing and updating screen state in Compose
Hi all. In this article, I'll look at how Compose can describe, update, and scale screen state using the MVI pattern.
State entity in MVI
MVI is an architectural pattern that is part of the UDF pattern family. Unlike MVVM, MVI implies only one data source. A visual representation of the pattern is shown in the figure below:
MVI contains three components: a logic and data layer (Model); UI layer that displays the state (View); and intents (Intent) – actions coming from the user when interacting with the View.
State is an entity that describes the current state that is displayed to the user through the View. There are many options for describing the screen state; below we will look at the various methods, their advantages and disadvantages.
How it was before Compose
Let's look at a screen laid out in XML. To describe the condition previously most often used sealed interface
:
sealed interface ScreenState {
data object Loading : ScreenState
data class Content(
val items: List<User>
) : ScreenState
data class Error(
val message: String
) : ScreenState
}
This state contains three states:
loading—show a progress indicator;
content – display a list of elements;
error—shows a data loading error.
An example view looks like this:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.state.observe(this, ::render)
viewModel.action(ScreenAction.LoadData)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_screen, container, false)
}
private fun render(state: ScreenState) {
when (state) {
is ScreenState.Content -> showContent(data = state)
is ScreenState.Error -> showError(data = state)
ScreenState.Loading -> showProgressBar()
}
}
Now let’s look at the diagram to see how the interface changes with user actions:
First, we load the data, then display the content, then the user actions during which it is necessary to update the data: we load against the background of the content that was loaded earlier, and then we update the screen display.
Status updates in viewModel
are presented below:
private val _state = MutableLiveData<ScreenState>()
val state: LiveData<ScreenState>
get() = _state
fun action(action: ScreenAction) {
when (action) {
ScreenAction.LoadData -> {
_state.value = ScreenState.Loading
viewModelScope.launch {
try {
_state.value = ScreenState.Content(loadData())
} catch (e: Exception) {
_state.value = ScreenState.Error(handlerError(e))
}
}
}
}
}
As you can see, the previous state is not stored. We set the status value at the beginning of the download, then, if successful, we execute Content
otherwise – Error
.
Let's consider scaling this approach. Let's add a new state for displaying a curtain with content. The state now looks like this:
sealed interface ScreenState {
data object Loading : ScreenState
data class Content(
val items: List<User>
) : ScreenState
data class Error(
val message: String
) : ScreenState
data class BottomSheet(
val title: String,
val content: List<String>
): ScreenState
}
Changes in view corresponding:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.state.observe(this, ::render)
viewModel.action(ScreenAction.LoadData)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_screen, container, false)
}
private fun render(state: ScreenState) {
when (state) {
is ScreenState.Content -> showContent(data = state)
is ScreenState.Error -> showError(data = state)
ScreenState.Loading -> showProgressBar()
is ScreenState.BottomSheet -> showBottomSheet(data = state)
}
}
As you can see, this solution is easily extensible. Now let's look at its application for a screen with Compose.
Using sealed class in Compose
View screen looks like this:
@Composable
fun Screen(
state: ScreenState
) {
when (state) {
is ScreenState.BottomSheet -> BottomSheetContent(state.title, state.content)
is ScreenState.Content -> Content(state.items)
is ScreenState.Error -> Error(state.message)
ScreenState.Loading -> ProgressBar()
}
}
ViewModel
remains unchanged. Now let’s look at the diagram to see how the interface changes with user actions:
First we load the data, then, if successful, display it. Then the user does something, and in accordance with this the data needs to be updated, a progress indicator is shown, but now against the background of a blank screen. And after loading we show the updated content.
The blank screen appears due to the fact that Compose changes the state when displaying loading, which hides the content display and we do not store the state of the previous data.
Let's assume that an error occurred while updating the data (reloading it). In our diagram it will look like this:
After closing the error, the user will see a blank screen. As you can see, this method does not work correctly for Compose. You can solve the problem, for example, like this:
private var userData: List<User> = emptyList()
fun action(action: ScreenAction) {
when (action) {
ScreenAction.LoadData -> {
_state.value = ScreenState.Loading
viewModelScope.launch {
try {
userData = loadData()
_state.value = ScreenState.Content(userData)
} catch (e: Exception) {
_state.value = ScreenState.Error(handlerError(e))
}
}
}
ScreenAction.CloseError -> {
_state.value = ScreenState.Content(userData)
}
}
}
We store the downloaded data in memory and use it to restore the content display state after hiding the error. In the diagram it looks like this:
Now let's look at a different approach: describing the screen state in Compose.
Using data class
Let us describe the state that was previously described using sealed interface
but this time with the help data class
:
data class ScreenState(
val isLoading: Boolean = false,
val content: List<User>? =null,
val error: String? = null,
val bottomSheet: BottomSheetContent? = null
)
data class BottomSheetContent(
val title: String,
val content: List<String>
)
Let's make changes to the function Compose
as follows:
@Composable
fun Screen(
state: ScreenState
) {
state.content?.let { data ->
Content(data)
}
state.bottomSheet?.let { data ->
BottomSheetContent(data.title, data.content)
}
state.error?.let { data ->
Error(data)
}
if (state.isLoading) {
ProgressBar()
}
}
ViewModel
will look like this:
private val _state = MutableLiveData<ScreenState>()
val state: LiveData<ScreenState>
get() = _state
private var userData: List<User> = emptyList()
fun action(action: ScreenAction) {
when (action) {
ScreenAction.LoadData -> {
_state.value = ScreenState(isLoading = true)
viewModelScope.launch {
try {
userData = loadData()
_state.value = ScreenState(
isLoading = false,
content = userData
)
} catch (e: Exception) {
_state.value = ScreenState(
isLoading = false,
error = handlerError(e)
)
}
}
}
ScreenAction.CloseError -> {
_state.value = ScreenState(
error = null,
content = userData
)
}
}
}
As you can see, there are special benefits in using data class
there is no way to describe the state: we also need to save the previous state, and in the function Compose
now it has become even less convenient to check the fields data class
to emptiness. But let's remember the method copy
which is available in data class
and instead of creating a new instance state
we will update it, below is the code in viewModel
:
private val _state = MutableLiveData(ScreenState())
val state: LiveData<ScreenState>
get() = _state
fun action(action: ScreenAction) {
when (action) {
ScreenAction.LoadData -> {
_state.value = _state.value?.copy(isLoading = true)
viewModelScope.launch {
try {
_state.value = _state.value?.copy(
isLoading = false,
content = loadData()
)
} catch (e: Exception) {
_state.value = _state.value?.copy(
isLoading = false,
error = handlerError(e)
)
}
}
}
ScreenAction.CloseError -> {
_state.value = _state.value?.copy(
error = null
)
}
}
}
We no longer need to store the data in memory separately and we save the state in the previous step. The user will see the same behavior as before using Compose.
In our example, a fairly simple state state
consider the case of a more complex screen:
data class ScreenState(
val isLoading: Boolean = false,
val content: Data? = null,
val error: String? = null,
val bottomSheet: BottomSheetContent? = null
)
data class Data(
val content: List<User>? = null,
val snackBar: SnackBar? = null
)
data class SnackBar(
val title: String,
val icon: Int
)
data class BottomSheetContent(
val title: String,
val content: List<String>
)
The content has become more complex and may contain data for display and snackBar
. Updated state inside viewModel
will look like this:
private val _state = MutableLiveData(ScreenState())
val state: LiveData<ScreenState>
get() = _state
fun action(action: ScreenAction) {
when (action) {
ScreenAction.LoadData -> {
_state.value = _state.value?.copy(isLoading = true)
viewModelScope.launch {
try {
_state.value = _state.value?.copy(
isLoading = false,
content = Data(
content = loadData(),
snackBar = null
)
)
} catch (e: Exception) {
_state.value = _state.value?.copy(
isLoading = false,
error = handlerError(e)
)
}
}
}
ScreenAction.CloseError -> {
_state.value = _state.value?.copy(
error = null
)
}
ScreenAction.ShowSnackBar -> {
_state.value = _state.value?.copy(
error = null,
content = _state.value?.content?.copy(
snackBar = SnackBar(
title = "title",
icon = 12
)
)
)
}
}
}
It is already difficult to update the state, there is a lot of nesting, and processing in the view is more complicated. Let's consider a solution that combines both methods.
Using data class with sealed class
Let's make changes to our state:
data class ScreenState(
val isLoading: Boolean = false,
val content: ContentState = ContentState.Shimmer,
val error: String? = null,
)
sealed interface ContentState {
data object Shimmer : ContentState
data class Data(
val content: List<User>? = null,
) : ContentState
data class BottomSheetContent(
val title: String,
val content: List<String>
) : ContentState
data class SnackBar(
val title: String,
val icon: Int
) : ContentState
}
We describe content through the interface sealed
and the state of the screen itself remains through data class
. ViewModel
will look like this:
private val _state = MutableLiveData(ScreenState())
val state: LiveData<ScreenState>
get() = _state
fun action(action: ScreenAction) {
when (action) {
ScreenAction.LoadData -> {
_state.value = _state.value?.copy(isLoading = true)
viewModelScope.launch {
try {
_state.value = _state.value?.copy(
isLoading = false,
content = ContentState.Data(
content = loadData(),
)
)
} catch (e: Exception) {
_state.value = _state.value?.copy(
isLoading = false,
error = handlerError(e)
)
}
}
}
ScreenAction.CloseError -> {
_state.value = _state.value?.copy(
error = null
)
}
ScreenAction.ShowSnackBar -> {
_state.value = _state.value?.copy(
content = ContentState.SnackBar(
title = "title",
icon = 12
)
)
}
ScreenAction.ShowBottomSheet -> {
_state.value = _state.value?.copy(
content = ContentState.BottomSheetContent(
title = "title",
content = loadContent()
)
)
}
}
}
As a result, the global state preserves the copying method via data class
and the content is easily scaled through sealed interface
. But it is worth remembering that in this content solution there is no state saving and a new state is set.
Resume
We looked at three different solutions to describe the screen state: using sealed interface
, data class
and a hybrid method that combines the first two. Each of the described methods has its own advantages and disadvantages.
Method with sealed interface
/class
allows for easy scaling, but it does not preserve the previous state. It is better to use it when you do not need to save the previous value state
.
Method using data class
allows you to easily save the previous state using the method copy
. But if the screen state is complex (with a lot of nesting), the complexity of the code increases. But the method is convenient when the screen is complex and there is no need for multiple nesting. Otherwise, you should think about using a hybrid method (data class
combined with sealed interface
), which combines saving the general state of the screen and describing the scalable state of some part of it. Although in this case the previous state of this very part is lost.
That is, the choice of method depends on the specific task and input data.