Designing Effective UIs For Enhancing Compose Previews

Jetpack Compose provides powerful tools for enhancing development, especially with its Preview system. This article will guide you through designing effective Compose UI components to maximize the potential of Compose Previews.

Jaewoong E.
Jaewoong E.
Published August 15, 2024

One of the biggest advantages of using Jetpack Compose is the Previews feature provided by Android Studio. This functionality allows developers to build and display parts of their layout incrementally rather than building the entire project. Compose Previews significantly reduce the time required to verify component displays directly within Android Studio by enabling developers to build and view results in composable chunks.

Another advantage of using the preview is that it forces you to implement well-designed Compose UI components. These components, which can be successfully displayed in the Compose Preview, are typically stateless and non-DI(dependency injection)-dependent. This makes them ideal for use in UI testing and screenshot testing and helps ensure consistency and reliability in your application's user interface.

In this article, you'll explore best practices for using Compose Previews and how to maximize their utilization. Topics include preview annotations, handling ViewModels in previews, and using LocalInspectionMode.

ViewModel in Previews

If you've used Compose Previews before, the following message might be familiar to you:

Compose Previews do not support generating preview layouts that include computational tasks such as network requests, I/O operations, or database access. To create proper Compose Previews in Android Studio, consider certain design approaches when implementing Composable functions.

1. Minimize Dependence on ViewModel

When designing Compose components, it's beneficial to keep your Composable functions independent from state, a practice known as state hoisting. This approach enhances reusability and testability by lifting state to higher-level components, thereby separating business logic from UI rendering.

This approach can be similarly applied to ViewModel, as it is a state holder. Passing the ViewModel as a parameter to a Composable function means that the Composable function depends on states, which are closely tied to business logic. This dependency can make writing unit tests more difficult. Therefore, it's better to keep state management separate from the UI components to enhance testability and maintainability.

Let’s see the example code below:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable fun UserProfiles(viewModel: UserViewModel) { val users by viewModel.users.collectAsStateWithLifecycle() LazyColumn { items(users) { user -> SingleProfile( modifier = Modifier.clickable { viewModel.onUserClick(user) }, .. ) } } }

If you look at the example above, the UserProfiles composable function depends on UserViewModel to observe the user list and handle click events. This presents two problems:

  1. Difficulty in Reuse and Unit Testing: The UserProfiles composable must receive UserViewModel as a parameter, complicating reuse in different lifecycle scopes or unit tests since an instance of UserViewModel is required.

  2. Broken Previews: Compose Previews can't handle ViewModels, making it challenging to render previews if you use functions like viewModels() or hiltViewModel().

To avoid passing the ViewModel into lower-level Composable functions, it's best to use state hoisting, as demonstrated in the example below:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable fun UserProfiles( userList: List<User>, onUserClick: (User) -> Unit ) { // Composable logic here } @Composable fun ParentComposable(viewModel: UserViewModel = viewModel()) { val userList by viewModel.userList.collectAsStateWithLifecycle() UserProfiles( userList = userList, onUserClick = { user -> viewModel.onUserClick(user) } ) }

This makes the UserProfiles composable function much easier to reuse in various situations where an instance of the UserViewModel might not be available. Additionally, it ensures compatibility with Compose Previews.

2. Design for Manual ViewModel Creation

Even when you aim to create loosely coupled components, it might not be possible to apply this approach universally, as you'll need to observe data as states somewhere, as illustrated in the figure below:

In the scenario above, you can make child composables like TopAppBar, FeedScreen, and BottomNavigation as stateless and testable as possible. However, it's challenging to achieve completely loose coupling for the FeedScreen composable, which serves as the root composable in the tree.

If you want to implement a preview for the FeedScreen composable, you typically have two options:

  1. Create a Separate FeedScreen Preview: You can create an additional Preview composable that arranges the child components similarly but without relying on the ViewModel. This approach is clear and straightforward, allowing for easy previewing. However, it might result in a Preview that differs from the actual FeedScreen composable function that runs at runtime, potentially leading to inconsistencies.

  2. Manually Create the ViewModel: Another approach is to manually implement your ViewModels without relying on dependency injection libraries. For instance, if your project follows the repository pattern, as shown in the example below, you can create mockable classes to manually instantiate ViewModels. This allows for flexibility in testing and previewing without the need for dependency injection frameworks, making it easier to manage ViewModel creation in different contexts.

kt
1
2
3
4
5
6
7
8
9
10
public interface LoginRepository public interface UserDeviceRepository @HiltViewModel public class UserViewModel @Inject constructor( private val repository: LoginRepository, private val userDeviceRepository: UserDeviceRepository, ) : ViewModel() { .. }

