Simplifying UI States with Kotlin Sealed Classes and Data Binding

Introduction

“Sealed classes are used for representing restricted class hierarchies…”

As such, Sealed Classes are useful when modeling states within various app workflows. These could represent network operations, available features, UI representations, etc.

Our Pain Point

We commonly implement a MVVM pattern for new screens. Generally, we create a ViewModel class and use Data Binding to bind our ViewModel data to the UI.

This has worked well for us.

Over time, we began to notice several common patterns in each screen:

  • show initial loading indicator
  • handle failure with a common error screen
  • provide a meaningful empty state in absence of data
  • display the final, loaded data

It became apparent that we could benefit from some standard mechanism with which to model and display these states.

Our Solution

Modeling UI States With Sealed Classes

Because we typically want to represent, and transition through, a small number of common states we turned to sealed classes.

We created the following class hierarchy:

gist available here: https://gist.github.com/n8ebel/93bac696a18127394c272095f450c2fb#file-uistates-kt

We can then transition through these states within our ViewModels’ lifecycle.

Exposing UI States

To expose this state to our Binding object, or any other necessary observers, we rely on an ObservableField.

Once our data has been loaded, or a failure has occurred, we transition states by setting the appropriate value to our bound Observable.

Depending on how our data flow is modeled, we may be able to handle these state transitions in a single place such as in the subscribe block of an rxjavaobservable chain, or we may have to handle multiple points of failure.

In most instances, we are able to handle these state transitions in a very limited set of locations and lean closely towards a single stream of data & transformations.

Binding UI States

Once the UI state is updated, we are ready update the actual view elements based on the exposed UiState.

To simplify and unify this process, we’ve created a small set of BindingAdapters to cover the majority of our common uses cases.

gist available here: https://gist.github.com/n8ebel/93bac696a18127394c272095f450c2fb#file-uistatebindingadapters-kt

With these BindingAdapters in place, connecting our individual Viewelements to our UiState becomes simple and consistent across screens.

For many views, it might simply require adding an adapter to the parent ViewGroup or perhaps adding the same BindingAdapter expression to multiple Views.

For example, if multiple sibling Views should be shown for HasData, then we can add app:uiState="@{viewModel}" for consistent visibility control for all.

Conclusion

This pattern has helped bring consistency and efficiency to how we model and build these common UI states for new screens in our app. It’s been an evolving process, and we will continue improve this as our use cases dictate.

Limitations & Open Questions

HasData is limited in what it represents

Our HasData state currently represents the presence of data, but not the actual UI representation of that data. It allows us to essentially turn on & off the visibility of view elements, but doesn’t control the actual visual state of those elements.

As we start to explore more in the way of Unidirectional Data Flow and further consolidate our data flows, I see us possibly expanding the functionality of HasData to also include the actual data that should be bound within the xml.

Multiple points of possible failure in UiState transition

While we expose our UiState from a single property on our ViewModels, that state is sometimes updated in multiple places within the ViewModel lifecycle. This does open the possibility for additional points of failure where the UI may not be updated properly.

The most common occurrence of this is in onError handling for rxjavastreams. If we have multiple separate observable chains, we must ensure we ultimately call uiState.set(Error) from each of them. However, this is really more of an indicator of how a single flow of data can reduce the number of points of error

There are multiple, very similar, BindingAdapters

If we created some type of custom view to encapsulate the UI for our loading, empty, or error screens, we could possibly reduce the number of binding adapters we would need.

This also would reduce the number of places with the xml that we are required to add a BindingAdapter expression.