Apple Watch の文字盤に自分のアプリの数字を一つ載せる。これだけのことが、Rork Max で本体アプリを出した後に意外と長い宿題として残りがちです。私自身、個人開発で配信している壁紙系・習慣記録系のアプリで「本体は動くのに、手首の上には何も出ていない」状態をしばらく放置していました。コンプリケーションは表示面積こそ小さいものの、ユーザーが一日に何十回も視線を落とす場所です。そこに居場所を作れるかどうかは、継続率にそのまま効いてきます。
ここからは、Rork Max が生成した SwiftUI アプリへ後付けで watchOS のコンプリケーションを実装し、watchOS の Smart Stack に拾わせるところまでを順番に進めます。Rork に任せられる部分と、自分の手で書く必要がある部分の線引きを、実際のコードと一緒に整理していきます。
watchOS 9 以降、コンプリケーションは WidgetKit に統一された
最初に押さえておきたいのは、いまのコンプリケーションが ClockKit ではなく WidgetKit で書くものになっている点です。watchOS 9 で ClockKit のコンプリケーション API は非推奨になり、現在は iPhone 側のホーム画面ウィジェットとまったく同じ Widget プロトコルで記述します。つまり、iOS のウィジェットを一度でも書いたことがあれば、知識の大半はそのまま流用できます。
コンプリケーションのレイアウトは、ウィジェットファミリーのうち accessory 系の4種類で表現します。
ファミリー 主な表示位置 使いどころ
accessoryCircular 円形コンプリケーション枠 進捗リングや1つの数値
accessoryRectangular 横長の枠・Smart Stack タイトル+値の2〜3行
accessoryInline 文字盤上部の1行 短いテキストとSF Symbol
accessoryCorner 文字盤の四隅 ゲージ付きの小さな数値
Rork Max は本体の SwiftUI アプリを生成してくれますが、watchOS 向けのウィジェット拡張ターゲットまでは自動では作りません。ここは自分で Xcode を開いて足す作業になります。Rork Max は Xcode 不要をうたっていますが、それは「本体アプリをそのまま公開する」場合の話で、ウィジェット拡張のような別ターゲットを追加するときは、エクスポートしたプロジェクトを一度 Xcode で開く必要があります。私はこの切り替えのタイミングを「Rork に任せる範囲の終わり」と捉えるようにしています。
ターゲット構成とデータ共有の土台を作る
ウィジェット拡張は、本体アプリとは別のプロセスで動きます。そのため、本体アプリが持っているデータをそのまま参照することはできません。両者をつなぐのが App Group の共有コンテナです。
まず Xcode で File > New > Target から Widget Extension を追加します。このとき watchOS 用のターゲットとして作る点に注意してください。続いて、本体アプリとウィジェット拡張の両方の Signing & Capabilities に同じ App Group(例: group.net.dolice.myapp)を追加します。
共有コンテナへの書き込みは、本体アプリ側で行います。Rork Max が生成したデータ層に、次のような薄いブリッジを一枚かませる形が扱いやすいです。
import Foundation
import WidgetKit
enum SharedStore {
static let appGroup = "group.net.dolice.myapp"
private static var defaults: UserDefaults ? {
UserDefaults ( suiteName : appGroup)
}
// 本体アプリから呼ぶ。値が変わったらウィジェットの再読み込みも依頼する
static func saveStreak ( _ days: Int ) {
defaults ? . set (days, forKey : "currentStreak" )
WidgetCenter.shared. reloadTimelines ( ofKind : "StreakComplication" )
}
static func loadStreak () -> Int {
defaults ? . integer ( forKey : "currentStreak" ) ?? 0
}
}
ここでのポイントは WidgetCenter.shared.reloadTimelines(ofKind:) です。共有コンテナに書き込んだだけでは、コンプリケーションの表示は更新されません。本体アプリが値を更新したタイミングで、明示的に再読み込みを依頼する必要があります。Rork Max の生成コードは画面遷移やステート管理は丁寧に作ってくれますが、このプロセスをまたいだ更新の依頼までは面倒を見てくれません。ここは手で足す前提でいた方が、後で「文字盤の数字が古いままだ」と悩まずに済みます。
4つのファミリーを1つのウィジェットで賄う
コンプリケーション本体は、TimelineProvider・Entry・View の3点セットで構成します。最初に Entry とプロバイダを書きます。
import WidgetKit
import SwiftUI
struct StreakEntry : TimelineEntry {
let date: Date
let streak: Int
// Smart Stack 用の重要度。数値が高いほど前面に出やすい
let relevance: TimelineEntryRelevance ?
}
struct StreakProvider : TimelineProvider {
func placeholder ( in context: Context) -> StreakEntry {
StreakEntry ( date : Date (), streak : 0 , relevance : nil )
}
func getSnapshot ( in context: Context, completion : @escaping (StreakEntry) -> Void ) {
completion ( StreakEntry ( date : Date (), streak : SharedStore. loadStreak (), relevance : nil ))
}
func getTimeline ( in context: Context, completion : @escaping (Timeline<StreakEntry>) -> Void ) {
let streak = SharedStore. loadStreak ()
// 連続記録が伸びているほど Smart Stack で前に出す
let score: Float = streak >= 7 ? 80 : (streak >= 3 ? 40 : 10 )
let entry = StreakEntry (
date : Date (),
streak : streak,
relevance : TimelineEntryRelevance ( score : score)
)
// 次の更新は1時間後。watchOS の更新予算を尊重する
let next = Calendar.current. date ( byAdding : .hour, value : 1 , to : Date ()) !
completion ( Timeline ( entries : [entry], policy : . after (next)))
}
}
ビュー側では、@Environment(\.widgetFamily) で現在のファミリーを受け取り、1つの View 内で出し分けます。ファミリーごとに別ファイルを作る必要はありません。
struct StreakComplicationView : View {
@Environment (\.widgetFamily) private var family
let entry: StreakEntry
var body: some View {
switch family {
case .accessoryCircular :
Gauge ( value : Double ( min (entry.streak, 30 )), in : 0 ... 30 ) {
Image ( systemName : "flame.fill" )
} currentValueLabel : {
Text ( " \( entry. streak ) " )
}
. gaugeStyle (.accessoryCircular)
case .accessoryRectangular :
VStack ( alignment : .leading) {
Label ( "連続記録" , systemImage : "flame.fill" )
. font (.headline)
Text ( " \( entry. streak ) 日継続中" )
. font (.caption)
}
case .accessoryInline :
Label ( " \( entry. streak ) 日" , systemImage : "flame.fill" )
default:
Text ( " \( entry. streak ) " )
}
}
}
最後に、これらを束ねる Widget を宣言し、対応ファミリーを明示します。
@main
struct StreakComplication : Widget {
var body: some WidgetConfiguration {
StaticConfiguration ( kind : "StreakComplication" , provider : StreakProvider ()) { entry in
StreakComplicationView ( entry : entry)
. containerBackground (.fill.tertiary, for : .widget)
}
. configurationDisplayName ( "連続記録" )
. description ( "習慣の連続日数を文字盤に表示します。" )
. supportedFamilies ([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}
containerBackground は watchOS 10 以降で必須です。これを付けていないと、ビルドは通るのに文字盤上で背景が描画されず、コンプリケーションが透明な箱のように見えてしまいます。私は最初これを忘れていて、シミュレータでは気づかず実機で初めて気づいた経験があります。コンプリケーション周りは、シミュレータと実機の見え方の差が大きい領域なので、実機確認を省かない方が安全です。
Smart Stack に拾わせるための重要度設計
watchOS の Smart Stack は、文字盤を上にスワイプすると現れる縦積みのウィジェット群です。ここに自分のコンプリケーションを自動で浮かび上がらせるには、accessoryRectangular ファミリーへの対応と、TimelineEntryRelevance の設計が鍵になります。
Smart Stack は、各ウィジェットが申告した relevance.score と、時間帯やユーザーの利用パターンを掛け合わせて表示順を決めます。先ほどのプロバイダでは、連続記録が7日以上なら 80、3日以上なら 40、それ未満なら 10 という段階を付けました。ここで全部を高スコアにしてしまうと、システムから見れば「常に重要だと主張するうるさいウィジェット」になり、かえって埋もれます。本当にユーザーの目に留めたい状態のときだけスコアを上げる。この抑制が効くかどうかで、Smart Stack での露出はかなり変わります。
relevance に加えて、TimelineEntry の date を未来の時刻にした複数エントリを返すと、「その時刻に重要度が高まる」という予約のような表現もできます。たとえば習慣アプリなら、ユーザーがいつも記録している夜の時間帯にスコアが上がるエントリを差し込んでおくと、ちょうどその頃に Smart Stack の上の方へ出やすくなります。
更新予算と現実的な運用
コンプリケーションでつまずきやすいのが更新頻度です。watchOS は省電力のため、ウィジェットの再読み込み回数に予算を設けています。1日あたりおおむね数十回が目安で、getTimeline で .atEnd や短すぎる .after を指定すると、すぐに予算を使い切って更新が止まります。
現実的には、次の3つの更新経路を組み合わせるのが安定します。
第一に、本体アプリが値を変えたときの WidgetCenter.shared.reloadTimelines(ofKind:) による即時更新。これがいちばん確実で、ユーザーの操作に直結します。第二に、Timeline の .after ポリシーによる定期更新。1時間に1回程度なら予算内に十分収まります。第三に、時刻依存の情報があるなら、未来の Entry を複数返してシステム側に描画を任せる方法。この3つを役割分担させると、予算を浪費せずに鮮度を保てます。
逆に避けたいのは、本体アプリのバックグラウンド処理から短い間隔で reload を叩き続けるパターンです。予算を食い尽くした結果、肝心の更新タイミングで反映されなくなります。私は初期に「念のため頻繁に更新」と考えて失敗しました。コンプリケーションは「変わったときだけ知らせる」設計の方が、結果的に新しい数字を見せられます。
つまずきを避けるための実装手順とおすすめ設定
ここまでの内容を、迷わず進められる順番に整理しておきます。
Xcode で watchOS の Widget Extension ターゲットを追加します。
本体アプリとウィジェット拡張の両方に同じ App Group を付与します。
SharedStore 経由で値を書き込み、変更時に reloadTimelines(ofKind:) を呼びます。
supportedFamilies に4ファミリーを宣言し、containerBackground を必ず付けます。
TimelineEntryRelevance を状態に応じて段階化します。
更新予算の決め方も具体的な数値でおすすめを示しておきます。.after ポリシーは1時間に1回(1日あたり約24回)に留めることをおすすめします。watchOS の予算は1日あたりおおむね数十回なので、即時更新の余地を残すには定期更新を24回前後に抑えるのが安全です。relevance.score は3段階(10 / 40 / 80)程度に絞り、80 を出すのは全体の2割以下の状態に限定することをおすすめします。常に高スコアを申告すると、かえって Smart Stack で埋もれます。
Rork Max とこの実装の責任分界点
ここまでをまとめると、Rork Max とのつき合い方が見えてきます。本体アプリの画面・データモデル・ロジックは Rork Max に生成させ、エクスポートする。watchOS のウィジェット拡張ターゲットの追加、App Group の設定、WidgetCenter を介したプロセス間の更新依頼、Smart Stack の重要度設計は、自分の手で足す。この線引きを最初から意識しておくと、「ノーコードで全部やろうとして詰まる」事態を避けられます。
コンプリケーションは小さな実装ですが、ユーザーが一日に何度も触れる接点を作る作業です。本体を出した後の余力でここまで届かせられると、アプリの存在感は手首の上で静かに、けれど確実に変わっていきます。同じように個人で開発を続けている方の参考になれば幸いです。