← Back to books

Chapter 2: Your First Wear App with Compose

In the last chapter we established the mindset. Now we build. By the end of this chapter you'll have Cadence running on a watch — a real glanceable home screen with the clock arced across the top, a scroll indicator that appears only when it's needed, and content that scales and fades as it approaches the curved edges of the display. It won't track a run yet, but it will already look and behave like it belongs on the wrist rather than like a shrunken phone app.

The reason we can get there quickly is that Compose for Wear OS reuses everything you already know about Compose — the same @Composable functions, the same state model, the same modifiers — and swaps in a Wear-specific set of components and structural primitives for the parts where the round form factor actually matters. Most of your Compose knowledge transfers untouched. This chapter is mostly about the parts that don't.

2.1 Creating the project

Open Android Studio and create a new project. In the template chooser, select the Wear OS category and pick the Empty Wear App (Compose) template. This gets you a project already wired for Wear: the right Gradle plugins, a Wear-flavored manifest, and a starting MainActivity.

You can start from a blank project and add everything by hand, and doing it once is educational, but the template saves you from a class of subtle manifest and dependency mistakes. We'll walk through everything the template sets up so nothing is magic.

Name the application module and package whatever you like; throughout the book I'll use the package dev.androidhire.cadence. Set the Minimum SDK to API 30 (Wear OS 3). That's the floor for Compose for Wear OS and a reasonable modern baseline. Every API level below that you'd try to support is real engineering cost for a shrinking slice of old hardware, and Chapter 12 will revisit distribution tradeoffs.

2.2 The dependency stack, and why it's different

Here's the first place Wear diverges from the phone, and it trips people up, so let's be precise about it.

When you build a phone Compose app you reach for androidx.compose.material3:material3. On Wear OS, you do not use that library. You use the Wear-specific Material 3 library instead. The layering looks like this:

What you need Wear OS artifact Relationship to the phone artifact
Material components androidx.wear.compose:compose-material3 instead of androidx.compose.material3:material3
Navigation androidx.wear.compose:compose-navigation instead of androidx.navigation:navigation-compose
Foundation androidx.wear.compose:compose-foundation in addition to androidx.compose.foundation:foundation
Preview tooling androidx.wear.compose:compose-ui-tooling in addition to the standard preview tooling

The mental model: the lower layers of Compose — runtime, compiler, ui, animation — are identical on both platforms, so those dependencies don't change. The upper layers — Material, Navigation, and some of Foundation — have Wear-specific versions because that's exactly where the round-screen behavior, the curved clock, the rotary scrolling, and the wearable component shapes live.

W> Never mix Material 3 and Material 2.5 in one app. The older androidx.wear.compose:compose-material (Material 2.5) still exists and still appears in tutorials. Pick one — for a new app, Material 3 — and stay there. Mixing them produces inconsistent theming and, worse, subtle layout bugs that waste an afternoon.

Let's define versions in a version catalog so the whole project shares one source of truth. In gradle/libs.versions.toml:

[versions]
composeBom = "2026.05.00"
wearComposeMaterial3 = "1.6.2"
wearComposeFoundation = "1.6.2"
wearComposeNavigation = "1.6.2"
wearComposeUiTooling = "1.6.2"
activityCompose = "1.13.0"
horologist = "0.7.0"

[libraries]
# Bill of Materials — pins all general Compose artifact versions together
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }

# General Compose (versions come from the BOM)
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }

# Wear-specific Compose
wear-compose-material3 = { group = "androidx.wear.compose", name = "compose-material3", version.ref = "wearComposeMaterial3" }
wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearComposeFoundation" }
wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearComposeNavigation" }
wear-compose-ui-tooling = { group = "androidx.wear.compose", name = "compose-ui-tooling", version.ref = "wearComposeUiTooling" }

# Horologist — layout helpers that make responsive Wear lists painless
horologist-compose-layout = { group = "com.google.android.horologist", name = "horologist-compose-layout", version.ref = "horologist" }

And in the app module's build.gradle.kts:

dependencies {
    // The BOM aligns all general Compose artifact versions.
    val composeBom = platform(libs.compose.bom)
    implementation(composeBom)

    // General Compose — identical to what you use on the phone.
    implementation(libs.activity.compose)
    implementation(libs.compose.ui.tooling.preview)
    implementation(libs.material.icons.extended)

    // Wear-specific: Material 3 for Wear, NOT androidx.compose.material3.
    implementation(libs.wear.compose.material3)

    // Foundation is additive — the Wear version alongside the standard one.
    implementation(libs.wear.compose.foundation)

    // Navigation and preview tooling, Wear flavors.
    implementation(libs.wear.compose.navigation)
    implementation(libs.wear.compose.ui.tooling)

    // Horologist layout helpers (used for responsive list padding).
    implementation(libs.horologist.compose.layout)

    // Tooling and tests.
    debugImplementation(libs.compose.ui.tooling)
    debugImplementation(libs.compose.ui.test.manifest)
    debugImplementation(composeBom)
}