In the example above, you can create mock classes and manually instantiate the UserViewModel to properly render the FeedScreen composable in the Previews. This approach allows you to simulate the ViewModel's behavior without relying on actual dependencies, ensuring the FeedScreen can be previewed accurately in Android Studio.

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class FakeLoginRepository : LoginRepository { override fun requestToken( authProvider: String, authIdentifier: String, email: String, ): Flow<ApiResponse<LoginInfo>> = flow {} } public class FakeDeviceRepository : UserDeviceRepository { .. } @Preview @Composable private fun FeedScreenPreview() { FeedScreen( viewModel = UserViewModel( loginRepository = FakeLoginRepository(), userDeviceRepository = FakeDeviceRepository(), ) ) }

With this approach, you may still encounter issues when observing states from ViewModels, calling methods, or accessing objects that cannot be initialized by the Preview system. It's important to continuously track and address these issues to ensure your Previews are accurate and fully functional.

LocalInspectionMode

Another valuable tool for dealing with Previews is using LocalInspectionMode in appropriate situations. The LocalInspectionMode allows you to check if the composable is rendered in a preview mode, so you can conditionally alter behavior or provide mock data specifically for Previews, ensuring that your composables render correctly even when the Preview system can't fully initialize certain objects or states.

Let’s imagine you have a UserViewModel that holds a StateFlow of users, which will be fetched with some delay after the entire layout is rendered. In this scenario, you might imagine the following code:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserViewModel: ViewModel() { private var mutableUsers = mutableStateListOf<User>() internal val users: StateFlow<List<User>> = MutableStateFlow(mutableUsers) } @Composable fun UserProfiles(viewModel: UserViewModel) { val users by viewModel.users.collectAsStateWithLifecycle() LazyColumn { items(users) { user -> .. } }

In this case, the UserProfiles composable will render with an empty list in Android Studio because the users field observes the flow as state, starting with an empty list when initialized by the Preview system. To address this, you can use LocalInspectionMode to display a sample item property only in preview modes:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable fun UserProfiles(viewModel: UserViewModel) { val users by if (LocalInspectionMode.current) { remember { mutableStateOf(MockUtils.mockUsers) } } else { viewModel.users.collectAsStateWithLifecycle() } LazyColumn { items(users) { user -> .. } } }

Another best practice is to use LocalInspectionMode when dealing with previews in cases where your composable function is involved in complex business logic, such as loading images from the network or video players. This allows you to bypass or simulate certain logic specifically for previews, ensuring that your UI renders correctly without requiring the actual network or other heavy operations to complete.

You can find a great example in the Jetpack Compose network image loading library, Landscapist. The GlideImage component handles complex operations such as fetching images from the network, resizing, measuring, formatting, and drawing bitmap images on the screen with additional plugins. By leveraging LocalInspectionMode, you can simplify these operations in previews to ensure the UI renders correctly without requiring full network or image processing logic during development.

kt
1
2
3
4
5
6
7
8
9
10
11
@Composable public fun GlideImage() { renderPlaceHolders() handlingStates() fetchImageFromNetwork { .. } }

In the scenario above, the Preview system can't handle these operations at build time, especially when they involve complex internal logic. In this case, GlideImage addresses this challenge by using LocalInspectionMode to simplify the logic during previews, as shown in the example code below:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable public fun GlideImage() { if (LocalInspectionMode.current) { Image(..) return // return the composable function here to not proceed additional works in the preview mode } renderPlaceHolders() handlingStates() fetchImageFromNetwork { .. } }

This approach benefits both API users and library providers, as well as ensuring a smoother experience in Android Studio. However, it has a potential side effect of ignoring other composable functions that also need to be rendered in the preview mode, so it should be used with caution and within a minimized scope in composable functions. This technique can be applied in various scenarios, such as rendering a video player with Exoplayer or handling real-time communication with WebRTC.

However, you might question whether this approach is correct since the preview logic is involved in the UI implementation, even though it doesn't affect the runtime outcome. While this method is practical for testing and previewing, it’s important to ensure that it doesn’t introduce unnecessary complexity or dependencies in your production code. Proper use of tools like LocalInspectionMode can help keep preview-specific logic isolated, ensuring that it remains purely for development purposes and doesn’t impact the final application.

Especially in library or SDK development, it's crucial to provide an exceptional user experience to guide developers correctly and reduce misunderstandings about API usage. If you're looking for a real-world example of using LocalInspectionMode in the AndroidX Jetpack library, you can check out its implementation in the Material3 Menu Composable. This approach helps enhance the preview experience without affecting the runtime behavior.

Preview Annotations

The Compose UI Tooling’s preview library offers several useful annotations that simplify the implementation of Compose previews in Android Studio. In this section, we'll explore these preview annotations and how they can make your development process for previews more efficient.

@Preview

The @Preview annotation is the cornerstone of Jetpack Compose's preview system. It can be applied to any composable function and is repeatable, meaning you can attach multiple @Preview annotations to the same composable function to view it in different configurations or devices like the example below:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Preview(name = "light mode") @Preview(name = "dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun MyPreview() { MaterialTheme { Text( text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", color = if (isSystemInDarkTheme()) { Color.White } else { Color.Yellow } ) } }

You will see the result below on your Android Studio:

@PreviewParameter

The @PreviewParameter annotation allows you to inject preview instances into a composable function as a parameter by using the PreviewParameterProvider. You can create your own classes that implement the PreviewParameterProvider interface, and then provide those classes to your composable functions using the @PreviewParameter annotation, as shown in the example below:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public data class User( val name: String, ) public class UserPreviewParameterProvider: PreviewParameterProvider<User> { override val values: Sequence<User> get() = sequenceOf( User("user1"), User("user2"), ) } @Preview(name = "UserPreview") @Composable private fun UserPreview( @PreviewParameter(provider = UserPreviewParameterProvider::class) user: User) { Text(text = user.name, color = Color.White) }

The Jetpack Compose UI tooling library includes a PreviewParameterProvider called LoremIpsum, which provides sample text strings like the preview image below:

You can use the class like a normal PreviewParameterProvider as you’ve seen in the code below:

kt
1
2
3
4
5
@Preview @Composable private fun TestPreview(@PreviewParameter(provider = LoremIpsum::class) text: String) { Text(text = text, color = Color.White) }

MultiPreview Annotations

As mentioned earlier, the @Preview annotation is repeatable, allowing you to apply multiple annotations to the same composable function. Jetpack Compose provides several useful built-in multi-preview annotations, such as @PreviewLightDark, @PreviewFontScale, @PreviewDynamicColors, and @PreviewScreenSizes.

For example, you can render both light and dark modes by using multiple preview annotations like the code below:

kt
1
2
3
4
5
6
@Preview(name = "light mode") @Preview(name = "dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun MyPreview() { .. }

You can simply replace it with the @PreviewLightDark annotation:

kt
1
2
3
4
5
@PreviewLightDark @Composable private fun MyPreview() { .. }

You can also utilize other MultiPreview annotations like the screenshot below:

You can enhance reusability by creating custom annotations that combine multiple annotations, as shown in the example below. This allows you to streamline your code and apply consistent configurations across your project:

kt
1
2
3
@Preview(name = "Light", showSystemUi = true, showBackground = true) @Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL, showSystemUi = true, showBackground = true) public annotation class MyPreviewLightDark

CompositionLocal

There are various factors that can break Compose Previews, and CompositionLocal is one of them. CompositionLocal is a powerful and convenient approach that allows you to access provided instances throughout the composable tree in Jetpack Compose. However, If you use a composable function in a preview that relies on CompositionLocal, it may cause the preview to fail because the composable function might attempt to access objects that aren't provided in the preview environment.

To avoid issues with Compose Previews when using CompositionLocal, ensure that all necessary dependencies are provided. A best practice is to implement a dedicated composable wrapper that supplies all required dependencies, as shown in the example below:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable fun PreviewProvider(content: @Composable () -> Unit) { val localImageLoader = ImageLoder.getLocalImageLoader() CompositionLocalProvider( LocalImageLoader provides localImageLoader ) { content() } } @Preview @Composable private fun AvatarPreview() { PreviewProvider { UserAvatar() } }

As shown in the code above, if the UserAvatar composable function relies on a value provided by CompositionLocal, such as LocalImageLoader, you can create a wrapper composable that supplies the necessary dependencies. This wrapper ensures that your Compose components have everything they need to render correctly in the preview.

Once you've created this preview wrapper theme, you can leverage it for preview screenshot testing or integrate it with other screenshot testing tools like Paparazzi. If you'd like to see a code example, check out the Pokedex-Compose’s preview module on GitHub.

Conclusion

In this article, you've explored strategies to enhance Compose previews by reducing dependencies on ViewModels, utilizing LocalInspectionMode, leveraging preview annotations, and effectively handling CompositionLocal in Compose previews.

You can build your application without using Compose previews, and you might even think they slow down the development process or are unnecessary since the runtime result could differ from the previews. However, by incorporating Compose previews into your workflow, you'll streamline UI development and make it easier to extend your components to UI tests or screenshot tests. Designing with previews in mind leads to more effective and testable UI components.

If you have any questions or feedback on this article, you can find the author on Twitter @github_skydoves or GitHub if you have any questions or feedback. If you’d like to stay up to date with Stream, follow us on Twitter @getstream_io for more great technical content.

As always, happy coding!

— Jaewoong