Jetpack Compose for Wear OS

Learn about Jetpack Compose for Wear OS by building a dedicated app to manage breath-holding times, including a stopwatch to track new records and save them in the collection. In this tutorial, you’ll get to know all the essential components, such as Inputs, Dialogs, Progress Indicators and Page Indicators. You’ll also learn when to use a Vignette and a TimeText. By Lena Stepanova.

Leave a rating/review
Download materials
Save for later

With Jetpack Compose for Wear OS, you can build beautiful user interfaces for watches. It has tons of components to choose from. In this tutorial, you’ll learn about all of the essential components — such as Inputs, Dialogs, Progress Indicators and Page Indicators. You’ll also learn when to use a Vignette and a TimeText.

Note: It’s good to have some experience with Compose and Wear OS, because this article won’t cover basic UI composables or how to set up a watch emulator. If you aren’t familiar with Compose, check out Jetpack Compose Tutorial for Android: Getting Started.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial. Unzip it and import into Android Studio. Build and run.

final version

The OneBreath app is a collection of breath-holding times. It also has a stopwatch to track new records and save them in the collection.

Play around with the app to get a feeling of what you’ll build in this tutorial.

Check out ApneaRecordLocalSource.kt and ApneaRecordRepository.kt – these classes mock a local data source. It will help to test the app, but it won’t keep your data between app launches.

Look also at StopWatchViewModel.kt. This is the view model for the future stopwatch screen. It will take care of counting time.

You don’t have to change anything in these three classes. Just focus on the UI.

Using Correct Dependencies

Switch to the starter project. Go to the app-level build.gradle and add the following dependencies:

implementation "androidx.wear.compose:compose-material:$wear_compose_version"
implementation "androidx.wear.compose:compose-navigation:$wear_compose_version"
implementation "androidx.wear.compose:compose-foundation:$wear_compose_version"

Why do you need these? In a Wear OS app, you should use the Wear OS versions for compose-material and compose-navigation because they are different from their regular siblings. As for the compose-foundation library, it builds upon its regular version so you have both dependencies.

Now that you have all necessary dependencies, build and run. You’ll see the following screen:

start screen

Time to dive in!

Watching over the Navigation

To begin, you’ll add Compose navigation so you can navigate between screens.

Navigating Compose for Wear OS

Navigation in Compose for Wear OS is a lot like the regular Compose navigation.

Open MainActivity.kt and declare a NavHostController above apneaRecordLocalSource:

private lateinit var navController: NavHostController
Note: Interested in learning more about Jetpack Compose? Subscribe to the Kodeco personal plan to access Jeptack Compose by Tutorials.

In setContent() above OneBreathTheme(), initialize a swipeDismissableNavController:

navController = rememberSwipeDismissableNavController()

The difference between Wear OS and a regular app is in how the user navigates back. Since watches don’t have back buttons, navigation back happens when users swipe to dismiss. That’s why you’ll use a SwipeDissmissableNavHost() here.

Inside the OneBreathTheme(), replace the temporary Box() composable with the entry point to the app:

  swipeDismissableNavController = navController,
  apneaRecordRepository = apneaRecordRepository

Here, you pass the recently created navController and the repository to OneBreathApp(), where you’ll set up the app navigation.

Go to OneBreathApp.kt. As you can see, it uses Scaffold(). But unlike the regular Compose Scaffold(), it has new attributes like timeText and vignette. You’ll get back to these later. For now, focus on SwipeDismissableNavHost(), where you pass navController and startDestination as parameters.

Check out the Destination.kt file in the ui/navigation folder:

sealed class Destination(
  val route: String
) {
  object Records : Destination("records")
  object DayTrainingDetails : Destination("dayTrainingDetails")
  object StopWatch : Destination("stopWatch")

This sealed class describes all the possible routes in the app. Now you can set up navigation for those routes. In OneBreathApp.kt, replace SwipeDismissableNavHost‘s empty body with the relevant routes:

composable(route = Destination.StopWatch.route) {

composable(route = Destination.TrainingDayDetails.route) {

composable(route = Destination.Records.route) {

Add the following inside the first route composable:

val stopWatchViewModel = StopWatchViewModel(apneaRecordRepository)

Here, you create a StopWatchViewModel and pass it to the StopWatchScreen().

The next route is Destination.TrainingDayDetails. This will lead you to the TrainingDayDetailsScreen(), where you’ll see the stats for all the breath holds you attempted on that day. In a large app, you’d create a details screen route based on the id of the item you want to display and use that id in a relevant DetailsViewModel. But this app is rather simple, so you can just keep a reference to a selected training day in the OneBreathApp(). Thus, add this line above Scaffold():

var selectedDay: TrainingDay? = null

Write this code inside the composable with Destination.TrainingDayDetails:

selectedDay?.let { day ->  // 1
    day.breaths,  // 2
    onDismissed = { swipeDismissableNavController.navigateUp() }  // 3

Here’s what’s happening in the code above:

  1. Navigate only after you set the selectedDay.
  2. Only the list of attempts is necessary to display the details.
  3. Unlike the previous route, you set the onDismissed() callback explicitly here because you’re using SwipeToDismissBox() in TrainingDayDetails().

HorizontalViewPager and SwipeToDismissBox Navigation

Before moving on to the next destination, open TrainingDayDetailsScreen.kt. The reason why the compose navigation in OneBreathApp.kt is different for this screen is the SwipeToDismissBox() composable. The SwipeToDismissBox() has two states:

if (isBackground) {
  Box(modifier = Modifier.fillMaxSize())  // 1
} else {
    modifier = Modifier
      .edgeSwipeToDismiss(state)  // 2
  ) {
    HorizontalPager(state = pagerState, count = maxPages) { page ->
      selectedPage = pagerState.currentPage
      DetailsView(attempts[page].utbTime, attempts[page].totalDuration)
  1. SwipeToDismissBox() has a background scrim, which in this case is just a black full-screen box.
  2. In a normal state, this Box() composable holds a HorizontalPager, which allows you to scroll through the details screen horizontally, but also makes swipe-to-dismiss action impossible. That’s why you need to place it within a SwipeToDismissBox() and have the edgeSwipeToDismiss() modifier to navigate back only when the user swipes right in the small space on the left part of the screen.

Finally, set up the last navigation route: Destination.Records. Back in OneBreathApp.kt in SwipeDismissableNavHost(), add the following code inside the relevant composable:

  apneaRecordRepository.records,  // 1
  onClickStopWatch = {  // 2
      route = Destination.StopWatch.route
  onClickRecordItem = { day ->  // 3
    selectedDay = day
      route = Destination.TrainingDayDetails.route

Here’s what’s going on:

  1. The records list screen displays a list of records from the local source.
  2. When you tap the New Training button, it redirects you to the stopwatch screen.
  3. When you choose a particular training day from the list, it redirects to the training day details screen.

As you can see, for the click events, this composable uses the two routes you’ve just set up.

You’re done with the navigation — good job! But there’s nothing spectacular to see in the app yet. So, it’s time to learn about the Compose UI components for Wear OS.