~14 min · on-the-wrist, complications, widgetkit, watch-face, deep-links
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Make it a face element and use it like a button. That would be good."
The Fastest Door Is on the Face
The owner wanted the capture app reachable from the watch face itself, without finding it in the app list first. On current watchOS a complication is a WidgetKit widget in an extension inside the watch app, rendered in the accessory families the faces offer: circular, corner, rectangular and inline. The instinct is to make it informative as well, showing how many captures are waiting. The family decided against that, for a reason that holds for every face element.
Why It Shows No Count
A complication's timeline refreshes on watchOS's budget, not when you capture. A number rendered there is stale minutes later, and the watch face is the one surface people trust at a glance, which makes a stale number there worse than no number at all. A widget that keeps requesting refreshes it does not need is also one the system eventually stops refreshing. So the complication is a pure button: a static configuration, one entry, a timeline with policy .never because nothing about it ever changes, and a widgetURL that opens the watch app on its one door. Live state stays in the app, where it is read fresh.
Each Family Has Its Own Shape
The four accessory families are not one view at four sizes. The corner family wants .widgetLabel for its curved text; the circular family wants AccessoryWidgetBackground() behind the symbol so it matches the face; inline gets one line of text with a symbol; and everything needs containerBackground(.clear, for: .widget), or the face draws a plate the design did not ask for. Rendering each family for its own shape is most of the work, and it is visible only on a real face.
The Tap Lands on the Same Door
A tap on a complication opens the watch app, so the URL scheme is declared in the watch app's Info.plist and handled there with onOpenURL; the phone never receives it. The handler does exactly what the in-app button does, so both entrances share one path. When the tap launches the app cold, the dictation door may be unavailable for a moment until a view is on screen, which is why the request is kept until it succeeds rather than tried once. The next lesson uses the same request for the Action Button. The complication extension also has its own bundle identifier under the watch app's and is checked by the upload gate, which requires it to be present in the archive.
Code
A watch complication that is only a button, shaped for each accessory family·swift
import SwiftUI
import WidgetKit
/// A button on the watch face. It shows no count: a complication refreshes on watchOS's budget,
/// not when you capture, and a stale number on the face is worse than none.
struct ButtonEntry: TimelineEntry {
let date: Date
}
struct ButtonProvider: TimelineProvider {
func placeholder(in context: Context) -> ButtonEntry { ButtonEntry(date: .now) }
func getSnapshot(in context: Context, completion: @escaping (ButtonEntry) -> Void) {
completion(ButtonEntry(date: .now))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<ButtonEntry>) -> Void) {
completion(Timeline(entries: [ButtonEntry(date: .now)], policy: .never)) // nothing ever changes
}
}
struct SparkButtonView: View {
@Environment(\.widgetFamily) private var family
var body: some View {
Group {
switch family {
case .accessoryCircular:
ZStack {
AccessoryWidgetBackground()
Image(systemName: "mic.fill").font(.title3)
}
case .accessoryCorner:
Image(systemName: "mic.fill")
.font(.title3)
.widgetLabel("Spark") // the corner's curved text
case .accessoryInline:
Label("Speak to Spark", systemImage: "mic.fill")
default:
Label("Speak to Spark", systemImage: "mic.fill").font(.headline)
}
}
.containerBackground(.clear, for: .widget) // or the face draws a plate behind it
.widgetURL(URL(string: "spark://speak")) // opens the WATCH app on the one door
}
}
struct SparkComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "com.example.spark.complication.speak", provider: ButtonProvider()) { _ in
SparkButtonView()
}
.configurationDisplayName("Speak to Spark")
.description("Opens dictation.")
.supportedFamilies([.accessoryCircular, .accessoryCorner, .accessoryRectangular, .accessoryInline])
}
}
@main
struct SparkWatchWidgets: WidgetBundle { // the extension's entry point
var body: some Widget { SparkComplication() }
}
The watch app: the face's tap and the in-app button open one door·swift
import SwiftUI
/// The face's tap and the in-app button land on the same door.
struct WatchRootView: View {
@Environment(\.scenePhase) private var phase
@State private var speakRequested = false
@State private var lastWords: String?
var body: some View {
VStack(spacing: 8) {
Button("Speak", systemImage: "mic.fill") { request() }
if let lastWords { Text(lastWords).font(.footnote).lineLimit(2) }
}
.onOpenURL { url in
if url.scheme == "spark", url.host() == "speak" { request() }
}
.onAppear { attempt() }
.onChange(of: phase) { _, now in
if now == .active { attempt() }
}
}
private func request() {
speakRequested = true
attempt()
}
/// The dictation door from the previous lesson returns false while no controller is visible,
/// which a cold tap from the face can hit. Keep the request and try again shortly.
private func attempt(retriesLeft: Int = 10) {
guard speakRequested else { return }
if WristDictation.speak({ words in lastWords = words }) {
speakRequested = false
} else if retriesLeft > 0 {
Task {
try? await Task.sleep(for: .milliseconds(300))
attempt(retriesLeft: retriesLeft - 1)
}
}
}
}
Add SparkComplication to Spark's watch widget extension and put it on a real watch face in each family the face supports. Photograph or describe how circular and corner look with and without AccessoryWidgetBackground and .widgetLabel. Then declare the spark scheme in the watch app's Info.plist, handle it in WatchRootView, and confirm a tap from the face opens dictation both when the app is running and when it is not.
Hint
Faces in the watch Simulator can be edited too, which is enough to check layout, but the cold-launch path and dictation itself need a real watch. If a tap opens the app but not dictation, the request was dropped instead of kept for the next appearance.
Progress
Progress is local-only — sign in to sync across devices.