|
1 | | -# ComposeOClock |
2 | | -Draw Wear OS Watch Faces with Compose Canvas (& runtime) |
| 1 | +# Compose O'Clock |
| 2 | + |
| 3 | +Draw Wear OS Watch Faces with Compose Canvas (& runtime) |
| 4 | + |
| 5 | +Compose O'Clock is the fastest way to develop a good looking and efficient Watch Face for Wear OS. |
| 6 | + |
| 7 | +Using this SDK, knowing how to use Jetpack Compose is about 96% of the knowledge you need to know to |
| 8 | +successfully get a proper watch face that you can also see live in a phone app. |
| 9 | + |
| 10 | +The 4% remaining to develop a good watch face app is documented here. |
| 11 | + |
| 12 | +## Features |
| 13 | + |
| 14 | +- ✅ **Live-preview** your Watch Face in Android Studio, a phone, or a watch |
| 15 | +- ✅ **Adaptive refresh-rate** out of the box (up to 60fps on watches, and 120fps on phones) |
| 16 | +- ✅ Compose Canvas and text APIs (**Kotlin-friendly!**) |
| 17 | +- ✅ Shader effects (Wear OS 4+) |
| 18 | +- ✅ Auto recomposition or re-render on state change |
| 19 | +- ✅ Convenience **time APIs** for clocks |
| 20 | +- ✅ Watch Face **specific optimizations** |
| 21 | +- ✅ **Instant** style changes, including while in ambient mode |
| 22 | +- ✅ Render your Watch Faces **in any Composable** in the phone or watch app |
| 23 | +- ✅ Full freedom for **complications** rendering (custom data like weather, step counts, heart rate…) |
| 24 | +- ✅ **Technical support** available for pro licenses |
| 25 | + |
| 26 | +## Getting started |
| 27 | + |
| 28 | +This repository contains a starter project. |
| 29 | +After cloning the project and opening it in Android Studio, you can navigate to the Watch Face |
| 30 | +entry point: [SampleWatchFaceService.kt](app-watch/src/main/kotlin/SampleWatchFaceService.kt). |
| 31 | + |
| 32 | +It all starts with this function inside `ComposeWatchFaceService`: |
| 33 | + |
| 34 | +```kotlin |
| 35 | +@Composable |
| 36 | +override fun Content(complicationData: Map<Int, StateFlow<ComplicationData>>) { |
| 37 | + MyWatchFace() |
| 38 | +} |
| 39 | +``` |
| 40 | + |
| 41 | +Here's how a simple, typical Watch Face looks like: |
| 42 | + |
| 43 | +```kotlin |
| 44 | +@Composable |
| 45 | +fun MyWatchFace() { |
| 46 | + FancyBackground() |
| 47 | + CoolHourPips() |
| 48 | + SomeExtraStuff() |
| 49 | + HourHand() |
| 50 | + MinutesHand() |
| 51 | + SecondsHand() |
| 52 | +} |
| 53 | +``` |
| 54 | + |
| 55 | +Inside those other composables, you need to use `OClockCanvas` at some point to draw things. |
| 56 | + |
| 57 | +It's exactly like `Canvas` from Compose, except that it also takes an optional `onTap` parameter, |
| 58 | +and that it will work in a Watch Face. |
| 59 | + |
| 60 | +Here's how `SecondsHand()` from above could look like: |
| 61 | + |
| 62 | +```kotlin |
| 63 | +@Composable |
| 64 | +private fun SecondsHand() { |
| 65 | + val time = LocalTime.current |
| 66 | + val isAmbient by LocalIsAmbient.current // We use delegation to get a Boolean here |
| 67 | + OClockCanvas { |
| 68 | + if (isAmbient) return@OClockCanvas // Don't read seconds in ambient mode, return early. |
| 69 | + // Because we read the time just below, |
| 70 | + // this will auto-re-render when the second changes. |
| 71 | + val degrees = time.seconds * 6f // 60s × 6 -> 360° |
| 72 | + val top = size.height / 32f |
| 73 | + drawLine( // Can use anything from Compose DrawScope |
| 74 | + someColor, |
| 75 | + start = center.copy(y = top).rotate(degrees), |
| 76 | + end = center, |
| 77 | + strokeWidth = 2.dp.toPx(), |
| 78 | + blendMode = BlendMode.Lighten, // Because why not! More on that below. |
| 79 | + cap = StrokeCap.Round |
| 80 | + ) |
| 81 | + } |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +## `OClockCanvas` |
| 86 | + |
| 87 | +### One canvas for all by default |
| 88 | + |
| 89 | +`OClockCanvas` is actually a bit more powerful than `Canvas`: |
| 90 | +All `OClockCanvas` will share the same underlying canvas, without layering by default, |
| 91 | +which means that if you use a `BlendMode`, it will apply over everything that has been drawn yet. |
| 92 | + |
| 93 | +It can have multiple use cases, like: |
| 94 | +- Tinting what's been drawn before (without having to touch those components), using `BlendMode.SrcIn` |
| 95 | +- Drawing on the pixels that haven't been drawn yet (e.g. draw a background last), using `BlendMode.DstOver` |
| 96 | +- Avoid overlaps, using `BlendMode.Xor` for example. |
| 97 | +- Make overlaps visible, using `BlendMode.Plus` or `BlendMode.Lighten` for example. |
| 98 | + |
| 99 | +### Taps |
| 100 | + |
| 101 | +Wear OS Watch Faces support taps (but not swipes, which the system already uses for tiles, notifications, and quick settings). |
| 102 | + |
| 103 | +When displaying an `OClockCanvas` element in a regular compose hierarchy (on the phone, in a watch, or in Android Studio preview), |
| 104 | +the behavior will be exactly the same as when set as a Watch Face: a swipe will cause the tap to be cancelled. Proper taps will be registered. |
| 105 | + |
| 106 | +Make sure your `onTap` handlers return `true` if the tap occurs in the region where you support actions, and `false` otherwise, |
| 107 | +**regardless of whether it's a tap down or up**. |
| 108 | + |
| 109 | +Also make sure tap actions are fired only on tap-up, and make sure you're not leaving broken/unwanted state on tap cancel. |
| 110 | + |
| 111 | +In addition to the `event`, the `onTap` lambda has an `OnTapScope` receiver, which contains the `size`, `center`, and density info, so it can be used along with the `position` `Offset` from the event to determine whether the tap should be handled or not. |
| 112 | + |
| 113 | +## Complications |
| 114 | + |
| 115 | +The complications APIs from `androidx.wear.watchface` are… well… complicated. |
| 116 | +Compose O'Clock provides several Compose friendly extensions that cover all types of complications |
| 117 | +that Wear OS supports. |
| 118 | + |
| 119 | +_Complication related code won't crash when running on a phone. Showing placeholder data is supported, |
| 120 | +and it's possible to forward live the actual complication data from the watch face to the phone app._ |
| 121 | + |
| 122 | +### For `ComplicationText?` & `ComplicationText` |
| 123 | + |
| 124 | +```kotlin |
| 125 | +@Composable |
| 126 | +fun ComplicationText?.rememberMeasuredAsState( |
| 127 | + default: String, |
| 128 | + callMeasure: TextMeasurer.(string: String) -> TextLayoutResult |
| 129 | +): State<TextLayoutResult> |
| 130 | + |
| 131 | +@Composable |
| 132 | +fun ComplicationText.rememberMeasuredAsState( |
| 133 | + callMeasure: TextMeasurer.(string: String) -> TextLayoutResult |
| 134 | +): State<TextLayoutResult> |
| 135 | +``` |
| 136 | + |
| 137 | +Note that `TextLayoutResult` can directly be used in `drawText` inside `OClockCanvas`. |
| 138 | + |
| 139 | +### For images/icons in `ComplicationData` subclasses |
| 140 | + |
| 141 | +```kotlin |
| 142 | +@Composable |
| 143 | +inline fun NoPermissionComplicationData.rememberDrawableAsState( |
| 144 | + preferSmallImage: Boolean = false, |
| 145 | + tint: Color = Color.Unspecified, |
| 146 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 147 | + ambientTint: Color = tint, |
| 148 | + ambientTintBlendMode: BlendMode = tintBlendMode |
| 149 | +): State<Drawable?> |
| 150 | + |
| 151 | +@Composable |
| 152 | +inline fun LongTextComplicationData.rememberDrawableAsState( |
| 153 | + preferSmallImage: Boolean = false, |
| 154 | + tint: Color = Color.Unspecified, |
| 155 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 156 | + ambientTint: Color = tint, |
| 157 | + ambientTintBlendMode: BlendMode = tintBlendMode |
| 158 | +): State<Drawable?> |
| 159 | + |
| 160 | +@Composable |
| 161 | +inline fun MonochromaticImageComplicationData.rememberDrawableAsState( |
| 162 | + tint: Color = Color.Unspecified, |
| 163 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 164 | + ambientTint: Color = tint, |
| 165 | + ambientTintBlendMode: BlendMode = tintBlendMode |
| 166 | +): State<Drawable?> |
| 167 | + |
| 168 | +@Composable |
| 169 | +inline fun PhotoImageComplicationData.rememberDrawableAsState( |
| 170 | + tint: Color = Color.Unspecified, |
| 171 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 172 | +): State<Drawable?> |
| 173 | + |
| 174 | +@Composable |
| 175 | +inline fun RangedValueComplicationData.rememberDrawableAsState( |
| 176 | + preferSmallImage: Boolean = false, |
| 177 | + tint: Color = Color.Unspecified, |
| 178 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 179 | + ambientTint: Color = tint, |
| 180 | + ambientTintBlendMode: BlendMode = tintBlendMode |
| 181 | +): State<Drawable?> |
| 182 | + |
| 183 | +@Composable |
| 184 | +inline fun ShortTextComplicationData.rememberDrawableAsState( |
| 185 | + preferSmallImage: Boolean = false, |
| 186 | + tint: Color = Color.Unspecified, |
| 187 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 188 | + ambientTint: Color = tint, |
| 189 | + ambientTintBlendMode: BlendMode = tintBlendMode |
| 190 | +): State<Drawable?> |
| 191 | + |
| 192 | +@Composable |
| 193 | +inline fun SmallImageComplicationData.rememberDrawableAsState( |
| 194 | + tint: Color = Color.Unspecified, |
| 195 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 196 | + ambientTint: Color = tint, |
| 197 | + ambientTintBlendMode: BlendMode = tintBlendMode |
| 198 | +): State<Drawable?> |
| 199 | + |
| 200 | +@RequiresApi(33) |
| 201 | +@Composable |
| 202 | +inline fun GoalProgressComplicationData.rememberDrawableAsState( |
| 203 | + preferSmallImage: Boolean = false, |
| 204 | + tint: Color = Color.Unspecified, |
| 205 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 206 | + ambientTint: Color = tint, |
| 207 | + ambientTintBlendMode: BlendMode = tintBlendMode |
| 208 | +): State<Drawable?> |
| 209 | + |
| 210 | +@RequiresApi(33) |
| 211 | +@Composable |
| 212 | +inline fun WeightedElementsComplicationData.rememberDrawableAsState( |
| 213 | + preferSmallImage: Boolean = false, |
| 214 | + tint: Color = Color.Unspecified, |
| 215 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 216 | + ambientTint: Color = tint, |
| 217 | + ambientTintBlendMode: BlendMode = tintBlendMode |
| 218 | +): State<Drawable?> |
| 219 | +``` |
| 220 | + |
| 221 | +### Other complications related convenience extensions for advanced use cases |
| 222 | + |
| 223 | +```kotlin |
| 224 | +fun Drawable.setTintBlendMode(blendMode: BlendMode) // BlendMode from Compose UI graphics |
| 225 | +fun Drawable.setTint(color: Color) // Color from Compose UI graphics |
| 226 | + |
| 227 | +@Composable |
| 228 | +fun SmallImage.rememberDrawableAsState( |
| 229 | + tint: Color = Color.Unspecified, |
| 230 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 231 | + ambientTint: Color = tint, |
| 232 | + ambientTintBlendMode: BlendMode = tintBlendMode, |
| 233 | +): State<Drawable?> |
| 234 | + |
| 235 | +@Composable |
| 236 | +fun MonochromaticImage.rememberDrawableAsState( |
| 237 | + tint: Color = Color.Unspecified, |
| 238 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 239 | + ambientTint: Color = tint, |
| 240 | + ambientTintBlendMode: BlendMode = tintBlendMode, |
| 241 | +): State<Drawable?> |
| 242 | + |
| 243 | +@Composable |
| 244 | +fun rememberDrawableAsState( |
| 245 | + monochromaticImage: MonochromaticImage?, |
| 246 | + smallImage: SmallImage?, |
| 247 | + preferSmallImage: Boolean = false, |
| 248 | + tint: Color = Color.Unspecified, |
| 249 | + tintBlendMode: BlendMode = BlendMode.SrcIn, |
| 250 | + ambientTint: Color = tint, |
| 251 | + ambientTintBlendMode: BlendMode = tintBlendMode, |
| 252 | +): State<Drawable?> |
| 253 | +``` |
| 254 | + |
| 255 | +## License |
| 256 | + |
| 257 | +Compose O'Clock has a dual license: |
| 258 | + |
| 259 | +It's free to use in debug apps, and you are free to use the dependency in any project that is compatible with the Apache 2.0 license. |
| 260 | + |
| 261 | +However, releasing an app using Compose O'Clock requires a commercial license. |
| 262 | + |
| 263 | +If you want to release a Watch Face app for Wear OS that uses Compose O'Clock, please send an email to [composeoclock@splitties.org](mailto:composeoclock@splitties.org) |
| 264 | + |
| 265 | +## Why Compose O'Clock? |
| 266 | + |
| 267 | +Currently, there are 3 ways to make a Watch Face for Wear OS: |
| 268 | + |
| 269 | +1. `androidx.wear.watchface` |
| 270 | +2. Samsung Watch Face Studio (WFS) |
| 271 | +3. XML based Watch Face Format (WFF) |
| 272 | + |
| 273 | +`androidx.wear.watchface` gives a lot of flexibility, but the APIs require a lot of boilerplate, |
| 274 | +which can steer you away from finishing and refining your watch face idea. |
| 275 | +Also, you get no IDE preview. |
| 276 | + |
| 277 | +Compose O'Clock uses `androidx.wear.watchface` internally, to ensure the best compatibility. |
| 278 | +However, the developer/designer experience is much improved because the API is simplified, |
| 279 | +many optimizations require much fewer code, or no code, and you get super fast live-preview in |
| 280 | +Android Studio, or on a phone app. You get both WYSIWYG (What You See is What You Get), |
| 281 | +and the power of Kotlin code. |
| 282 | + |
| 283 | +Samsung Watch Face Studio (WFS) is a WYSIWYG editor. |
| 284 | +Features are limited, making it likely your Watch Face will look like yet another basic one. |
| 285 | +Samsung WFS outputs XML based Watch Face Format (WFF), so all WFF limitations apply to WFS. |
| 286 | + |
| 287 | +XML based Watch Face Format (WFF) has many limitations. Here's a non exhaustive list: |
| 288 | +- Works only on Wear OS 4: many non Samsung and non Google watches are still on Wear OS 2 or 3. 🚫 |
| 289 | +- Capped at 15fps: sensor based animations, and other animations never look smooth. 🐌 |
| 290 | +- No live-preview: you need to re-build and re-deploy each time you want to see your changes ⏳ |
| 291 | +- Settings are very limited (numbers and gradients are not supported, unless you hack deeply) |
| 292 | +- You can't control the order in which settings will appear 🤷🏻🤪 |
| 293 | +- No support for in-app purchases(!) 💸 |
| 294 | +- No usage statistics possible |
| 295 | +- Near-zero connection with your users |
| 296 | +- No interactivity apart from switching raster images, and launching complications 🔒 |
| 297 | +- Doesn't support all types of complications |
| 298 | +- No runtime logic except for basic arithmetic expressions |
| 299 | +- No support for shaders |
| 300 | +- No support for blend modes (except masking) |
| 301 | +- On phone editor is slow and hard to discover 🐌 |
| 302 | +- As of Wear OS 4, the WFF renderer is no better than what's possible with `androidx.wear.watchface` |
| 303 | +- Publishing on the Play Store is as long as any other app, if not more, despite reduced risks |
| 304 | + |
| 305 | +If, for some reason, you still want to try WFF, without the inconvenience of writing XML by hand, |
| 306 | +we made a Kotlin DSL based on kotlinx.html: https://github.com/Splitties/WffDsl |
| 307 | +We decided to not publish it because the dev UX and product potential isn't great at the moment. |
| 308 | + |
| 309 | +We are waiting for the Wear OS team at Google to make an on-device API, so executable watch faces, |
| 310 | +like ones made with Compose O'Clock, or `androidx.wear.watchface`, can update the WFF that |
| 311 | +will be used for ambient mode or energy saver mode (which will make sense once the WFF renderer is improved). |
0 commit comments