Jetpack Compose for Wear OS

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.

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

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.

Getting to Know the Components

Open RecordsListScreen.kt and add the following to RecordsListScreen() body:

ScalingLazyColumn {  // 1
  item {
    StopWatchListItemChip(onClickStopWatch)  // 2
  for (item in records) {
    item {
      RecordListItemChip(item, onClickRecordItem)

Here’s what this means:

  1. ScalingLazyColumn() is a Wear OS analog for LazyColumn(). The difference is that it adapts to the round watch screen. Build and refresh the previews in RecordsListScreen to get a visual representation.
  2. Every item in the ScalingLazyColumn() is a Chip(). Look at StopWatchListItemChip() and RecordListItemChip() — they have placeholders for onClick, icon, label, secondaryLabel and other parameters.

Build and run. You’ll see a collection of breath holds:

records list

You can either start a new training or choose a training day record from the list and then swipe to dismiss.

Congratulations — you nailed the navigation!

Now, open StopWatchScreen.kt. This screen displays the data processed in the StopWatchViewModel. On top of the StopWatchScreen() composable, there are two states that influence the recomposition:

val state by stopWatchViewModel.state.collectAsState() // 1
val duration by stopWatchViewModel.duration.collectAsState() // 2
  1. This state handles all parts of the UI that don’t rely on the current stopwatch time, such as the StartStopButton() or the text hint on top of it.
  2. The duration state will trigger recomposition of the progress indicator and the time text every second.

For now, the StopWatchScreen() only counts the time. But once the user finishes their breath hold, the app should ask for a certain input. This is a perfect place to use a dialog.

Using Dialogs

You can use Wear OS dialogs just like the regular Compose dialogs. Look at the dialog() composable in StopWatchScreen():

  showDialog = showSaveDialog,  // 1
  onDismissRequest = { showSaveDialog = false }  // 2
) {
    onPositiveClick = {  // 3
    onNegativeClick = {
    result = duration.toRecordString()

Here’s what’s happening:

  1. You introduced showSaveDialog at the top of StopWatchScreen(). It controls whether this dialog is visible or not.
  2. A simple callback resets showSaveDialog to false and hides the dialog.
  3. SaveResultDialog() is an Alert() dialog and requires onPositiveClick() and onNegativeClick() callbacks.

To activate this dialog, in the StartStopButton() find the onStop() callback and add the code below stopWatchViewModel.stop():

if (state.utbTime > 0) {
  showSaveDialog = true

In freediving, the first important metric for your breath hold is the Urge To Breathe (UTB) time. This is the moment when the CO2 reaches a certain threshold and your brain signals your body to inhale. But it doesn’t mean you’ve run out of oxygen yet.

Check out the stop() function in StopWatchViewModel.kt. It controls what happens when the user taps the stop button. On the first tap, it saves the UTB time to a local variable. On the second tap, time tracking actually stops. That’s why you set showSaveDialog to true only when utbTime has already been recorded.

Build and run. Take a deep breath and start the stopwatch. Once you tap the button two times — one for UTB and one for final time — you’ll see the SaveResultDialog dialog:

result dialog

Next, you’ll add some interactive components to this app.

Adding Inputs

Go to SaveResultDialog.kt. This is an Alert, which is one of the Wear OS dialog types. The other type is Confirmation. You can learn more about the differences between the two types in the official documentation.

Look at the parameters of Alert(). It has an optional icon and a title, which is already created. The body of this alert dialog uses a Text() composable. You only need to set the buttons for the user interaction. Set the negativeButton and positiveButton parameters to:

negativeButton = {
    onClick = onNegativeClick,  // 1
    colors = ButtonDefaults.secondaryButtonColors()  // 2
  ) {
      imageVector = Icons.Filled.Clear,  // 3
      contentDescription = "no"
positiveButton = {
    onClick = onPositiveClick,
    colors = ButtonDefaults.primaryButtonColors()
  ) {
      imageVector = Icons.Filled.Check,
      contentDescription = "yes"

As you can see, using Button() in Wear OS is simple:

  1. The most important part is providing the buttons with an onClick callback, which you’ll set in a moment.
  2. You can specify the colors for the buttons.
  3. You can also choose an icon — in this case, it’s a cross for the negative action and a tick for the positive action.

Back in the StopWatchScreen.kt, find SaveResultDialog() and change the onPositiveCallback() and onNegativeCallback() to:

onPositiveClick = {
  showSaveDialog = false
onNegativeClick = {
  showSaveDialog = false

In both cases here, you close the dialog. If the user agrees to save the result, you call the relevant method from StopWatchViewModel. Otherwise, you just need to refresh the values shown in the StopWatchScreen().

Build and run.

interact with dialog

You can interact with the dialog and save or discard the breath hold result. Either way, you navigate back to the StopWatchScreen().

Buttons are one way to interact with the user. There are also several input options in Wear OS. You can use one of the following:

  • Slider: To choose from a range of values.
  • Stepper: If you want a vertical version of a slider.
  • Toggle chip: To switch between two values.
  • Picker: To select specific data.

In the OneBreath app, you’ll deal with a Slider().

Open AssessmentDialog.kt. Add the following line above the Alert(), doing all the necessary imports:

var value by remember { mutableStateOf(5f) }

This will hold the value of an InlineSlider() with an initial value of 5. In the next step, you’ll set the value range to 10.

Add the InlineSider() to the empty body of Alert() dialog:

  value = value,
  onValueChange = { value = it },
  increaseIcon = { Icon(InlineSliderDefaults.Increase, "satisfied") },
  decreaseIcon = { Icon(InlineSliderDefaults.Decrease, "unsatisfied") },
  valueRange = 1f..10f,
  steps = 10,
  segmented = true

As you can see, it has several parameters for the value, the buttons, the value range, the number of steps and whether it has segments or not. The value of this slider changes when the user taps the Increase or Decrease buttons. Since you want to save this value along with the breath hold time results, replace the empty onClick parameter in positiveButton:

onClick = {

And now, back in StopWatchScreen.kt, use the AssessmentDialog() just like you did with SaveResultDialog(). First, add a variable below showSaveDialog:

var showRatingDialog by remember { mutableStateOf(false) }

Then, at the bottom of StopWatchScreen() add a dialog. Use the showRatingDialog as a handle to show or hide the dialog and use AssessmentDialog() as content:

  showDialog = showRatingDialog,
  onDismissRequest = { showRatingDialog = false }
) {
    onPositiveClick = { rating ->
      showRatingDialog = false  // 1
    onNegativeClick = {
      showRatingDialog = false  // 2

Here’s what happens:

  1. After tapping the positive button, you save the self-rating in the database along with other values from the StopWatchViewModel.
  2. When the user doesn’t want to rate himself, you just save the result.

Also, replace in SaveResultDialog() with showRatingDialog = true, because you want to show one dialog after another and save the result only after the AssessmentDialog().

Build and run. If you chose to keep the record in the first dialog, you’ll see the second dialog as well:

slider dialog
Ready for some even cooler Wear OS Composables? It’s time to talk about Vignette and TimeText.

Adding a Vignette

Open OneBreathApp.kt and look at the parameters in Scaffold() again.
Set vignette parameter to:

vignette = {
  if (currentBackStackEntry?.destination?.route == Destination.Records.route) {
    Vignette(vignettePosition = VignettePosition.TopAndBottom)

This condition means the vignette will be there only for the RecordsListScreen(). A vignette is a UI feature that dims an edge part of the screen. In your case, it’s TopAndBottom, as specified in vignettePosition.

Compare the record list screen with and without the vignette:

Without Vignette With Vignette
no vignette with vignette

See the difference? In the right-hand version, the edges are slightly darker.


Another essential Wear OS UI component is TimeText(). Still in OneBreathApp.kt, replace the empty timeText parameter in Scaffold() with:

timeText = {
  if (currentBackStackEntry?.destination?.route == Destination.TrainingDayDetails.route) {  // 1
      startLinearContent = {  // 2
          text = selectedDay?.date.toFormattedString(),
          color = colorPrimary,
          style = textStyle
      startCurvedContent = {  // 3
          text = selectedDay?.date.toFormattedString(),
          color = colorPrimary,
          style = CurvedTextStyle(textStyle)
  } else TimeText()  // 4

Here’s a breakdown of this code:

  1. You only want to show an additional text before the time in the training day details screen. This additional text will hold the date of the record.
  2. TimeText() adapts to round and square watches. For square watches, it uses TimeTextDefaults.timeTextStyle().
  3. For round watches, use CurvedTextStyle().
  4. All the screens except the training day details screen will still show the current time on top.

Build and run. You’ll see the current time on top now. Tap on one of the green chips. In the training day details screen, you’ll also see the date:

training day details screen

Progress Indicator

Wouldn’t it be nice to have something like a clock hand for the stopwatch? You can do that with a Wear OS CircularProgressIndicator.

Go to StopWatchScreen.kt and add the following to the top of the Box(), right above Column():

  progress = duration.toProgress(),
  modifier = Modifier
    .padding(all = 1.dp)

This indicator will recompose every second and show the current duration of your breath hold. It’s usually recommended to leave a gap for the TimeText() by adding startAngle and endAngle parameters, but in OneBreath you’ll sacrifice those to make the indicator resemble a clock hand.

Build and run the app and start the stopwatch. You’ll see the clock ticking:

determinate progress indicator

This CircularProgressIndicator() is determinate, but you can also use its indeterminate version to show a loading indicator – just leave out the progress parameter. It would look like this:

indeterminate progress indicator

Page Indicator

While you’re still running the app, go to the record list screen and tap on one of the training day items. Here, in the details screen, you can page through all your breath holds on that day. Would be nice to know what page you’re on, right? A HorizontalPageIndicator will help you with that.

Go to TrainingDayDetailsScreen.kt. In SwipeToDismissBox(), add this below val pagerState = rememberPagerState() :

val pageIndicatorState: PageIndicatorState = remember {
  object : PageIndicatorState {
    override val pageOffset: Float
      get() = 0f
    override val selectedPage: Int
      get() = selectedPage
    override val pageCount: Int
      get() = maxPages

Here, you create a PageIndicatorState that connects the HorizontalPager() and HorizontalPageIndicator(). The selectedPage is set when you scroll through the pager. The pageCount is the total number of attempts on the training day. The pageOffset is 0f in this case, but you can use it to animate the indicator.

To use it, add HorizontalPageIndicator() right below HorizontalPager:

  pageIndicatorState = pageIndicatorState,
  selectedColor = colorAccent

Build and run. Pick a training day from the list. You’ll see a paging indicator at the bottom:

paging indicator

HorizontalPageIndicator() is an example of a horizontal paging indicator. If you need a vertical indicator, you can use a PositionIndicator(). Check out the official materials for more components.

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

Congratulations! You can now track your breath-holding records with the app you’ve just built. Now, take a deep breath and set a new personal record! :]

If you’re interested in learning more about various Wear OS Composables, check out the official documentation documentation, as well as the Horologist library for advanced date and time pickers. And if you enjoy Wear OS development, don’t miss out on the Creating Tiles for Wear OS video course.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!


Leave a Reply

Your email address will not be published. Required fields are marked *