ウィジェットとLive Activities、App IntentsによるMeteoHealthの拡張#
「良いアプリ」といえば長らく「アプリの中の良い画面」を意味していました。開いて、見て、タップして、閉じる。ところがiOS 16からiOS 18にかけて、この定義はもう十分ではなくなりました。ユーザーはアプリを開かないまま、ホーム画面から、Dynamic Islandから、Siriへの声がけから、あるいはコントロールセンターでのワンタップから、アプリのロジックに触れる機会が増え続けています。
MeteoHealth——天気と睡眠、心拍、そして実際の体調を結びつけるアプリ——を作る中で、ユーザーが最も頻繁に行う操作(「コップ一杯の水を記録する」「回復予測を確認する」「トレーニングの進行状況を見る」)は、アプリをフルに起動する価値がないことがはっきりしました。そうしてプロダクトに加わったのが、インタラクティブウィジェット、Dynamic IslandのLive Activities、App Intentsを基盤にしたSiriコマンド、そして文字盤コンプリケーション付きのApple Watchアプリです。この記事では、それらをコードとアーキテクチャのレベルでどう組み立てたか、そしてどの判断が実際に役立ったかを紹介します。
なぜこれは単なる「チェックボックス機能」ではないのか#
3年前、ウィジェットはタイマーで15〜30分ごとに更新される静止画にすぎませんでした。iOS 17からは、App Intentsフレームワークを通じて、アプリを開かずにその場でコードを実行するボタンやトグルがウィジェットに追加されました。iOS 18はさらにその上に3つ目のチャンネルを追加しました——コントロールセンター、ロック画面、アクションボタンに配置できる「コントロール」です。
MeteoHealthのようなアプリにとって、これは飾りではなく、「Xをやろうと思った」と「Xが完了した」の間にある摩擦を取り除く手段です。水分を記録する、24時間先の体調リスク予測をちらっと見る、トレーニング中の心拍を確認する——これらはすべて、アプリを起動しないほうが速いのです。
インタラクティブウィジェット: ホーム画面に直接置くAppIntent#
インタラクティブウィジェットの核心的な考え方は、ウィジェット内のボタンやトグルがアプリを開くのではなく、AppIntentプロトコルに準拠した構造体を直接実行する、というものです。バックグラウンドでプロセスを起動するか、共有データコンテナを再利用するかはシステムが判断します。コードの立場からは、何が起こるべきかを記述するだけで済みます。
import AppIntents
import WidgetKit
struct LogWaterIntent: AppIntent {
static var title: LocalizedStringResource = "Log a Glass of Water"
static var description = IntentDescription("Adds 250 ml to today's water intake")
@Parameter(title: "Amount (ml)", default: 250)
var amountML: Int
func perform() async throws -> some IntentResult {
try await HydrationStore.shared.addWater(milliliters: amountML)
WidgetCenter.shared.reloadTimelines(ofKind: "HydrationWidget")
return .result()
}
}この同じインテントを、ウィジェットのSwiftUIレイアウトに直接組み込んだものが次のコードです。デリゲートもopenURLも中間画面も必要ありません。
struct HydrationWidgetView: View {
var entry: HydrationEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Water today")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(entry.totalML) ml")
.font(.title2.bold())
Button(intent: LogWaterIntent(amountML: 250)) {
Label("+250 ml", systemImage: "drop.fill")
}
.buttonStyle(.borderedProminent)
.tint(.cyan)
}
.padding()
}
}最初の実装でつまずいたポイントがあります。アプリとウィジェットのストレージが同期していない場合(MeteoHealthは独立したデータベースではなく、App GroupとCore Dataを使っています)、ボタンをタップした後もウィジェットに表示される状態がアプリより遅れてしまいます。perform()内で明示的にWidgetCenter.shared.reloadTimelinesを呼び出すのはオプションではありません。これがなければ、次に予定されたタイムライン更新まで、ウィジェットのUIはデータが変わったことを知る術がないのです。
Live ActivitiesとDynamic Island: 常に見えているトレーニング#
Live Activitiesが解決するのは別の課題です。「素早いアクション」ではなく、「時間が限られたイベントについて、継続的に更新される状態」を扱います。MeteoHealthではそれがトレーニングです。ランニングや筋トレの最中、ロック画面とDynamic Islandは、電話をロック解除しなくても、リアルタイムの心拍数、経過時間、消費カロリーを表示し続けます。
まずはActivityAttributesで状態を記述するところから始まります。
import ActivityKit
struct WorkoutAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var heartRate: Int
var elapsedSeconds: Int
var caloriesBurned: Int
}
var workoutType: String
}次はメインアプリからアクティビティを開始する処理です。通常、ユーザーがApple Watchやアプリ自体でトレーニングを開始した瞬間に呼び出されます。
func startWorkoutActivity(type: String) {
let attributes = WorkoutAttributes(workoutType: type)
let initialState = WorkoutAttributes.ContentState(
heartRate: 0,
elapsedSeconds: 0,
caloriesBurned: 0
)
do {
let activity = try Activity<WorkoutAttributes>.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil),
pushType: .token
)
print("Started Live Activity: \(activity.id)")
} catch {
print("Failed to start Live Activity: \(error)")
}
}そして最後に、Dynamic Island本体のレイアウトです。コンパクト、ミニマル、展開の3つの表示をそれぞれ定義します。
struct WorkoutLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutAttributes.self) { context in
WorkoutLockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label("\(context.state.heartRate)", systemImage: "heart.fill")
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.elapsedSeconds.formattedDuration)
}
DynamicIslandExpandedRegion(.bottom) {
Text("\(context.state.caloriesBurned) kcal")
}
} compactLeading: {
Image(systemName: "heart.fill")
} compactTrailing: {
Text("\(context.state.heartRate)")
} minimal: {
Image(systemName: "heart.fill")
}
}
}
}実務上の注意点として、ContentStateを1秒間に何十回も更新するのは良い考えではありません。Live Activitiesは更新頻度を制限しており、あまりに頻繁な呼び出しはシステム側で間引かれるか、まとめられてしまいます。MeteoHealthでは、アプリまたはWatchコンパニオンがアクティブな間、Dynamic Island内の心拍数を数秒おきにローカルで更新しています。これで「データが生きている」感覚を保ちながら、システムに過度な負荷をかけずに済みます。
App IntentsとSiri:「Hey Siri、水を一杯記録して」#
ウィジェットのボタンを支えているのと同じAppIntentプロトコルは、音声コマンド、ショートカット、Spotlightの提案の基盤にもなります。違いはAppShortcutsProviderというラッパーで、これによってどの自然言語のフレーズがインテントを呼び出すべきかをシステムに伝えます。
struct LogWaterGlassIntent: AppIntent {
static var title: LocalizedStringResource = "Log a Glass of Water"
static var openAppWhenRun = false
func perform() async throws -> some IntentResult & ProvidesDialog {
try await HydrationStore.shared.addWater(milliliters: 250)
return .result(dialog: "Logged a glass of water")
}
}
struct MeteoHealthShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: LogWaterGlassIntent(),
phrases: [
"Log a glass of water in \(.applicationName)",
"Add water in \(.applicationName)"
],
shortTitle: "Log Water",
systemImageName: "drop.fill"
)
}
}ここでopenAppWhenRun = falseというフラグが重要です。これがないと、Siriはまずアプリを開いてから初めてアクションを実行します——まさに私たちが取り除こうとしている余分なステップです。falseにすることで、コマンド全体がその場で完結します。ユーザーがフレーズを言うと、ProvidesDialogを通じて確認の返答が聞こえ、アプリは画面に一切現れません。
iOS 18のコントロール: コントロールセンターでの素早いアクション#
iOS 18で3つ目のチャンネルが登場しました。コントロールセンター、ロック画面、そしてアクションボタンに割り当てられる「コントロール」です。技術的にはWidgetKitの上にもう一層重ねたものですが、ControlWidgetButtonやトグルといった異なる表示テンプレートを使い、瞬時の応答に対する期待値もずっと厳しくなっています。
struct HydrationControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.meteohealth.hydration-control"
) {
ControlWidgetButton(action: LogWaterIntent(amountML: 250)) {
Label("Log Water", systemImage: "drop.fill")
}
}
.displayName("Log Water")
.description("Quickly add a glass of water from Control Center")
}
}ウィジェットと同じLogWaterIntentを再利用している点に注目してください。これは偶然ではなく、意図的な選択です。一つのAppIntentを、ビジネスロジックを重複させることなく、ウィジェット・Siriコマンド・コントロールという3つの表面すべてで再利用できます。App Intentsが「Siri向けの機能」から、アプリ全体のアクションを束ねる単一のレイヤーへと変わるのは、まさにこの仕組みによってです。
Watchアプリとコンプリケーション: 手首でも同じインテントの言語を使う#
MeteoHealthには、電話画面のミラーではない、フル機能のApple Watchアプリがあります。さまざまな文字盤向けのコンプリケーションがあり、現在の体調リスク予測や心拍数を表示します。watchOSのコンプリケーションは、iPhoneのウィジェットと同じWidgetKit+タイムラインプロバイダーの組み合わせを使っているため、タイムラインのコードの大部分は一度書けば両プラットフォームで動作し、文字盤のフォームファクターに合わせたレイアウトの違いだけがわずかにあります。
手首からのすばやい記録——トレーニング直後に症状や水分を記録する操作——も、iPhoneで使っているのと同じAppIntentを通じて行われます。SwiftUIのコードはプラットフォームごとに異なりますが、ビジネスロジックとインテントの定義は共有されています。これによりバグの発生源が大きく減りました。HydrationStore.shared.addWaterが一度正しく動作すれば、それが呼び出されるすべての場所で正しく動作するのです。
ウィジェット、Live Activity、コントロール: どれを選ぶべきか#
この3つの仕組みはそれぞれ異なるシナリオをカバーしており、それらを混同することが、機能がユーザーに定着しない典型的な原因になります。
| 基準 | ウィジェット(WidgetKit) | Live Activity(ActivityKit) | コントロール(iOS 18 Controls) |
|---|---|---|---|
| 使うべき場面 | ゆっくり変化する持続的な要約 | 時間が限られたアクティブなイベント(トレーニング、タイマー、配達) | ワンタップで済む単一の素早いアクション |
| 表示場所 | ホーム画面、ロック画面、StandBy | ロック画面、Dynamic Island | コントロールセンター、ロック画面、アクションボタン |
| 表示期間 | 数時間〜数日、スケジュールに沿って更新 | 限定的——長時間更新がないとアクティビティは消える | 常時表示、状態はオンデマンドで取得 |
| データ更新 | タイムラインプロバイダー + WidgetCenter.reloadTimelines | ActivityKit経由のプッシュ、またはローカルのContentState更新 | タップ時に実行されるAppIntent |
| インタラクティブ性 | AppIntentによるボタン・トグル(iOS 17+) | 主に表示、インタラクションは最小限 | 完全——ボタンとトグルそのものが目的 |
私が使っている経験則はこうです。データを「見る」必要があるならウィジェット、「進行中のプロセスを追いかける」必要があるならLive Activity、「一つのことをできるだけ速くやる」必要があるならコントロール、というものです。
このプロジェクトから得たもの#
MeteoHealthから得た最大の教訓は、この3つの仕組みが意味を持つのは、共有された既存のビジネスロジック層の上に成り立っている場合だけだ、ということです。私はウィジェット用に「水を記録する」実装を、Siri用に別の実装を、コントロール用にさらに別の実装を書いたわけではありません。すべてが同じAppIntentを使い、ストレージのロジックはすべて共有のApp Groupに存在し、アプリ・ウィジェット・Watchコンプリケーションのいずれからもアクセスできます。
第二の教訓は状態の同期です。WidgetKitとActivityKitは、変更を自動的には検知しません。WidgetCenter.shared.reloadTimelinesの呼び出しとContentStateの更新は明示的に行う必要があり、まさにここから「タップしたのに画面が何も変わらない」という類のバグが最も頻繁に生まれます。
そして第三の教訓は、アプリを取り巻くエコシステムは、ユーザーのアクションが本当に短い場合にしか機能しない、ということです。コップ一杯の水、予測をちらっと見る、トレーニングを開始する——perform()の中のロジックが複雑なUIや複数のステップを必要とし始めた瞬間、それは機能をウィジェットや音声コマンドに移すのではなく、アプリの中に留めておくべきというサインです。



