Jetpack Compose for Wear OS
[ad_1]
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.
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:
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:
OneBreathApp(
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)
StopWatchScreen(stopWatchViewModel)
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
TrainingDayDetailsScreen(
day.breaths, // 2
onDismissed = { swipeDismissableNavController.navigateUp() } // 3
)
}
Here’s what’s happening in the code above:
- Navigate only after you set the
selectedDay
. - Only the list of attempts is necessary to display the details.
- Unlike the previous route, you set the
onDismissed()
callback explicitly here because you’re usingSwipeToDismissBox()
inTrainingDayDetails()
.
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 {
Box(
modifier = Modifier
.fillMaxSize()
.edgeSwipeToDismiss(state) // 2
) {
HorizontalPager(state = pagerState, count = maxPages) { page ->
selectedPage = pagerState.currentPage
DetailsView(attempts[page].utbTime, attempts[page].totalDuration)
}
}
}
-
SwipeToDismissBox()
has a background scrim, which in this case is just a black full-screen box. - In a normal state, this
Box()
composable holds aHorizontalPager
, 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 aSwipeToDismissBox()
and have theedgeSwipeToDismiss()
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:
RecordsListScreen(
apneaRecordRepository.records, // 1
onClickStopWatch = { // 2
swipeDismissableNavController.navigate(
route = Destination.StopWatch.route
)
},
onClickRecordItem = { day -> // 3
selectedDay = day
swipeDismissableNavController.navigate(
route = Destination.TrainingDayDetails.route
)
}
)
Here’s what’s going on:
- The records list screen displays a list of records from the local source.
- When you tap the New Training button, it redirects you to the stopwatch screen.
- 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:
-
ScalingLazyColumn()
is a Wear OS analog forLazyColumn()
. The difference is that it adapts to the round watch screen. Build and refresh the previews inRecordsListScreen
to get a visual representation. - Every item in the
ScalingLazyColumn()
is aChip()
. Look atStopWatchListItemChip()
andRecordListItemChip()
— they have placeholders foronClick
,icon
,label
,secondaryLabel
and other parameters.
Build and run. You’ll see a collection of breath holds:
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
- 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. - 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()
:
Dialog(
showDialog = showSaveDialog, // 1
onDismissRequest = { showSaveDialog = false } // 2
) {
SaveResultDialog(
onPositiveClick = { // 3
},
onNegativeClick = {
},
result = duration.toRecordString()
)
}
Here’s what’s happening:
- You introduced
showSaveDialog
at the top ofStopWatchScreen()
. It controls whether this dialog is visible or not. - A simple callback resets
showSaveDialog
tofalse
and hides the dialog. -
SaveResultDialog()
is anAlert()
dialog and requiresonPositiveClick()
andonNegativeClick()
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:
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 = {
Button(
onClick = onNegativeClick, // 1
colors = ButtonDefaults.secondaryButtonColors() // 2
) {
Icon(
imageVector = Icons.Filled.Clear, // 3
contentDescription = "no"
)
}
},
positiveButton = {
Button(
onClick = onPositiveClick,
colors = ButtonDefaults.primaryButtonColors()
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "yes"
)
}
}
As you can see, using Button()
in Wear OS is simple:
- The most important part is providing the buttons with an
onClick
callback, which you’ll set in a moment. - You can specify the
colors
for the buttons. - 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
stopWatchViewModel.save()
},
onNegativeClick = {
showSaveDialog = false
stopWatchViewModel.refresh()
}
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.
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:
InlineSlider(
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 = {
onPositiveClick(value)
}
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:
Dialog(
showDialog = showRatingDialog,
onDismissRequest = { showRatingDialog = false }
) {
AssessmentDialog(
onPositiveClick = { rating ->
showRatingDialog = false
stopWatchViewModel.save(rating) // 1
},
onNegativeClick = {
showRatingDialog = false
stopWatchViewModel.save() // 2
}
)
}
Here’s what happens:
- After tapping the positive button, you save the self-rating in the database along with other values from the
StopWatchViewModel
. - When the user doesn’t want to rate himself, you just save the result.
Also, replace stopWatchViewModel.save()
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:
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 |
---|---|
See the difference? In the right-hand version, the edges are slightly darker.
TimeText
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
TimeText(
startLinearContent = { // 2
Text(
text = selectedDay?.date.toFormattedString(),
color = colorPrimary,
style = textStyle
)
},
startCurvedContent = { // 3
curvedText(
text = selectedDay?.date.toFormattedString(),
color = colorPrimary,
style = CurvedTextStyle(textStyle)
)
}
)
} else TimeText() // 4
}
Here’s a breakdown of this code:
- 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.
-
TimeText()
adapts to round and square watches. For square watches, it usesTimeTextDefaults.timeTextStyle()
. - For round watches, use
CurvedTextStyle()
. - 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:
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()
:
CircularProgressIndicator(
progress = duration.toProgress(),
modifier = Modifier
.fillMaxSize()
.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:
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:
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
:
HorizontalPageIndicator(
pageIndicatorState = pageIndicatorState,
selectedColor = colorAccent
)
Build and run. Pick a training day from the list. You’ll see a paging indicator at the bottom:
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!
[ad_2]
Source link