Building Passive UI in Jetpack Compose
Declarative UI is often described as creating UI that is "functional" in its nature, that is to say that data goes in and UI comes out. This is wonderful in the abstract but in a world where apps without side-effects are not particularly useful to end users, how can this be accomplished? This post will look at how to apply concepts and patterns to create "Passive" or "Dumb" UI; UI that doesn't do anything except show visuals. In many ways this post is a spiritual successor to a previous post on StateHolders so it may be helpful to get a refresher on those ideas before continuing this post.
Passive UI #
In his 2006 blog post titled "Passive View" Martin Fowler introduces a concept of abstracting view logic into the controller of an MVC architecture to remove the dependency between the View and the Model, treating the Controller as an arbitrator between the two other components. Building on this idea, one can envision a ui composable that has no internal state and relies entirely on an external coordinator. That coordinator would manage and update state before sending the unified state object into the component to render. This separates the rendering logic from the state manipulation logic, giving a clear boundary or responsibilities. Here is an example to help illustrate this idea.
A note before continuing, there will be discussion of uppercase "C" Composable functions, that is Composable component functions rendered by Jetpack Compose and lowercase "c" composable functions that are functions designed to be reused and combined to share code. The casing will be the distinction as needed.
Example #
To show what is being accomplished, this example will be to work backwards from a less ideal state towards a more ideal state. To start, here is a component mixing state and rendering logic into a single component.
@Composable
fun ToggleInput() {
var isOn by remember { mutableStateOf(false) }
LaunchedEffect(isOn) {
if (isOn) {
Log.i("Toggle", "Turned Input On")
}
}
val title = if (isOn) {
R.string.toggle_state_on
} else {
R.string.toggle_state_off
}
Column {
Text(text = stringResource(title))
Checkbox(
checked = isOn,
onCheckedChange = {
isOn = !isOn
}
)
}
}
This component is doing a ton of work! It is...
- Rendering a toggle component
- Retrieving a string resource based on the toggle state
- Rendering the toggle state string
- Logging when the toggle value is on
- Handling the logic to toggle the
isOn
flag
The logic can be split out from the rendering so all the ui component cares about is showing the title and checkbox and calling updateToggle.
data class ToggleInputState(
val title: String,
val isOn: Boolean,
val updateToggle: (Boolean) -> Unit,
)
@Composable
fun rememberToggleInputState(): ToggleInputState {
var isOn by remember { mutableStateOf(false) }
LaunchedEffect(isOn) {
if (isOn) {
Log.i("Toggle", "Turned Input On")
}
}
val title = if (isOn) {
R.string.toggle_state_on
} else {
R.string.toggle_state_off
}
return ToggleInputState(
title = stringResource(id = title),
isOn = isOn,
updateToggle = { isOn = it },
)
}
@Composable
fun ToggleInput(state: ToggleInputState) {
Column {
Text(text = state.title)
Checkbox(
checked = state.isOn,
onCheckedChange = state.updateToggle,
)
}
}
Now ToggleInput
is only in charge of rendering based on the current state. This has a few distinct advantages including that ToggleInput
and rememberToggleInputState
can now be tested in isolation and we now control the API surface available to our ui component. This pattern also has several advantages for compose previews because the component can now be easily rendered in any state desired, in this example either on or off. Additionally, the previews can be shown using a hard-coded state value or they can use the rememberToggleInputState
depending on the particular use case. See the example of the improved previews below.
@Preview
@Composable
fun PreviewToggleInputWithHolder() {
ToggleInput(state = rememberToggleInputState())
}
@Preview
@Composable
fun PreviewToggleInputWithState() {
val state = ToggleInputState(
title = "Test Title",
isOn = true,
updateToggle = {},
)
ToggleInput(state = state)
}
@Preview
@Composable
fun PreviewToggleInputWithStateOff() {
val state = ToggleInputState(
title = "Test a Different Title",
isOn = false,
updateToggle = {},
)
ToggleInput(state = state)
}
A final improvement can be made to our state holder now that it has been broken out of the ui component. The logic to manipulate the toggle state can be split out and composed with the logic to handle the side-effect and string resource loading with no change to the ToggleInput
or ToggleInputState
.
data class ToggleState(
val isOn: Boolean,
val updateToggle: (Boolean) -> Unit,
)
@Composable
fun rememberToggleState(): ToggleState {
var isOn by remember { mutableStateOf(false) }
return ToggleState(
isOn = isOn,
updateToggle = { isOn = it },
)
}
data class ToggleInputState(
val title: String,
val isOn: Boolean,
val updateToggle: (Boolean) -> Unit,
)
@Composable
fun rememberToggleInputState(
toggleState: ToggleState = rememberToggleState(),
): ToggleInputState {
LaunchedEffect(toggleState.isOn) {
if (toggleState.isOn) {
Log.i("Toggle", "Turned Input On")
}
}
val title = if (toggleState.isOn) {
R.string.toggle_state_on
} else {
R.string.toggle_state_off
}
return ToggleInputState(
title = stringResource(id = title),
isOn = toggleState.isOn,
updateToggle = toggleState.updateToggle,
)
}
Now it is even easier to test the specific functionality in isolation (e.g. rememberToggleInputState
with isOn
true or false and the updateToggle
call from rememberToggleState
). Additionally, if there is need for a toggle state elsewhere in the application, the rememberToggleState
can be reused in that location instead of repeating the same lines of code over and over again.
Why Passive UI #
Hopefully the previous example has already highlighted the positive outcomes of switching to use a Passive UI. To be more explicit, here are some of the advantages of leveraging Passive UI for ui components.
- Separation of Concerns - Splitting up rendering logic from state manipulation logic makes the code easier to read, easier to test, and easier to maintain
- More Testable - With logic split up, tests can be narrowly defined for the ui component and the state components to test exactly what is required
- Better Previews - As shown, Composable previews become more flexible and powerful when leveraging the Passive UI pattern
- Encourages Reusable State - Breaking state out into composable functions creates better more flexible code chunks
- Controlled API Surface - By creating a well defined state object and passing it into the Passive UI Composable changes to state are well contained and well understood
Conclusion #
Passive UI has substantial advantages over cramming all the code into a single component, especially as the complexity of a given component grows. As with any new pattern, try applying the pattern in small ways to see if the positive outcomes outweigh any extra effort in implementation. To play around with the code presented above, check out the gist here. Until next time, thanks!
Did you find this content helpful?
Please share this post and be sure to subscribe to the RSS feed to be notified of all future articles!
Want to go above and beyond? Help me out by sending me $1 on Ko-fi. It goes a long way in helping run this site and keeping it advertisement free. Thank you in advance!