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 Contentotherwise – 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 interfacebut 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 copywhich is available in data classand 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 stateconsider 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 sealedand 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 classand 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *