Stojan Anastasov

Posted on • Originally published at lordraydenmk.github.io on

Modelling UI State on Android

The recommended approach from Google for Android development is holding the UI state in a `ViewModel` and having the `View` observe it. To achieve that one can use `LiveData`, `StateFlow`, `RxJava` or a similar tool. But how to model the UI state? Use a data class or a sealed class? Use one observable property or many? I will describe the tradeoffs between the approaches and present a tool to help you decide which one to use. This article is heavily inspired by Types as Sets from the Elm guide.

Photo by Marc-Olivier Jodoin on Unsplash

Types as sets

By Making Data Structure we can make sure the possible values in code exactly match the valid values in real life. Doing that helps to avoid a whole class of bugs related to invalid data. To achieve that, first we need to understand the relationship between Types and Sets.

We can think of Types as sets of values, they contain unique elements and there is no ordering between them. For example:

• `Nothing` - the empty set, it contains no elements
• `Unit` - the singleton set, it contains one element - `Unit`
• `Boolean` - contains the elements `true` and `false`
• `Int` - contains the elements: β¦ `-2`, `-1`, `0`, `1`, `2` β¦
• `Float` - contains the elements: `0.1`, `0.01`, `1.0` β¦.
• `String` - contains the elements: `""`, `"a"`, `"b"`, `"Kotlin"`, `"Android"`, `"Hello world!"`β¦

So when you write: `val x: Boolean` it means `x` belongs to the set of `Boolean` values and can be either `true` or `false`.

Cardinality

In Mathematics, Cardinality is the measure of βnumber of elementsβ of a Set. For example the set of `Boolean` contains the elements `[true, false]` so it has a cardinality = 2.

Letβs take a look at the cardinality of the sets mentioned above:

• `Nothing` - 0
• `Unit` - 1
• `Boolean` - 2
• `Short` - 65535
• `Int` - β
• `Float` - β
• `String`- β

Note : The cardinality of `Int` and `Float` is not exactly infinity, itβs 2^32 however that is a huge number.

When building apps, we use built-in types and create custom types using constructs like data classes and sealed classes.

Product Types (*)

One flavor of product types in Kotlin are `Pair` and `Triple`. Letβs take a look at their cardinality:

• `Pair<Unit, Boolean>` - cardinality(Unit) * cardinality(Boolean) = 1 * 2 = 2
• `Pair<Boolean, Boolean>` - 2 * 2 = 4

`Pair<Unit, Boolean>` contains the elements:

• `Pair(Unit, false)`
• `Pair(Unit, true)`

Other examples:

• `Triple<Unit, Boolean, Boolean`> = 1 * 2 * 2 = 4
• `Pair<Int, Int>` = cardinality(Int) * cardinality(Int) = β * β = β

Well, that escalated quickly.

When combining types with `Pair/Triple` their cardinalities multiply (hence the name Product Types).

`Pair/Triple` are the generic version of data classes with 2 and 3 properties respectively.

`data class User(val emailVerified: Boolean, val isAdmin: Boolean)` has the same cardinality as `Pair<Boolean, Boolean>`, 4. The elements are:

• `User(false, false)`
• `User(false, true)`
• `User(true, false)`
• `User(true, true)`.

Sum Types (+)

In Kotlin we use sealed classes to implement Sum types. When combining types using sealed classes, the total cardinality is equal to the sum of the cardinality of the members. Some examples are:

``````sealed class NotificationSetting
object Disabled : NotificationSettings() // an object has one element -> cardinality = 1
data class Enabled(val pushEnabled: Boolean, val emailEnabled: Boolean) : NotificationSettings()

// cardinality = cardinality (Disabled) + cardinality(Enabled)
// cardinality = 1 + (2 * 2)
// cardinality = 1 + 4 = 5

sealed class Location
object Unknown : Location()
data class Somewhere(val lat: Float, val lng: Float) : Location()

// cardinality = cardinality (Unknown) + cardinality(Somewhere)
// cardinality = 1 + (β * β)
// cardinality = 1 + β = β

``````

Nullable Types

Another way to model the `Location` type is using nullable types `data class Location(val lat: Float, val lng: Float)` and represent it as: `val location: Location?`. In this scenario we use `null` when the location is unknown. These two representations have the same cardinality and we can convert between them without any information loss. A few more examples:

