본문 바로가기
C.W.K.
Stream
Lesson 06 of 07 · published

컴플리케이션은 계기판이 아니라 버튼이야

~14 min · on-the-wrist, complications, widgetkit, watch-face, deep-links

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"화면 구성 요소로 만들어서 버튼처럼 쓰면 좋을 텐데."

가장 빠른 문은 시계 화면에 있어

주인은 앱 목록을 뒤지지 않고 시계 화면에서 바로 담기 앱을 열고 싶어 했어. 지금 watchOS에서 컴플리케이션은 워치 앱 안의 확장에 들어 있는 WidgetKit 위젯이고, 시계 화면이 제공하는 액세서리 종류에 맞춰 그려져. 원형, 모서리, 사각형, 한 줄이 있어. 그러면 본능적으로 정보도 같이 보여주고 싶어져. 기다리는 담기가 몇 개인지 같은 거 말이야. 가족 앱은 그러지 않기로 했어. 그 이유는 시계 화면에 올리는 요소라면 어디에나 똑같이 통해.

왜 개수를 안 보여주냐면

컴플리케이션 타임라인은 담을 때가 아니라 watchOS 예산에 맞춰 갱신돼. 거기 그린 숫자는 몇 분만 지나도 낡은 값이 돼. 시계 화면은 사람들이 한눈에 보고 믿는 유일한 화면이라서, 낡은 숫자가 떠 있느니 아예 없는 게 나아. 게다가 필요 없는 갱신을 자꾸 요청하는 위젯은 결국 시스템이 갱신을 끊어버리기도 해. 그래서 컴플리케이션은 순수한 버튼이야. 구성은 이래. 정적 설정, 항목 하나, 바뀌는 게 전혀 없으니 정책이 .never인 타임라인, 그리고 워치 앱을 그 하나뿐인 문으로 여는 widgetURL. 실시간 상태는 언제든 새로 읽을 수 있는 앱 안에 둬.

종류마다 모양이 따로야

액세서리 종류 넷은 뷰 하나를 크기만 네 가지로 그린 게 아니야. 모서리 종류는 휘어지는 글자를 .widgetLabel로 달아야 하고, 원형 종류는 시계 화면과 어울리도록 기호 뒤에 AccessoryWidgetBackground()를 깔아야 해. 한 줄 종류엔 기호와 글자 한 줄이 들어가. 그리고 넷 다 containerBackground(.clear, for: .widget)가 필요해. 이게 없으면 시계 화면이 디자인에 없던 바탕판을 뒤에 그려버리거든. 일의 대부분은 종류마다 제 모양을 잡아주는 건데, 그 결과는 진짜 시계 화면에서만 제대로 보여.

탭은 같은 문으로 이어져

컴플리케이션을 누르면 워치 앱이 열려. 그래서 URL 스킴은 워치 앱 Info.plist에 선언하고 거기서 onOpenURL로 받아. 폰은 그걸 절대 안 받아. 처리기는 앱 안 버튼과 똑같은 일을 하니까, 두 입구가 한 길을 같이 써. 꺼져 있던 앱이 탭으로 켜지면, 뷰가 화면에 뜰 때까지 잠깐 받아쓰기 문을 못 쓸 수 있어. 그래서 요청은 한 번 시도하고 버리지 말고, 성공할 때까지 쥐고 있어. 다음 레슨에선 같은 요청을 동작 버튼에 연결해. 컴플리케이션 확장은 워치 앱 식별자 아래에 자기 번들 식별자를 따로 갖고, 아카이브에 그 확장이 실제로 들어 있는지는 업로드 관문이 확인해.

Code

버튼일 뿐인 워치 컴플리케이션, 액세서리 종류마다 제 모양으로·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() }
}
워치 앱: 시계 화면의 탭과 앱 안 버튼이 문 하나를 열어·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

Spark 워치 위젯 확장에 SparkComplication을 넣고, 진짜 시계 화면에서 지원하는 종류마다 하나씩 올려봐. 원형과 모서리가 AccessoryWidgetBackground.widgetLabel이 있을 때와 없을 때 어떻게 보이는지 찍거나 적어. 다음으로 워치 앱 Info.plist에 spark 스킴을 선언하고 WatchRootView에서 처리해. 앱이 돌고 있을 때와 꺼져 있을 때 모두, 시계 화면에서 누르면 받아쓰기가 열리는지 확인해.
Hint
워치 시뮬레이터에서도 시계 화면을 편집할 수 있으니 배치 확인엔 충분해. 하지만 꺼진 상태에서 켜지는 경로와 받아쓰기 자체는 진짜 워치에서 봐야 해. 눌렀을 때 앱은 열리는데 받아쓰기가 안 열리면, 요청을 뷰가 다시 뜰 때까지 쥐고 있지 않고 버린 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.