Compose is not only for phone screens. The same ideas (composable functions, state, modifiers) also build interfaces for the wrist with Compose for Wear OS, and for the home screen with Jetpack Glance, which lets you write app widgets in a Compose-like style. This lesson covers the final items of Pathway 5 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-5): Compose for Wear OS introduction, Wear OS with Compose and Compose and Glance for remote surfaces. The related codelab is Compose for Wear OS (https://developer.android.com/codelabs/compose-for-wear-os).
By the end you will be able to:
- Explain how Wear OS design differs from phone design: tiny round screens, glanceable tasks, short sessions, battery.
- Set up a Wear OS module and an emulator.
- Build screens with
androidx.wear.compose:ScalingLazyColumn(or the newer list components),Chip/Button,Text,TimeText,Scaffold. - Handle navigation with
SwipeDismissableNavHost. - Understand Tiles and complications as Wear's "remote surfaces".
- Build a phone app widget with Glance:
GlanceAppWidget, receiver,provideGlance,GlanceTheme, actions, updates and widget metadata. - Explain why Glance is not ordinary Compose.
Big idea: every surface has its own rules. A watch face and a widget cannot show a full app, so each shows the one thing the user needs right now.
Honesty note: Wear OS and Glance libraries change quickly (for example, new list components and Material 3 for Wear). Version numbers below are placeholders; check the official documentation for the current APIs before you ship.
- Composables, state and modifiers (lessons 15 to 19).
- Lazy lists (lesson 21) and Navigation Compose (lessons 26 and 55).
- ViewModel and Flows (lessons 25 and 33).
- Manifest, services and broadcast receivers (lessons 3 and 42).
- Emulators (lesson 4).
The wrist
| Constraint | Consequence |
|---|---|
| Screen about 1.2 to 1.5 inches, often round | Content near the edge is clipped; centre things |
| Glance time of seconds | One primary action per screen |
| Battery | Avoid constant work; use efficient background APIs |
| Input | Touch, rotating crown or bezel, voice; no keyboard |
| Connectivity | Often paired to a phone; may be standalone with its own network |
Wear OS building blocks
| Piece | Purpose |
|---|---|
| App | Activities with Compose for Wear OS |
| Tile | A swipe-away card with at-a-glance info and quick actions |
| Complication | A small data slot on a watch face (steps, next event) |
| Ongoing activity | Shows a running workout or timer on the watch face |
App widgets
An app widget is a small view on the phone's home screen (clock, weather, "next lesson"). It is displayed by the launcher, not by your app process, using RemoteViews, a limited serialisable description of views. This is why widgets cannot use arbitrary Compose.
Jetpack Glance gives you a Compose-style API (GlanceAppWidget, Column, Row, Text, Button, LazyColumn) that is translated to RemoteViews. You write familiar code, but the composables come from androidx.glance, not androidx.compose. Mixing them does not compile.
- File → New → New Project → Wear OS → Empty Wear App (template name may vary).
- Create a Wear OS emulator (small round) from Device Manager. The wizard downloads a Wear OS system image.
- Look at the manifest: it declares
uses-feature android:name="android.hardware.type.watch", and often acom.google.android.wearable.standalonemeta-data flag for apps that work without a phone.
Dependencies (placeholders for versions):
[versions]
wearCompose = "<latest stable>"
wearTiles = "<latest stable>"
[libraries]
androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" }
androidx-wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" }
androidx-wear-tiles = { group = "androidx.wear.tiles", name = "tiles", version.ref = "wearTiles" }
(Wear has its own Material component set: compose-material for Material 2 for Wear and a newer compose-material3 with Material 3 Expressive components. Newer templates use the latest; follow whichever your template generates, and check which is current.)
A "Next Lesson" watch app for a school: show the next class and a button to mark attendance.
package com.example.registerwear
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.PositionIndicator
import androidx.wear.compose.material.Scaffold
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.TimeText
import androidx.wear.compose.material.Vignette
import androidx.wear.compose.material.VignettePosition
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { WearApp() }
}
}
@Composable
fun WearApp() {
MaterialTheme {
val listState = rememberScalingLazyListState()
Scaffold(
timeText = { TimeText() },
vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) },
positionIndicator = { PositionIndicator(scalingLazyListState = listState) }
) {
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
item { Text("Next class", textAlign = TextAlign.Center, style = MaterialTheme.typography.title3) }
item { Text("Mathematics\n10:30 SS2 B", textAlign = TextAlign.Center) }
item {
Chip(
onClick = { /* mark attendance */ },
label = { Text("Mark attendance") },
colors = ChipDefaults.primaryChipColors()
)
}
}
}
}
}
(Add import androidx.compose.ui.unit.dp.) Things that differ from phone Compose:
- The
Scaffoldis Wear's: it places the time text at the top, a vignette to blend edges, and a position indicator for scrolling. ScalingLazyColumnis a list whose items scale and fade towards the curved edges, matching round screens. Newer Wear releases introduceTransformingLazyColumnwith richer transformations; check which one the current guidance recommends for new code.ChipandButtonare Wear-sized, with large touch targets and high contrast. Do not use the phone's Material components on Wear unless the documentation says they are supported.- Text is short. If a label does not fit in about two lines, shorten it.
Rotary input
The crown or rotating bezel scrolls lists. ScalingLazyColumn supports it when it can take focus; in many versions you add Modifier.rotaryScrollable(...) or use the default behaviour. See the "rotary input" page for your version.
The watch back gesture is a swipe from the left edge, so Wear provides SwipeDismissableNavHost.
import androidx.wear.compose.navigation.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
@Composable
fun WearNav() {
val navController = rememberSwipeDismissableNavController()
SwipeDismissableNavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(onOpenClass = { id -> navController.navigate("class/$id") })
}
composable("class/{id}") { entry ->
val id = entry.arguments?.getString("id")
ClassScreen(id = id)
}
}
}
It uses the same NavController ideas as lesson 26 with a swipe-to-dismiss transition. (The string-route API shown here is the one in the Wear navigation library; check whether a type-safe version has been added.)
State and data
Use the same architecture as the phone: a ViewModel, a StateFlow, and collectAsStateWithLifecycle() (lesson 33). Keep the UI very simple: lists of three to five items, large touch targets, and a single clear action.
A Tile is a card the user swipes to from the watch face. It is defined by a TileService and described with ProtoLayout elements (a layout format that the system renders), not with Compose UI directly:
class NextClassTileService : TileService() {
override fun onTileRequest(requestParams: RequestBuilders.TileRequest): ListenableFuture<TileBuilders.Tile> =
Futures.immediateFuture(
TileBuilders.Tile.Builder()
.setResourcesVersion("1")
.setTileTimeline(
TimelineBuilders.Timeline.fromLayoutElement(
LayoutElementBuilders.Text.Builder()
.setText("Maths 10:30")
.build()
)
)
.build()
)
override fun onTileResourcesRequest(
requestParams: RequestBuilders.ResourcesRequest
): ListenableFuture<ResourceBuilders.Resources> =
Futures.immediateFuture(
ResourceBuilders.Resources.Builder().setVersion("1").build()
)
}
The exact builder APIs differ by Tiles library version (newer versions provide Material components and a kotlin-friendly layout DSL). Register the service in the manifest with the androidx.wear.tiles.action.BIND_TILE_PROVIDER intent filter and the BIND_TILE_PROVIDER permission. Key rules:
- Tiles are rendered by the system, so you describe them; you cannot run arbitrary UI.
- Keep them cheap: they refresh on request, not every second.
- Use a freshness interval or push updates when data changes.
Complications are provided by a ComplicationDataSourceService that returns ComplicationData (short text, ranged value, icon) for the slot the watch face asks about.
Treat tiles and complications as the next topic to study after this lesson. The programme's Pathway 5 introduces them; the codelabs on developer.android.com go through each step.
Now move to the phone. A "Next class" widget on the home screen.
Dependencies
[versions]
glance = "<latest stable>"
[libraries]
androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
The widget
package com.example.registerwidget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
class NextClassWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
// load data here (suspending work is allowed)
val next = "Mathematics, 10:30"
provideContent {
GlanceTheme {
NextClassContent(next)
}
}
}
}
@Composable
private fun NextClassContent(next: String) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.padding(12.dp)
.clickable(actionStartActivity<MainActivity>()),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Next class",
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp)
)
Text(
next,
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
)
}
}
Spot the differences from ordinary Compose:
GlanceModifier, notModifier.androidx.glance.layout.Column,Text,Button, not the Compose ones.provideContent { }renders and keeps the widget updated while it is alive;provideGlancemay do suspending work first.- Click handling uses actions (
actionStartActivity,actionRunCallback), not lambdas that run in your app process.
The receiver
import androidx.glance.appwidget.GlanceAppWidgetReceiver
class NextClassWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = NextClassWidget()
}
The widget metadata
res/xml/next_class_widget_info.xml:
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="180dp"
android:minHeight="110dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_description"
android:initialLayout="@layout/glance_default_loading_layout" />
(glance_default_loading_layout comes with the Glance library. updatePeriodMillis="0" means "do not update on a timer"; periodic updates under 30 minutes are not honoured by the system anyway, so update from your own code or WorkManager.)
The manifest
<receiver
android:name=".NextClassWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/next_class_widget_info" />
</receiver>
Install the app, long-press the home screen, choose Widgets, and drag Next class onto the screen.
Actions that run code
Widgets cannot call your composable lambdas. Use an ActionCallback:
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.ActionParameters.Key
import androidx.glance.appwidget.action.actionRunCallback
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
private val CountKey = intPreferencesKey("present_count")
class MarkPresentCallback : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs: Preferences.Builder ->
// Preferences.Builder variant may be MutablePreferences depending on the version
}
NextClassWidget().update(context, glanceId)
}
}
The exact updateAppWidgetState lambda type depends on the Glance version (MutablePreferences in most). The pattern is stable: store small state in the widget's Preferences DataStore, then call update to re-run provideGlance.
Reading widget state in the content
import androidx.glance.currentState
@Composable
private fun CounterContent() {
val prefs = currentState<Preferences>()
val count = prefs[CountKey] ?: 0
Column {
Text("Present: $count")
Button(text = "Mark present", onClick = actionRunCallback<MarkPresentCallback>())
}
}
Updating from elsewhere
// From a ViewModel, Worker or repository after data changes:
NextClassWidget().updateAll(context)
For periodic refresh, schedule a WorkManager PeriodicWorkRequest (minimum 15 minutes) that fetches new data, stores it, and calls updateAll.
Responsive sizing
A widget can be resized. With SizeMode.Responsive, Glance gives you different layouts per size:
private val Small = DpSize(120.dp, 90.dp)
private val Large = DpSize(250.dp, 150.dp)
class NextClassWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Responsive(setOf(Small, Large))
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val size = LocalSize.current
if (size.width >= Large.width) WideLayout() else CompactLayout()
}
}
}
This is the adaptive thinking of lesson 58 applied to widgets.
Limits to remember
- Glance has a subset of Compose: no arbitrary
Canvasor custom layouts, limited modifiers and a few component types. - Widgets run in the launcher process as RemoteViews, so keep content light.
- Images must be small: large bitmaps can exceed the RemoteViews memory limit and the widget fails to load.
- Use
GlanceThemewith Material 3 colours for dynamic colour on supported devices.
Standalone or companion?
A Wear app can be standalone, working without a phone and using its own network access, or a companion that depends on a phone app for data. Standalone is friendlier because many watches are used without the phone nearby, but it means you must handle connectivity, storage and sign-in on a tiny screen. Many apps choose a hybrid: the watch caches data and syncs through the phone when available. Whichever you choose, declare it honestly in the manifest and design the first-run experience so that a user is never stuck on an empty screen.
Battery and background work
A watch battery is small. Avoid wake locks, polling loops and frequent network calls. Prefer WorkManager for deferrable work, Health Services for sensor and exercise data (instead of reading sensors yourself), and ongoing activities for long-running tasks the user should see. Test with the Battery Historian or the Profiler, and measure before you assume.
Testing on small screens
Test on both a round and a square emulator, with large font and bold text settings turned on in the watch settings. Text that fits at the default size often overflows at the largest size, and clipped text on a round face loses meaning. Use @WearPreviewDevices and @WearPreviewFontScales (from the Wear tooling library, where available) to preview several shapes and font scales at once.
Glance versus ordinary Compose
| Aspect | Compose UI | Glance |
|---|---|---|
| Runs in | Your app process | The launcher, through RemoteViews |
| Modifier type | Modifier |
GlanceModifier |
| Click handling | Lambdas | Actions and callbacks |
| Animation and custom drawing | Full | Not supported |
| Lists | LazyColumn |
Glance LazyColumn (limited) |
| Update model | Recomposition on state change | Explicit update or updateAll calls |
Remember that a widget is a promise of freshness: if it shows yesterday's timetable, the user stops trusting it. Decide up front who updates it (your app, a Worker, or a push message) and test that path by changing the data while the widget is on the home screen.
Exercise 1: Wear versus phone
List four changes you would make when adapting a phone "Class list" screen for a round watch.
(1) Use ScalingLazyColumn (or the current Wear list) instead of LazyColumn. (2) Use Wear Chip/Button components with large touch targets. (3) Shorten labels and show at most the next few items. (4) Add TimeText, a position indicator and use SwipeDismissableNavHost for back. Optional: support the crown and a Tile for the next class.
Exercise 2: Spot the bug
Why does this widget code not compile?
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent { Column { Text("Hi") } }
}
It imports ordinary Compose Column and Text. Glance needs its own: androidx.glance.layout.Column and androidx.glance.text.Text, with GlanceModifier for modifiers.
Exercise 3: Update strategy
The widget should show fresh timetable data. Why not set updatePeriodMillis="60000", and what should you do instead?
The system does not honour periods shorter than about 30 minutes, and frequent updates waste battery. Instead update when data changes with updateAll(context), and use a WorkManager periodic job (15 minute minimum) for scheduled refreshes.
Mistake #1: Using phone Compose components on Wear. Fix: use the Wear component set.
Mistake #2: Putting content at the screen edge of a round display. Fix: use Scaffold, ScalingLazyColumn and padding.
Mistake #3: Importing Compose instead of Glance. Fix: check imports begin with androidx.glance.
Mistake #4: Doing network work in provideContent. Fix: load in provideGlance before provideContent, or in a Worker.
Mistake #5: Expecting lambdas to run in your app. Fix: use ActionCallbacks and actions.
Mistake #6: Large bitmaps in widgets. Fix: downscale or avoid.
Mistake #7: Frequent timed updates. Fix: event-driven updates and WorkManager.
Mistake #8: Forgetting to declare the receiver and metadata. Fix: manifest entry plus appwidget-provider XML.
Mistake #9: Testing only on a phone emulator. Fix: use a Wear emulator and several widget sizes.
Mistake #10: Treating alpha APIs as stable. Fix: read the release notes before upgrading.
Question 1. Why does Wear use SwipeDismissableNavHost?
Back on a watch is a swipe from the left edge, and the host provides that swipe-to-dismiss behaviour.
Question 2. What is ScalingLazyColumn for?
A scrolling list that scales and fades items towards the curved edges of a round screen.
Question 3. What does Glance translate to?
RemoteViews, which the launcher renders.
Question 4. How do you run code when a widget button is tapped?
Use actionRunCallback<YourCallback>() with an ActionCallback.
Question 5. Name a safe way to refresh widget data.
A WorkManager job or event-driven code that calls updateAll(context).
Go deeper: official resources
- Jetpack Compose for Android developers, Pathway 5
- Compose for Wear OS codelab
- Jetpack Compose course page
Project goal: Show the next class on a watch and on the home screen.
Requirements:
- A
shareddata source (a simple Kotlin object with a timetable list is fine). - A Wear OS app with a
ScalingLazyColumntimetable, a Mark attendance chip, and a detail screen usingSwipeDismissableNavHost. - A Glance widget that shows the next class, opens the app when tapped, and has a Mark present button using an
ActionCallback. - Widget layouts for at least two sizes with
SizeMode.Responsive. - A manifest receiver, metadata XML and a description string.
- A WorkManager job (or a simple call after data change) that refreshes the widget.
- Test on a Wear emulator and a phone, with the widget at two sizes.
Stretch goal: add a basic Tile that shows the same next-class text.
The shared data and a widget that reads it:
data class ClassSlot(val subject: String, val time: String)
object Timetable {
val slots = listOf(
ClassSlot("Mathematics", "10:30"),
ClassSlot("English", "11:30"),
ClassSlot("Physics", "13:00")
)
fun next(): ClassSlot = slots.first()
}
class NextClassWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Responsive(setOf(DpSize(120.dp, 90.dp), DpSize(250.dp, 150.dp)))
override suspend fun provideGlance(context: Context, id: GlanceId) {
val next = Timetable.next()
provideContent {
GlanceTheme {
val wide = LocalSize.current.width >= 250.dp
Column(
GlanceModifier.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.padding(12.dp)
.clickable(actionStartActivity<MainActivity>())
) {
Text(next.subject, style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp,
color = GlanceTheme.colors.onSurface))
Text(next.time, style = TextStyle(color = GlanceTheme.colors.onSurface))
if (wide) {
Button("Mark present", actionRunCallback<MarkPresentCallback>())
}
}
}
}
}
}
On the watch, the same Timetable.slots feed the ScalingLazyColumn using items(Timetable.slots.size) { index -> Chip(...) }. Because both surfaces read one data source, the next step is moving it into a repository with Room, exactly as in lessons 34 and 62.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 59 project”.
On github.com click New repository, name it android-lesson-59-wear-os-and-app-widgets-with-compose-and-g, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 59 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Designing for Small Surfaces
- Step-by-Step Build 1: A Wear OS Project
- Step-by-Step Build 2: Your First Wear Screen
- Step-by-Step Build 3: Navigation on the Watch
- Step-by-Step Build 4: Tiles and Complications (Overview)
- Step-by-Step Build 5: Your First App Widget with Glance
- Step-by-Step Build 6: Interactive and Updating Widgets
- Step-by-Step Build 7: Practicalities for Both Surfaces
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check