• `Unit?` - cardinality = 2 (1 + cardinality(Unit))
• `Boolean?` - 3 (1 + cardinality(Boolean))

The elements of a nullable type `A?` are `null` + the elements of the original type `A`. The cardinality of a nullable type is 1 + the cardinality of the original type.

Enums

Enums are another way of representing Sum types in Kotlin:

``````enum class Color { RED, YELLOW, GREEN }

``````

The cardinality of `Color` is equal to the number of elements, in this case 3. An alternative representation using a sealed class is:

``````sealed class Color
object Red : Color()
object Yellow : Color()
object Green : Color()

``````

and it has the same cardinality - 3.

Why does it matter

Thinking about Types as Sets and their cardinality helps with data modelling to avoid a whole class of bugs related to Invalid Data. Letβs say we are modelling a traffic light. The possible colors are: red, yellow and green. To represent them in code we could use:

a `String` where `"red"`, `"yellow"` and `"green"` are the valid options and everything else is invalid data. But then the user types `"rad"` instead of red and we have an issue. Or `"yelow"` or `"RED"`. Should all functions validate their arguments? Should all functions have tests? The root cause of the issue here is cardinality. A String has cardinality of β while the our problem has 3. There are β - 3 possible invalid values.

a data class `data class Color(val isRed: Boolean, val isYellow: Boolean, val isGreen: Boolean)` - here `Color(true, false, false)` represents red. Yet this still leaves room for invalid data e.g. `Color(true, true, true)`. Again you would need checks and tests to ensure values are valid. The cardinality of the data class `Color` is 8 and it has 8 - 3 = 5 illegal values. Itβs much better then the `String`, but we can still improve it.

an enum - `enum class Color { RED, YELLOW, GREEN }` - this has a cardinality = 3. It matches exactly the possible valid values of the problem. Illegal values are now impossible, so there is no need for tests that check data validity.

By modelling the data in a way that rules out illegal values, the resulting code will also end up shorter, more clear and easier to test.

Make sure the set of possible values in code match the set of valid values of the problem and a lot of issues disappear.

Exposing State from a ViewModel

Build an app that calls a traffic light endpoint and shows the color of the traffic light (red, yellow or green). During the network call show a `ProgressBar`. On success show a `View` with the color and in case of an error show a `TextView` with a generic text. Only one view is visible at a time and there is no possibility to retry errors.

``````class TrafficLightViewModel : ViewModel() {

val state: LiveData<TrafficLightState> = TODO()
}

``````

I will represent the color as an enum with three values. How should the type `TrafficLightState` look like?

``````data class TrafficLightState(
val isError: Boolean,
val color: Color?
)

``````

one way is a data class with three properties. Yet this has possible invalid states e.g. `TrafficLightState(true, true, Color.RED)`. Both error and loading are true. Showing both the `ProgressBar` and the error `TextView` should not be possible in the UI. We also have a Color which is impossible in case of an error. The cardinality of `TrafficLightState` is 2 (Boolean) * 2 (Boolean) * 4 (Color?) = 16.

What about using many observable properties?

``````class TrafficLightViewModel : ViewModel() {

val error: LiveData<Boolean> = TODO()

val color: LiveData<Color?> = TODO()
}

``````

This has a cardinality of 2 (Boolean) * 2 (Boolean) * 4 (Color?) = 16. it has the same cardinality as the data class approach and enables illegal values.

Another approach is using a sealed class:

``````sealed class TrafficLightState
object Error : TrafficLightState()
data class Success(val color: Color) : TrafficLightState()

``````

which has a cardinality of 1 (Loading) + 1 (Error) + 3 (Success) = 5 which exactly matches the possible states of the problem:

• during the network call: `Loading` and show the `ProgressBar`
• if the network call fails: `Error` and show a `TextView`
• on success: `Success` and show a view with the corresponding color

These are all valid approaches and when designing your UI state. But only one of them matches exactly the possible valid states of the problem. To eliminate bugs, simplify code and reduce the number of tests, use that one.

Conclusion

To model problems in code we use built-in types like: `Boolean`, `Int`, `String`β¦ and in Kotlin we also create custom types using constructs like data class and sealed class. Different language constructs have different effects on the cardinality of the model. Reduce the number of invalid states by picking the right combination. That will results in simpler and more robust code.

Thanks GaΓ«l for the review.