I> Horologist is a Google-maintained companion library for Wear OS that fills gaps around the official components — responsive layout padding, media UI, health helpers, and more. It's not mandatory, but its rememberResponsiveColumnPadding helper is the cleanest way to get correct top-and-bottom padding on a scrolling list across different watch sizes, so we adopt it from the start. We'll use more of it later.

Set compileSdk and targetSdk to 36 (Android 16, the base for Wear OS 6) and minSdk to 30 in your module's defaultConfig.

2.3 The manifest

A Wear app's manifest has a few elements a phone app's doesn't. Here's AndroidManifest.xml, annotated:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Declares this APK is meant for watches. -->
    <uses-feature android:name="android.hardware.type.watch" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.DeviceDefault">

        <!-- The Wearable shared library. -->
        <uses-library
            android:name="com.google.android.wearable"
            android:required="true" />

        <!-- Marks the app as standalone: it installs and runs
             on the watch without a companion phone app. -->
        <meta-data
            android:name="com.google.android.wearable.standalone"
            android:value="true" />

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:taskAffinity=""
            android:theme="@style/CadenceTheme.Splash">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

The two lines that matter most for us right now: uses-feature ... type.watch tells the Play Store this is a watch app, and the standalone meta-data set to true declares that Cadence works with no phone attached — exactly the design goal we set in Chapter 1. We'll revisit both when we talk distribution in Chapter 12.

2.4 The theme

Theming works the same way it does on the phone — a MaterialTheme supplying colors, typography, and shapes — but you pull MaterialTheme from the Wear Material 3 package, and you generally leave the shapes alone because the Wear defaults are tuned for round devices.

Create ui/theme/Theme.kt:

package dev.androidhire.cadence.ui.theme

import androidx.compose.runtime.Composable
import androidx.wear.compose.material3.MaterialTheme

@Composable
fun CadenceTheme(
    content: @Composable () -> Unit,
) {
    // Using the default Wear Material 3 color scheme, typography,
    // and shapes. These defaults are designed for the round form
    // factor, so we deliberately do NOT override shapes here.
    MaterialTheme(
        content = content,
    )
}

That's genuinely all you need to start. Two notes for later:

  • Dynamic color. On supported watches, Material 3 Expressive can generate your color scheme from the active watch face, so Cadence would automatically harmonize with whatever face the wearer chose. We'll leave the default scheme for now and return to dynamic color when we care about polish.
  • Typography on Wear is its own scale, tuned for glanceability on small round screens. When you reach for a text style later, use MaterialTheme.typography and trust that the Wear defaults are already sized for the wrist.

2.5 MainActivity

The activity is thin. It extends ComponentActivity and hands off to Compose immediately:

package dev.androidhire.cadence

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import dev.androidhire.cadence.ui.CadenceApp

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        // Wear apps install a splash screen before setContent.
        installSplashScreen()
        super.onCreate(savedInstanceState)

        setContent {
            CadenceApp()
        }
    }
}

If you've written a Compose phone app, this is exactly what you expect. Nothing about the activity is Wear-specific except the splash screen convention. All the interesting Wear behavior lives in the composables.

2.6 The app shell: AppScaffold

Now the first genuinely Wear-specific structural piece. On the phone, Scaffold gives you an app bar, a FAB, a drawer. None of those exist on a watch. Instead, Wear has a two-level scaffold system that coordinates the watch's structural elements: the clock, the scroll indicator, and page indicators.

AppScaffold is the outer, app-level shell. It hosts things that should persist across screen transitions — most importantly TimeText, the curved clock along the top arc. Because it lives at the app level, the clock stays put and behaves correctly even as you navigate or swipe-to-dismiss between screens.

Create ui/CadenceApp.kt:

package dev.androidhire.cadence.ui

import androidx.compose.runtime.Composable
import androidx.wear.compose.material3.AppScaffold
import dev.androidhire.cadence.ui.home.CadenceHomeScreen
import dev.androidhire.cadence.ui.theme.CadenceTheme

@Composable
fun CadenceApp() {
    CadenceTheme {
        AppScaffold {
            // For now, a single screen. In Chapter 5 this becomes
            // a navigation host with several destinations.
            CadenceHomeScreen()
        }
    }
}

AppScaffold provides a default TimeText automatically — you don't have to place the clock yourself. That's the platform doing the right thing for you: the Material guidelines call for the time at the top of every screen, and the scaffold honors that by default, fading it out while the user scrolls.

