Skip to content
C.W.K.
Stream
Lesson 06 of 07 · published

The Complication Is a Button, Not a Dashboard

~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)
            }
        }
    }
}

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.