The Many Approaches to Providing @Preview data in Jetpack Compose
Using the @Preview
annotation in Jetpack Compose renders a visible representation of a composable allowing for rapid prototyping, a visual reference, and an inline example of how the composable can be used. The effort to show the correct state for a composable can be a serious effort, so here are some approaches to creating reusable and sharable data for previewing composables. For the rest of the article we will be looking at a composable that takes a single large state object from a composable state holder.
Example Code #
Starting off, here is the example code showing only the parts relevant to this discussion. To follow along, the code can be found in the gist here. The example includes a ui component, a state object, and a state holder that creates the state object for the ui component.
// State
data class UserCardState(
val userName: String,
val isActive: Boolean,
val activeDescription: String,
)
// State Holder
@Composable
fun userCardState(
userName: String,
isActive: Boolean,
): UserCardState {
// ...
}
}
// UI Component
@Composable
fun UserCard(
state: UserCardState,
modifier: Modifier = Modifier,
) {
// ...
}
The Brute Force Method #
The most common approach will be the "Brute Force Method", that is the approach that just creates new previews with a new state for each variant. Here are a set of previews for the example that showcase this approach.
@Preview
@Composable
fun PreviewUserCard() {
val state = UserCardState(
userName = "John Doe",
isActive = true,
activeDescription = "Active",
)
UserCard(state = state)
}
@Preview
@Composable
fun PreviewUserCardInactive() {
val state = UserCardState(
userName = "John Doe",
isActive = false,
activeDescription = "Inactive",
)
UserCard(state = state)
}
This is the simplest to set up but can require a lot of maintenance time as any change to state will require updates to all the previews. Additionally, this approach does nothing to prevent states that aren't valuable to preview or are generally invalid in practice like the case in our example of having isActive = true
and activeDescription = "Invalid"
.
The Hardcoded State Method #
The next step up in reusability and complexity is to use hardcoded data. Two common approaches are to expose the data as a companion object value from the state class or as a global value in the file. For this case and other examples going forward, the preview data can be made private to ensure only this component has access or it can be made public to allow other components to reuse the same data.
// Global Value
private val userCardStatePreview = UserCardState(
userName = "John Doe",
isActive = true,
activeDescription = "Active",
)
@Preview
@Composable
fun PreviewUserCard() {
val state = userCardStatePreview
UserCard(state = state)
}
@Preview
@Composable
fun PreviewUserCardInactive() {
val state = userCardStatePreview
.copy(
isActive = false,
activeDescription = "Inactive"
)
UserCard(state = state)
}
// Companion Object
data class UserCardState(
val userName: String,
val isActive: Boolean,
val activeDescription: String,
) {
companion object {
val preview = UserCardState(
userName = "John Doe",
isActive = true,
activeDescription = "Active",
)
}
}
@Preview
@Composable
fun PreviewUserCard() {
val state = UserCardState.preview
UserCard(state = state)
}
@Preview
@Composable
fun PreviewUserCardInactive() {
val state = UserCardState.preview
.copy(
isActive = false,
activeDescription = "Inactive"
)
UserCard(state = state)
}
This approach can be further refined by creating a custom PreviewParameterProvider
as shown in the documentation for Compose previews. This blog post is not intended as a guide on how to do that, but below is an example using the CollectionPreviewParameterProvider
that shows how the global value approach can be updated to require only a single preview.
private val userCardStatePreview = UserCardState(
userName = "John Doe",
isActive = true,
activeDescription = "Active",
)
class UserCardStatePreviewParameterProvider
: CollectionPreviewParameterProvider<UserCardState>(
listOf(
userCardStatePreview,
userCardStatePreview.copy(
isActive = false,
activeDescription = "Invalid"
)
)
)
@Preview
@Composable
fun PreviewUserCard(
@PreviewParameter(
UserCardStatePreviewParameterProvider::class
)
state: UserCardState
) {
UserCard(state = state)
}
This solution builds on the "Brute Force Method" by creating sensible defaults for the object and leveraging the copy function to change just the values that need changed for each preview. However, it still lacks the ability to prevent or avoid invalid states. This is a step in the right direction but can still be improved upon at the cost of complexity.
The Factory Method #
Another approach that removes the need to call copy
and allows for more complex object creation logic is to implement a factory object. In the below example the logic to determine what description to show is moved into the factory, requiring fewer parameters to create the object. Additionally, reasonable defaults are provided as default parameters.
object UserStateFactory {
fun create(
userName: String = "John Doe",
isActive: Boolean = true,
): UserCardState {
val activeDescription = if (isActive) {
"Active"
} else {
"Inactive"
}
return UserCardState(
userName = userName,
isActive = isActive,
activeDescription = activeDescription,
)
}
}
@Preview
@Composable
fun PreviewUserCard() {
val state = UserStateFactory.create()
UserCard(state = state)
}
@Preview
@Composable
fun PreviewUserCardInactive() {
val state = UserStateFactory.create(isActive = false)
UserCard(state = state)
}
The "Factory Method" has the advantage of narrowing down the possible outputs while also being able to give reasonable defaults. The "Factory Method" is however additional code to maintain and in this case duplicates the logic that already exists in the state holder.
The State Holder Integration Method #
A final approach is to treat the preview as an opportunity to preview the state holder and the UI at the same time. This can be done because the preview is a composable. The below example shows this in action.
@Preview
@Composable
fun PreviewUserCard() {
val state = userCardState(
userName = "John Doe",
isActive = true
)
UserCard(state = state)
}
@Preview
@Composable
fun PreviewUserCardInactive() {
val state = userCardState(
userName = "John Doe",
isActive = false
)
UserCard(state = state)
}
The "State Holder Integration Method" has the advantages of the "Factory Method" including reducing invalid outputs and reducing the number of inputs. Additionally, it has the advantage of only producing real data based on the state holder without having to replicate the logic like in the "Factory Method". This method however has the drawbacks of requiring parameters without defaults (unless you introduce additional code) and an inability to use a PreviewParameterProvider
(again unless you introduce additional code, check out this article from Katie Barnett for a possible workaround).
Combining Methods #
These methods should not be viewed as discrete approaches but instead as options that can be used in isolation but can also be combined to create novel solutions to solve specific previewing problems. For example, make a factory with an @Composable
create method that uses the stateholder to generate preview states? Alternatively combine the factory with PreviewParameterProvider to quickly create many states for a complex component? Perhaps even generate the state using the "State Holder Integration Method" but take advantage of leveraging the copy
function to tweak only the values you want changed like in the "Hardcoded Method". There are vastly more solutions available by avoiding a one size fits all approach.
Review #
In review, the "Brute Force Method" is a great option to start until the complexity and needs of the component are understood. The "Hardcoded State Method" is a great middle road that gives you more reusability with less overhead. The "Factory Method" gives you more control over outputs at the cost of additional code and potentially duplicated logic. Finally, the "State Holder Integration Method" gives you the most true to real use experience but at the cost of extra code or duplicated initialization. In addition, these methods can be combined to create an even more diverse set of approaches to fit any possible scenario.
Conclusion #
This article is just an overview of possibilities and a project may choose to adopt or avoid any number of these as makes sense. Consider trying out a new solution from this list and see if it's an improvement over your current approach. 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!