2.7 The screen shell: ScreenScaffold and a glanceable list

ScreenScaffold is the inner, per-screen shell. Where AppScaffold owns app-wide furniture, ScreenScaffold owns this screen's scroll behavior: it shows a ScrollIndicator on the right edge tied to your list's scroll state, coordinates hiding and showing the clock as you scroll, and — the nice part — gives you a dedicated slot for an EdgeButton that hugs the bottom of the round screen.

Our home screen is a vertically scrolling list of glanceable items built on TransformingLazyColumn, the round-aware list that scales and fades content toward the curved top and bottom edges. Create ui/home/CadenceHomeScreen.kt:

package dev.androidhire.cadence.ui.home

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DirectionsRun
import androidx.compose.material.icons.rounded.History
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.ButtonDefaults
import androidx.wear.compose.material3.EdgeButton
import androidx.wear.compose.material3.EdgeButtonSize
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.ListHeader
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.lazy.rememberTransformationSpec
import androidx.wear.compose.material3.lazy.transformedHeight
import androidx.wear.compose.material3.SurfaceTransformation
import com.google.android.horologist.compose.layout.ColumnItemType
import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding
import dev.androidhire.cadence.R

@Composable
fun CadenceHomeScreen(
    onStartRun: () -> Unit = {},
    onOpenHistory: () -> Unit = {},
) {
    val listState = rememberTransformingLazyColumnState()
    val transformationSpec = rememberTransformationSpec()

    ScreenScaffold(
        scrollState = listState,
        contentPadding = rememberResponsiveColumnPadding(
            first = ColumnItemType.ListHeader,
            last = ColumnItemType.Button,
        ),
        edgeButton = {
            EdgeButton(
                onClick = onOpenHistory,
                buttonSize = EdgeButtonSize.Medium,
            ) {
                Icon(
                    imageVector = Icons.Rounded.History,
                    contentDescription = null,
                )
                Text(stringResource(R.string.history))
            }
        },
    ) { contentPadding ->
        TransformingLazyColumn(
            state = listState,
            contentPadding = contentPadding,
        ) {
            item {
                ListHeader(
                    modifier = Modifier
                        .transformedHeight(this, transformationSpec),
                    transformation = SurfaceTransformation(transformationSpec),
                ) {
                    Text(stringResource(R.string.app_name))
                }
            }

            item {
                // The primary, one-tap action: start a run.
                Button(
                    onClick = onStartRun,
                    modifier = Modifier
                        .fillMaxWidth()
                        .transformedHeight(this, transformationSpec),
                    transformation = SurfaceTransformation(transformationSpec),
                    icon = {
                        Icon(
                            imageVector = Icons.Rounded.DirectionsRun,
                            contentDescription = null,
                        )
                    },
                ) {
                    Text(stringResource(R.string.start_run))
                }
            }

            item {
                // A glanceable stat. Real data arrives in Chapter 6;
                // for now it's a static placeholder.
                TodayDistanceCard(
                    modifier = Modifier
                        .transformedHeight(this, transformationSpec),
                    transformation = SurfaceTransformation(transformationSpec),
                )
            }
        }
    }
}

Let's slow down on the parts that are new.

rememberTransformingLazyColumnState() is the Wear analog of rememberLazyListState(). It drives both the list and the ScrollIndicator — you pass the same state object to ScreenScaffold (as scrollState) and to the list, and the platform wires the indicator up for you.

rememberResponsiveColumnPadding(first, last) comes from Horologist. On a round screen the correct top and bottom padding depends on the size of the first and last items and on the physical watch size — get it wrong and your first item clips into the curve or your last item is unreachable. Rather than hand-tuning dp values per device, you declare the type of the first and last items and let the helper compute responsive padding. Here the first item is a ListHeader and the last is a Button.

