Wear OS Compose Watch Face Development
Wear OS Compose watch face development has been revolutionized by the Watch Face Format (WFF) and Jetpack Compose for Wear OS. Google’s new declarative approach replaces the legacy Canvas-based watch face API with an XML-based format that is more battery efficient, easier to build, and compatible with the Watch Face Studio visual editor. In 2026, WFF is the required format for new watch face submissions on Google Play.
This guide covers building watch faces from scratch using both the Watch Face Format for static/semi-dynamic faces and the Compose-based watch face service for fully custom rendering. You will learn how to add complications, handle user customization, optimize battery life, and publish to the Play Store.
Understanding Watch Face Formats
Google offers two approaches to watch face development in 2026, each suited for different complexity levels:
- Watch Face Format (WFF) — XML-based declarative format. Best for most watch faces. Battery efficient because the system renders it without running your app’s code. Supports complications, animations, and user configuration.
- WatchFaceService (Compose) — Code-based approach using Canvas or Compose. Required for truly custom rendering (particle effects, 3D, real-time data visualization). Uses more battery but offers unlimited creativity.
For 90% of watch faces, WFF is the right choice. Use the code-based approach only when WFF cannot express your design.
The reason the distinction matters so much comes down to who runs the rendering loop. With WFF, the system’s own watch face renderer interprets your XML, so your process is not even alive while the face is drawn. With the code-based service, your renderer runs in your own process and is woken on a schedule to draw frames. That single architectural difference drives nearly every battery, review-score, and maintenance consequence discussed below — so choose deliberately rather than reaching for code out of habit.
Building with Watch Face Format
WFF watch faces are defined in XML and packaged as an APK with no Activity or Service. The system’s watch face renderer handles all drawing, which means your watch face consumes zero CPU while the screen is on.
<!-- res/raw/watchface.xml -->
<WatchFace width="450" height="450">
<!-- Background -->
<Scene>
<Group name="background">
<PartImage
x="0" y="0" width="450" height="450"
resourceId="@drawable/bg_dark" />
</Group>
<!-- Hour markers -->
<Group name="hour_markers">
<PartImage x="213" y="10" width="24" height="40"
resourceId="@drawable/marker_12" />
<PartImage x="400" y="213" width="40" height="24"
resourceId="@drawable/marker_3"
pivotX="0.5" pivotY="0.5" />
<!-- ... other markers -->
</Group>
<!-- Digital time display -->
<DigitalClock x="125" y="180" width="200" height="50">
<TimeText format="HH:mm"
align="CENTER"
size="42"
color="#FFFFFF"
font="@font/roboto_mono_bold" />
</DigitalClock>
<!-- Date display -->
<DigitalClock x="150" y="240" width="150" height="30">
<TimeText format="EEE, MMM d"
align="CENTER"
size="16"
color="#88FFFFFF"
font="@font/roboto_regular" />
</DigitalClock>
<!-- Analog hands -->
<AnalogClock x="225" y="225">
<HourHand resourceId="@drawable/hand_hour"
width="16" height="140"
pivotX="0.5" pivotY="0.85" />
<MinuteHand resourceId="@drawable/hand_minute"
width="12" height="180"
pivotX="0.5" pivotY="0.9" />
<SecondHand resourceId="@drawable/hand_second"
width="4" height="190"
pivotX="0.5" pivotY="0.85"
color="#FF4444" />
</AnalogClock>
<!-- Complication slots -->
<ComplicationSlot x="125" y="315" width="80" height="80"
slotId="1"
supportedTypes="SHORT_TEXT ICON RANGED_VALUE"
defaultProvider="com.google.android.wearable.provider.battery" />
<ComplicationSlot x="245" y="315" width="80" height="80"
slotId="2"
supportedTypes="SHORT_TEXT ICON"
defaultProvider="com.google.android.wearable.provider.steps" />
</Scene>
<!-- Ambient mode (always-on display) -->
<AmbientScene>
<Group name="ambient_bg">
<PartImage x="0" y="0" width="450" height="450"
resourceId="@drawable/bg_ambient" />
</Group>
<DigitalClock x="125" y="200" width="200" height="50">
<TimeText format="HH:mm"
align="CENTER" size="48"
color="#AAAAAA"
font="@font/roboto_mono_light" />
</DigitalClock>
</AmbientScene>
</WatchFace>
A few structural rules are easy to miss the first time. The Scene describes the interactive (screen-on) state, while the AmbientScene describes the always-on display, and they are intentionally separate so the format can swap to a low-power render path without your involvement. WFF also supports simple expressions and conditionals through its configuration system, which is how a single XML file can react to user choices or complication data without any imperative code. Because the renderer validates the document at install time, malformed coordinates or unsupported tags fail fast rather than producing a blank face on the wrist.
Adding Complications That Users Actually Configure
Complications are the small data widgets — battery, steps, next calendar event — that users dock onto a watch face. In WFF you declare a ComplicationSlot with a position, a list of supportedTypes, and a sensible defaultProvider. The types matter: SHORT_TEXT renders a compact value, RANGED_VALUE draws an arc or gauge ideal for battery and activity rings, and ICON shows a glyph. Declaring several supported types per slot lets users wire in providers you never anticipated, which is precisely the appeal of the system.
A practical guideline from the design guidance is to include two to four slots, positioned symmetrically, and to pick defaults that look complete out of the box so the face is attractive before any customization. Avoid crowding the dial; complications that overlap the hands or the digital readout are the most common cosmetic complaint in store reviews.
User Customization with Flavors
WFF supports user-configurable options through flavors. Users can change colors, toggle complications, or switch between analog and digital modes directly from the watch face settings.
<!-- Watch face configuration options -->
<UserConfiguration>
<ColorOption id="accent_color"
label="Accent Color"
defaultValue="#4FC3F7">
<ColorEntry value="#4FC3F7" label="Blue" />
<ColorEntry value="#81C784" label="Green" />
<ColorEntry value="#FFB74D" label="Orange" />
<ColorEntry value="#F06292" label="Pink" />
<ColorEntry value="#BA68C8" label="Purple" />
</ColorOption>
<BooleanOption id="show_seconds"
label="Show Seconds"
defaultValue="true" />
<ListOption id="style"
label="Watch Style"
defaultValue="analog">
<ListEntry value="analog" label="Analog" />
<ListEntry value="digital" label="Digital" />
<ListEntry value="hybrid" label="Hybrid" />
</ListOption>
</UserConfiguration>
The configuration ids you define here are referenced from the scene, so a single face definition expands into many distinct looks without duplicating drawables. Keep the option set tight, though. Each additional toggle multiplies the visual states you must test, and there is a real cost: the editor UI on a small round screen becomes unwieldy past a handful of choices. In practice, an accent color, a seconds toggle, and a style switch cover what most users want.
Code-Based Watch Face with Compose
For watch faces that need custom rendering — particle effects, real-time data visualization, or complex animations — use the Wear OS Compose watch face service approach with Canvas drawing:
class CustomWatchFaceService : WatchFaceService() {
override suspend fun createWatchFace(
surfaceHolder: SurfaceHolder,
watchState: WatchState,
complicationSlotsManager: ComplicationSlotsManager,
currentUserStyleRepository: CurrentUserStyleRepository
): WatchFace {
val renderer = CustomCanvasRenderer(
surfaceHolder = surfaceHolder,
watchState = watchState,
complicationSlotsManager = complicationSlotsManager,
currentUserStyleRepository = currentUserStyleRepository,
canvasType = CanvasType.HARDWARE
)
return WatchFace(WatchFaceType.ANALOG, renderer)
}
}
class CustomCanvasRenderer(
surfaceHolder: SurfaceHolder,
watchState: WatchState,
complicationSlotsManager: ComplicationSlotsManager,
currentUserStyleRepository: CurrentUserStyleRepository,
canvasType: Int
) : Renderer.CanvasRenderer2(
surfaceHolder, currentUserStyleRepository, watchState,
canvasType, 16L, clearWithBackgroundTintBeforeRenderingHighlightLayer = true
) {
private val hourPaint = Paint().apply {
color = Color.WHITE
strokeWidth = 8f
strokeCap = Paint.Cap.ROUND
isAntiAlias = true
}
private val minutePaint = Paint().apply {
color = Color.WHITE
strokeWidth = 5f
strokeCap = Paint.Cap.ROUND
isAntiAlias = true
}
override suspend fun createSharedAssets(): SharedAssets = object : SharedAssets {
override fun onDestroy() {}
}
override fun render(
canvas: Canvas,
bounds: Rect,
zonedDateTime: ZonedDateTime,
sharedAssets: SharedAssets
) {
val centerX = bounds.exactCenterX()
val centerY = bounds.exactCenterY()
// Draw background
canvas.drawColor(Color.BLACK)
// Calculate hand angles
val hours = zonedDateTime.hour % 12
val minutes = zonedDateTime.minute
val seconds = zonedDateTime.second
val hourAngle = (hours + minutes / 60f) * 30f
val minuteAngle = (minutes + seconds / 60f) * 6f
// Draw hour hand
val hourLength = centerX * 0.5f
drawHand(canvas, centerX, centerY, hourAngle, hourLength, hourPaint)
// Draw minute hand
val minuteLength = centerX * 0.75f
drawHand(canvas, centerX, centerY, minuteAngle, minuteLength, minutePaint)
// Draw complications
for ((_, slot) in complicationSlotsManager.complicationSlots) {
slot.render(canvas, zonedDateTime, renderParameters)
}
}
private fun drawHand(
canvas: Canvas, cx: Float, cy: Float,
angle: Float, length: Float, paint: Paint
) {
val radians = Math.toRadians((angle - 90).toDouble())
val endX = cx + length * Math.cos(radians).toFloat()
val endY = cy + length * Math.sin(radians).toFloat()
canvas.drawLine(cx, cy, endX, endY, paint)
}
override fun renderHighlightLayer(
canvas: Canvas, bounds: Rect,
zonedDateTime: ZonedDateTime, sharedAssets: SharedAssets
) {
canvas.drawColor(renderParameters.highlightLayer!!.backgroundTint)
}
}
Several details in this class repay study. The 16L argument is the interactive frame interval in milliseconds, roughly 60 frames per second; raising it to 33L halves the frame rate and the energy spent when smoothness is not essential. The SharedAssets mechanism exists so that expensive, immutable resources — decoded bitmaps, gradient shaders, typefaces — are created once and shared across rendering passes rather than rebuilt per frame. The separate renderHighlightLayer draws the selection outlines shown in the watch face editor and never appears on the live face, so keep it cheap.
Battery Optimization
Watch face battery impact is the number one reason for negative Play Store reviews. Follow these rules to minimize power consumption:
// Battery optimization best practices
class BatteryEfficientRenderer : Renderer.CanvasRenderer2(...) {
override fun render(
canvas: Canvas, bounds: Rect,
zonedDateTime: ZonedDateTime, sharedAssets: SharedAssets
) {
// 1. In ambient mode: no second hand, no animations,
// minimal colors, update once per minute
if (renderParameters.drawMode == DrawMode.AMBIENT) {
renderAmbient(canvas, bounds, zonedDateTime)
return
}
// 2. Use hardware canvas (CanvasType.HARDWARE) not software
// 3. Avoid allocating objects in render() — pre-create paints
// 4. Skip second hand animation when wrist is down
// 5. Cache bitmap draws, redraw only changed portions
renderInteractive(canvas, bounds, zonedDateTime)
}
private fun renderAmbient(
canvas: Canvas, bounds: Rect, zonedDateTime: ZonedDateTime
) {
// Burn-in protection: shift content by a few pixels
val burnInOffsetX = (zonedDateTime.minute % 5) - 2
val burnInOffsetY = (zonedDateTime.minute % 7) - 3
canvas.translate(burnInOffsetX.toFloat(), burnInOffsetY.toFloat())
// Use outline-only rendering to minimize lit pixels on OLED
canvas.drawColor(Color.BLACK)
// Draw only hours and minutes, no seconds
}
}
The physics behind these rules is worth understanding so you apply them correctly. Wear OS hardware overwhelmingly uses OLED panels, where each lit pixel draws current and a fully black pixel draws almost none. That is why ambient designs favor thin outlines on black rather than filled shapes, and why a bright photographic background that looks stunning in screenshots can quietly halve all-day battery life. Ambient mode also restricts you to roughly one update per minute, so a seconds display is simply not available there — designing as if it were guarantees a jarring transition every time the wrist drops.
Burn-in protection, shown above by nudging content a few pixels, is mandatory on always-on faces: static elements left in one place for hours can permanently ghost into the panel. Allocation discipline is the other recurring theme. Allocating Paint objects, paths, or bitmaps inside render() creates garbage on every frame, and the resulting collection pauses both stutter the animation and waste energy. Pre-create everything you can, and reuse it.
When NOT to Use Wear OS Watch Faces
Building a watch face is not justified if your goal is just to display app-specific data (steps, weather, calendar). Instead, build a complication data provider — it integrates with any existing watch face and takes significantly less development effort. Similarly, if your watch face concept requires constant network requests or sensor polling, it will drain the battery quickly and receive poor user reviews.
The Watch Face Format has limitations — if you need real-time physics simulations, 3D rendering, or camera integration, WFF cannot support these. However, building a code-based watch face for these features means your app uses considerably more battery. Consider whether the creative vision justifies the battery cost for end users.
There is also a distribution reality to weigh. Because WFF is now the required format for new submissions, an existing Canvas-based face cannot simply be re-uploaded; it must be ported, and not every effect survives the translation. If your concept genuinely depends on per-frame imperative drawing, you are committing to the higher-battery code path and the support burden that comes with poorer reviews. For many teams, the honest answer is that a polished WFF face plus a well-built complication provider serves users better than an ambitious custom renderer that the battery cannot sustain.
Key Takeaways
- Watch face development offers two paths: WFF (XML, battery efficient) for most faces, and code-based Canvas rendering for custom effects
- Watch Face Format is required for new Google Play submissions and renders without running your code, maximizing battery life
- Complications let users add data from other apps to your watch face — always include 2-4 complication slots
- Ambient mode must use minimal rendering: no second hand, outline fonts, burn-in protection pixel shifting
- Battery optimization is critical — use hardware canvas, pre-allocate paints, and avoid object allocation in the render loop
Related Reading
- Jetpack Compose Performance Optimization
- Kotlin Multiplatform Compose for iOS and Android
- Jetpack Compose Android UI Guide
External Resources
Building with Watch Face Format
WFF watch faces are defined in XML and packaged as an APK with no Activity or Service. The system’s watch face renderer handles all drawing, which means your watch face consumes zero CPU while the screen is on.
<!-- res/raw/watchface.xml -->
<WatchFace width="450" height="450">
<!-- Background -->
<Scene>
<Group name="background">
<PartImage
x="0" y="0" width="450" height="450"
resourceId="@drawable/bg_dark" />
</Group>
<!-- Hour markers -->
<Group name="hour_markers">
<PartImage x="213" y="10" width="24" height="40"
resourceId="@drawable/marker_12" />
<PartImage x="400" y="213" width="40" height="24"
resourceId="@drawable/marker_3"
pivotX="0.5" pivotY="0.5" />
<!-- ... other markers -->
</Group>
<!-- Digital time display -->
<DigitalClock x="125" y="180" width="200" height="50">
<TimeText format="HH:mm"
align="CENTER"
size="42"
color="#FFFFFF"
font="@font/roboto_mono_bold" />
</DigitalClock>
<!-- Date display -->
<DigitalClock x="150" y="240" width="150" height="30">
<TimeText format="EEE, MMM d"
align="CENTER"
size="16"
color="#88FFFFFF"
font="@font/roboto_regular" />
</DigitalClock>
<!-- Analog hands -->
<AnalogClock x="225" y="225">
<HourHand resourceId="@drawable/hand_hour"
width="16" height="140"
pivotX="0.5" pivotY="0.85" />
<MinuteHand resourceId="@drawable/hand_minute"
width="12" height="180"
pivotX="0.5" pivotY="0.9" />
<SecondHand resourceId="@drawable/hand_second"
width="4" height="190"
pivotX="0.5" pivotY="0.85"
color="#FF4444" />
</AnalogClock>
<!-- Complication slots -->
<ComplicationSlot x="125" y="315" width="80" height="80"
slotId="1"
supportedTypes="SHORT_TEXT ICON RANGED_VALUE"
defaultProvider="com.google.android.wearable.provider.battery" />
<ComplicationSlot x="245" y="315" width="80" height="80"
slotId="2"
supportedTypes="SHORT_TEXT ICON"
defaultProvider="com.google.android.wearable.provider.steps" />
</Scene>
<!-- Ambient mode (always-on display) -->
<AmbientScene>
<Group name="ambient_bg">
<PartImage x="0" y="0" width="450" height="450"
resourceId="@drawable/bg_ambient" />
</Group>
<DigitalClock x="125" y="200" width="200" height="50">
<TimeText format="HH:mm"
align="CENTER" size="48"
color="#AAAAAA"
font="@font/roboto_mono_light" />
</DigitalClock>
</AmbientScene>
</WatchFace>
User Customization with Flavors
WFF supports user-configurable options through flavors. Users can change colors, toggle complications, or switch between analog and digital modes directly from the watch face settings.
<!-- Watch face configuration options -->
<UserConfiguration>
<ColorOption id="accent_color"
label="Accent Color"
defaultValue="#4FC3F7">
<ColorEntry value="#4FC3F7" label="Blue" />
<ColorEntry value="#81C784" label="Green" />
<ColorEntry value="#FFB74D" label="Orange" />
<ColorEntry value="#F06292" label="Pink" />
<ColorEntry value="#BA68C8" label="Purple" />
</ColorOption>
<BooleanOption id="show_seconds"
label="Show Seconds"
defaultValue="true" />
<ListOption id="style"
label="Watch Style"
defaultValue="analog">
<ListEntry value="analog" label="Analog" />
<ListEntry value="digital" label="Digital" />
<ListEntry value="hybrid" label="Hybrid" />
</ListOption>
</UserConfiguration>
Code-Based Watch Face with Compose
For watch faces that need custom rendering — particle effects, real-time data visualization, or complex animations — use the Wear OS Compose watch face service approach with Canvas drawing:
class CustomWatchFaceService : WatchFaceService() {
override suspend fun createWatchFace(
surfaceHolder: SurfaceHolder,
watchState: WatchState,
complicationSlotsManager: ComplicationSlotsManager,
currentUserStyleRepository: CurrentUserStyleRepository
): WatchFace {
val renderer = CustomCanvasRenderer(
surfaceHolder = surfaceHolder,
watchState = watchState,
complicationSlotsManager = complicationSlotsManager,
currentUserStyleRepository = currentUserStyleRepository,
canvasType = CanvasType.HARDWARE
)
return WatchFace(WatchFaceType.ANALOG, renderer)
}
}
class CustomCanvasRenderer(
surfaceHolder: SurfaceHolder,
watchState: WatchState,
complicationSlotsManager: ComplicationSlotsManager,
currentUserStyleRepository: CurrentUserStyleRepository,
canvasType: Int
) : Renderer.CanvasRenderer2(
surfaceHolder, currentUserStyleRepository, watchState,
canvasType, 16L, clearWithBackgroundTintBeforeRenderingHighlightLayer = true
) {
private val hourPaint = Paint().apply {
color = Color.WHITE
strokeWidth = 8f
strokeCap = Paint.Cap.ROUND
isAntiAlias = true
}
private val minutePaint = Paint().apply {
color = Color.WHITE
strokeWidth = 5f
strokeCap = Paint.Cap.ROUND
isAntiAlias = true
}
override suspend fun createSharedAssets(): SharedAssets = object : SharedAssets {
override fun onDestroy() {}
}
override fun render(
canvas: Canvas,
bounds: Rect,
zonedDateTime: ZonedDateTime,
sharedAssets: SharedAssets
) {
val centerX = bounds.exactCenterX()
val centerY = bounds.exactCenterY()
// Draw background
canvas.drawColor(Color.BLACK)
// Calculate hand angles
val hours = zonedDateTime.hour % 12
val minutes = zonedDateTime.minute
val seconds = zonedDateTime.second
val hourAngle = (hours + minutes / 60f) * 30f
val minuteAngle = (minutes + seconds / 60f) * 6f
// Draw hour hand
val hourLength = centerX * 0.5f
drawHand(canvas, centerX, centerY, hourAngle, hourLength, hourPaint)
// Draw minute hand
val minuteLength = centerX * 0.75f
drawHand(canvas, centerX, centerY, minuteAngle, minuteLength, minutePaint)
// Draw complications
for ((_, slot) in complicationSlotsManager.complicationSlots) {
slot.render(canvas, zonedDateTime, renderParameters)
}
}
private fun drawHand(
canvas: Canvas, cx: Float, cy: Float,
angle: Float, length: Float, paint: Paint
) {
val radians = Math.toRadians((angle - 90).toDouble())
val endX = cx + length * Math.cos(radians).toFloat()
val endY = cy + length * Math.sin(radians).toFloat()
canvas.drawLine(cx, cy, endX, endY, paint)
}
override fun renderHighlightLayer(
canvas: Canvas, bounds: Rect,
zonedDateTime: ZonedDateTime, sharedAssets: SharedAssets
) {
canvas.drawColor(renderParameters.highlightLayer!!.backgroundTint)
}
}
In conclusion, Wear OS Compose watch face development is an essential skill for modern Android wearable engineering. By applying the patterns and practices covered in this guide — choosing WFF by default, reserving code-based rendering for genuinely custom designs, and treating battery as a first-class constraint — you can build watch faces that are both expressive and dependable. Start with the fundamentals, iterate on your design, and continuously measure power draw on real hardware.