transformedHeight(this, transformationSpec) and SurfaceTransformation(transformationSpec) are what make items scale and fade toward the edges. The this refers to the item scope inside the TransformingLazyColumn. Apply both to every item you want to participate in the effect, and the list traces the round display beautifully as it scrolls. (A small number of components don't yet support the transformation; when in doubt, the compiler tells you.)

EdgeButton lives in the ScreenScaffold's edgeButton slot, not in the list. It scales up and fades in as the user scrolls to the bottom, and hugs the curved bottom edge — reclaiming space a rectangular button would waste. We're using it for "History," a secondary action, while "Start run" stays a prominent primary Button in the list itself.

Here's the small stat card referenced above. Put it in the same file or its own; I'll keep it separate as ui/home/TodayDistanceCard.kt:

package dev.androidhire.cadence.ui.home

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.SurfaceTransformation
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.TitleCard

@Composable
fun TodayDistanceCard(
    modifier: Modifier = Modifier,
    transformation: SurfaceTransformation? = null,
) {
    TitleCard(
        onClick = { /* Opens today's detail in a later chapter. */ },
        modifier = modifier.fillMaxWidth(),
        transformation = transformation,
        title = { Text("Today") },
    ) {
        // Static for now; wired to Health Services data in Chapter 6.
        Text(
            text = "0.0 km",
            style = MaterialTheme.typography.numeralMedium,
        )
    }
}

Two things worth noticing here even in placeholder form. First, we reached for TitleCard — one of the Wear card types — rather than rolling a custom surface, so it's themed and shaped correctly for free. Second, the distance uses a numeral typography style. Material 3 Expressive on Wear ships type styles tuned specifically for large glanceable numbers, which is exactly the kind of thing a runner reads at arm's length. Using the right style now means the number is legible the moment real data flows in.

Finally, the strings. In res/values/strings.xml:

<resources>
    <string name="app_name">Cadence</string>
    <string name="start_run">Start run</string>
    <string name="history">History</string>
</resources>

2.8 Previews without leaving your desk

Deploying to a watch or booting an emulator for every tweak is slow. The Wear tooling gives you preview annotations that render your composables at correct watch sizes and shapes right in Studio. Add a preview to the home screen:

import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
import dev.androidhire.cadence.ui.theme.CadenceTheme

@WearPreviewDevices
@Composable
private fun CadenceHomeScreenPreview() {
    CadenceTheme {
        // Wrap in AppScaffold so the clock and structure render
        // the way they will at runtime.
        androidx.wear.compose.material3.AppScaffold {
            CadenceHomeScreen()
        }
    }
}

@WearPreviewDevices renders the composable across a representative set of watch profiles at once — small round, large round, and square — so you catch a layout that works on a big Pixel Watch but breaks on a small square device before you deploy. There are also single-target annotations (@WearPreviewSmallRound, @WearPreviewLargeRound, @WearPreviewSquare) when you want to focus on one.

T> Preview inside the scaffold. A composable previewed bare, without AppScaffold/ScreenScaffold, can look fine and then be wrong at runtime because it's missing the clock, the scroll indicator, and the responsive padding. Preview your screens the way they actually run — wrapped in the scaffolds — so the preview tells you the truth.

2.9 Running it

Pick your target in Studio's device dropdown — the Wear OS emulator you created in Chapter 1, or a physical watch connected over Wi-Fi — and run.

What you should see: "Cadence" in a header at the top, a curved clock arced above it, a prominent Start run button, a Today / 0.0 km card below it, and a History edge button hugging the bottom. Scroll with a drag or the rotary bezel and watch two things happen: the items scale and fade as they approach the top and bottom curves, and the scroll indicator appears on the right edge only while you're actually scrolling, then fades away.

If you're on the emulator, try switching between a round and a square profile to confirm the layout holds. If you're on hardware, do the thing that actually matters: raise your wrist and look at it for three seconds, the way a real user will. Can you tell at a glance that you can start a run? That's the test from Chapter 1, and passing it on real hardware is the only verification that counts.

W> If the build fails on the Wear artifacts, the usual cause is accidentally pulling in androidx.compose.material3:material3 (the phone library) alongside the Wear one — often dragged in transitively by a copied-in dependency. Check your dependency tree and make sure the only Material 3 in the graph is androidx.wear.compose:compose-material3. This is the single most common setup mistake, and it's the exact thing Section 2.2 warned about.

2.10 What we built, and what's next

In one chapter Cadence went from nothing to a real Wear OS app that respects the form factor: a standalone app (per the manifest) with the curved clock, a round-aware scrolling list that scales and fades at the edges, a responsive layout that survives different watch sizes, a glanceable primary action, and an edge-hugging secondary action — all themed by Material 3 Expressive and all previewable without leaving Studio.

Notice how little of this was exotic. The activity, the state model, the composable structure, the previews — all familiar Compose. The Wear-specific surface area was small and concentrated exactly where the round screen and the glance budget demanded it: the scaffold pair, TransformingLazyColumn, the transformation modifiers, EdgeButton, and the Wear flavors of the Material and Foundation libraries.

Cadence currently has one screen with static content. In Chapter 3 we go deep on the Material 3 Expressive component catalog — the buttons that morph shape on press, the cards, dialogs, pickers, and progress indicators — and build out Cadence's real screens with them. Then in Chapters 4 and 5 we make the app scrollable-by-rotary in earnest and give it genuine multi-screen navigation, before wiring in live run data with Health Services in Chapter 6.