# WidgetKit iOS 17+:App Intentsによるインタラクティブウィジェット > WidgetKitとApp IntentsでインタラクティブなiOSウィジェットを作成する完全ガイド。2026年のiOS 17+向けボタン、トグル、アニメーション、ベストプラクティス。 - Published: 2026-03-04 - Updated: 2026-04-29 - Author: SharpSkill - Tags: widgetkit, ios, app-intents, swift, widgets - Reading time: 14 min --- iOS 17は、ネイティブなインタラクティブ性を導入することでWidgetKitに革命をもたらしました。ウィジェットはもはや静的な表示ではありません:アプリを開かずに、ホーム画面から直接ユーザーのアクションに応答できるようになりました。この大きな進化はApp Intentsフレームワークに依存しており、滑らかでモダンなユーザー体験を提供します。 > **この記事の内容** > > この記事では、プロジェクト設定からアニメーションと状態管理を伴う高度なパターンまで、iOS 17+インタラクティブウィジェットの完全な作成を紹介します。 ## インタラクティブウィジェットのアーキテクチャ iOS 17+ウィジェットのインタラクティブ性は、App Intentsフレームワークを通じて機能します。アプリを開く従来のディープリンクとは異なり、App Intentsはウィジェットから直接コードを実行し、その後新しいデータで表示を自動的に更新します。 ```swift // InteractiveWidgetArchitecture.swift import WidgetKit import SwiftUI import AppIntents // アーキテクチャは3つの主要コンポーネントに基づいています: // 1. Widget Timeline Provider - データを提供 // 2. Widget View - Button/Toggleでインターフェースを表示 // 3. App Intent - タップ時にアクションを実行 struct TaskWidget: Widget { // ウィジェットの一意の識別子 let kind: String = "TaskWidget" var body: some WidgetConfiguration { // パラメータなしウィジェット用のStaticConfiguration StaticConfiguration( kind: kind, provider: TaskTimelineProvider() ) { entry in TaskWidgetView(entry: entry) // App Intentsには必須 .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("タスク") .description("ホーム画面からタスクを管理します。") .supportedFamilies([.systemSmall, .systemMedium]) } } ``` ウィジェットは設定を宣言し、データを提供するプロバイダーを指定します。`.containerBackground`属性は、iOS 17以降のインタラクティブウィジェットには必須です。 ## Timeline Providerの作成 Timeline Providerは、ウィジェットが更新されるタイミングと方法を決定します。インタラクティブウィジェットでは、App Intentsによって引き起こされる変更にも対応する必要があります。 ```swift // TaskTimelineProvider.swift import WidgetKit import SwiftUI // 特定の瞬間のウィジェットの状態を表すEntry struct TaskEntry: TimelineEntry { let date: Date let tasks: [Task] // 視覚的フィードバック用のロード状態 var isLoading: Bool = false } // アプリとウィジェットで共有されるデータモデル struct Task: Identifiable, Codable { let id: UUID var title: String var isCompleted: Bool var priority: Priority enum Priority: String, Codable { case low, medium, high } } struct TaskTimelineProvider: TimelineProvider { // 初期ロード中に表示されるプレースホルダー func placeholder(in context: Context) -> TaskEntry { TaskEntry( date: Date(), tasks: [ Task(id: UUID(), title: "サンプルタスク", isCompleted: false, priority: .medium) ] ) } // ウィジェットギャラリー用のスナップショット func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) { let entry = TaskEntry( date: Date(), tasks: TaskDataManager.shared.fetchTasks().prefix(3).map { $0 } ) completion(entry) } // リフレッシュポリシー付きの完全なTimeline func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let tasks = TaskDataManager.shared.fetchTasks() let entry = TaskEntry(date: Date(), tasks: Array(tasks.prefix(3))) // 15分後またはユーザーアクション後にリフレッシュ let nextUpdate = Calendar.current.date( byAdding: .minute, value: 15, to: Date() ) ?? Date() let timeline = Timeline( entries: [entry], policy: .after(nextUpdate) ) completion(timeline) } } ``` プロバイダーは共有された`TaskDataManager`を使用してデータにアクセスします。このアプローチにより、メインアプリケーションとウィジェット間の同期が保証されます。 > **App Group必須** > > アプリとウィジェット間でデータを共有するには、プロジェクトのcapabilitiesでApp Groupを設定する必要があります。UserDefaultsまたはファイルはこの共有グループを使用する必要があります。 ## 共有データマネージャー アプリケーションとウィジェット間でのデータ共有には、App Group経由でアクセス可能な共通コンテナが必要です。 ```swift // TaskDataManager.swift import Foundation final class TaskDataManager { // グローバルアクセス用Singleton static let shared = TaskDataManager() // Xcodeで設定されたApp Group識別子 private let appGroupID = "group.com.example.taskapp" // アプリとウィジェット間で共有されるUserDefaults private var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupID) } private let tasksKey = "tasks" private init() {} // 共有ストレージからタスクを取得 func fetchTasks() -> [Task] { guard let data = sharedDefaults?.data(forKey: tasksKey), let tasks = try? JSONDecoder().decode([Task].self, from: data) else { return [] } return tasks } // ウィジェット通知付きで保存 func saveTasks(_ tasks: [Task]) { guard let data = try? JSONEncoder().encode(tasks) else { return } sharedDefaults?.set(data, forKey: tasksKey) } // 特定のタスクを更新 func updateTask(_ task: Task) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == task.id }) { tasks[index] = task saveTasks(tasks) } } // 完了状態を切り替え func toggleTaskCompletion(taskID: UUID) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == taskID }) { tasks[index].isCompleted.toggle() saveTasks(tasks) } } } ``` このマネージャーはすべての永続化ロジックをカプセル化し、アプリケーションとウィジェットのApp Intents両方で使用されます。 ## インタラクティブ用App Intentの作成 App Intentは、ユーザーがウィジェットと対話する際に実行されるアクションを定義します。iOSはこのアクションをバックグラウンドで実行し、その後ウィジェットを自動的に更新します。 ```swift // ToggleTaskIntent.swift import AppIntents import WidgetKit // タスクの状態を切り替えるIntent struct ToggleTaskIntent: AppIntent { // Siriショートカットに表示されるタイトル static var title: LocalizedStringResource = "タスクの状態を切り替え" // アクセシビリティ用の説明 static var description = IntentDescription("タスクを完了または未完了としてマークします。") // パラメータ:変更するタスクのID @Parameter(title: "タスクID") var taskID: String // AppIntentに必要なinitializer init() {} // viewからの作成用のパラメータ付きinitializer init(taskID: UUID) { self.taskID = taskID.uuidString } // アクションの実行 func perform() async throws -> some IntentResult { // 文字列IDをUUIDに変換 guard let uuid = UUID(uuidString: taskID) else { return .result() } // タスクの更新 TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) // ウィジェット更新の要求 WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result() } } ``` `WidgetCenter.shared.reloadTimelines`の呼び出しは、アクション後にウィジェットを即座に更新し、即時の視覚的フィードバックを保証します。 ## インタラクティブボタン付きウィジェットView ウィジェットViewは、intentをアクションとする標準のSwiftUI `Button`コンポーネントを使用します。iOS 17+はこれらのインタラクションを自動的にインターセプトしてApp Intentを実行します。 ```swift // TaskWidgetView.swift import SwiftUI import WidgetKit struct TaskWidgetView: View { let entry: TaskEntry // ウィジェットサイズへの適応 @Environment(\.widgetFamily) var family var body: some View { VStack(alignment: .leading, spacing: 8) { // タイトルとカウンター付きヘッダー headerView // インタラクティブボタン付きタスクリスト ForEach(entry.tasks.prefix(tasksLimit)) { task in TaskRowView(task: task) } Spacer(minLength: 0) } .padding() } // サイズに応じたタスク数 private var tasksLimit: Int { switch family { case .systemSmall: return 2 case .systemMedium: return 3 default: return 4 } } private var headerView: some View { HStack { Text("タスク") .font(.headline) .fontWeight(.bold) Spacer() // 残りタスク数のバッジ let remaining = entry.tasks.filter { !$0.isCompleted }.count Text("\(remaining)") .font(.caption.bold()) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(remaining > 0 ? Color.orange : Color.green) .clipShape(Capsule()) } } } struct TaskRowView: View { let task: Task var body: some View { // App Intentをアクションとするボタン Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // 完了インジケーター Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // タスクタイトル Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) .lineLimit(1) Spacer() // 優先度インジケーター priorityIndicator } .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color(.systemBackground).opacity(0.5)) .cornerRadius(8) } .buttonStyle(.plain) } @ViewBuilder private var priorityIndicator: some View { switch task.priority { case .high: Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.red) case .medium: Image(systemName: "minus.circle.fill") .foregroundStyle(.orange) case .low: EmptyView() } } } ``` `Button(intent:)`構文は、ボタンをApp Intentに直接接続します。タップ時、iOSは`perform()`を実行し、その後ウィジェットを自動的に更新します。 ## ウィジェット用インタラクティブToggle オン/オフ型のアクションには、`Toggle`コンポーネントがネイティブiOSスタイルでボタンの代替を提供します。 ```swift // ToggleWidgetView.swift import SwiftUI import AppIntents // 明示的な状態を持つToggle専用のIntent struct SetTaskCompletionIntent: AppIntent { static var title: LocalizedStringResource = "タスク状態の設定" @Parameter(title: "タスクID") var taskID: String // 目標状態:true = 完了、false = 未完了 @Parameter(title: "完了") var isCompleted: Bool init() {} init(taskID: UUID, isCompleted: Bool) { self.taskID = taskID.uuidString self.isCompleted = isCompleted } func perform() async throws -> some IntentResult { guard let uuid = UUID(uuidString: taskID) else { return .result() } var tasks = TaskDataManager.shared.fetchTasks() if let index = tasks.firstIndex(where: { $0.id == uuid }) { // 状態を明示的に設定(toggleではない) tasks[index].isCompleted = isCompleted TaskDataManager.shared.saveTasks(tasks) } WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result() } } struct TaskToggleRowView: View { let task: Task var body: some View { HStack { Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) Spacer() // intent付きインタラクティブtoggle Toggle( isOn: task.isCompleted, intent: SetTaskCompletionIntent( taskID: task.id, isCompleted: !task.isCompleted ) ) .toggleStyle(.switch) .labelsHidden() } .padding(.vertical, 4) } } ``` Toggleはバイナリ状態に対してより直感的なインタラクションを提供し、iOSデザインに自然に統合されます。 > **インタラクティブウィジェットの制限** > > ウィジェットはアラート、シート、ナビゲーションを表示できません。すべてのアクションは自己完結型で、可視状態を直接更新する必要があります。 ## 更新アニメーションとトランジション iOS 17+では、アクション後のウィジェット更新中にトランジションをアニメーション化できます。`.contentTransition`修飾子がこれらのアニメーションを制御します。 ```swift // AnimatedTaskWidgetView.swift import SwiftUI import WidgetKit struct AnimatedTaskRowView: View { let task: Task var body: some View { Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // トランジションアニメーション付きアイコン Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // 変更時のアイコンアニメーション .contentTransition(.symbolEffect(.replace)) Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) // テキストアニメーション .contentTransition(.opacity) Spacer() } .padding(.vertical, 6) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 8) .fill(task.isCompleted ? Color.green.opacity(0.1) : Color.clear) ) // 背景アニメーション .animation(.easeInOut(duration: 0.3), value: task.isCompleted) } .buttonStyle(.plain) } } // アニメーション無効化付きウィジェット struct AnimatedTaskWidget: Widget { let kind: String = "AnimatedTaskWidget" var body: some WidgetConfiguration { StaticConfiguration( kind: kind, provider: TaskTimelineProvider() ) { entry in AnimatedTaskWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("アニメーション付きタスク") .description("滑らかなアニメーション付きウィジェット。") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) // コンテンツアニメーションの有効化 .contentMarginsDisabled() } } struct AnimatedTaskWidgetView: View { let entry: TaskEntry var body: some View { VStack(alignment: .leading, spacing: 8) { headerView ForEach(entry.tasks) { task in AnimatedTaskRowView(task: task) } Spacer(minLength: 0) } .padding() } private var headerView: some View { HStack { Text("タスク") .font(.headline.bold()) Spacer() let completed = entry.tasks.filter(\.isCompleted).count let total = entry.tasks.count // アニメーション付き進捗 Text("\(completed)/\(total)") .font(.caption.bold()) .foregroundStyle(.secondary) .contentTransition(.numericText()) } } } ``` `.symbolEffect(.replace)`と`.numericText()`アニメーションは、状態間の滑らかなトランジションを作成し、ユーザー体験を大幅に向上させます。 ## AppIntentConfigurationによる設定可能なウィジェット ユーザーがカスタマイズ可能なウィジェット(フィルター、カテゴリー)では、AppIntentConfigurationがStaticConfigurationを置き換えます。 ```swift // ConfigurableTaskWidget.swift import WidgetKit import SwiftUI import AppIntents // ユーザーに公開される設定 struct TaskWidgetConfigurationIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "タスク設定" static var description = IntentDescription("タスクの表示をカスタマイズします。") // 優先度によるフィルター @Parameter(title: "優先度", default: .all) var priorityFilter: PriorityFilter // 完了タスクを表示 @Parameter(title: "完了を表示", default: true) var showCompleted: Bool // タスクの最大数 @Parameter(title: "タスク数", default: 3) var maxTasks: Int } // 優先度フィルター用Enum enum PriorityFilter: String, AppEnum { case all case high case medium case low static var typeDisplayRepresentation: TypeDisplayRepresentation = "優先度" static var caseDisplayRepresentations: [PriorityFilter: DisplayRepresentation] = [ .all: "すべて", .high: "高", .medium: "中", .low: "低" ] } // 設定に適応したプロバイダー struct ConfigurableTaskProvider: AppIntentTimelineProvider { typealias Entry = TaskEntry typealias Intent = TaskWidgetConfigurationIntent func placeholder(in context: Context) -> TaskEntry { TaskEntry(date: Date(), tasks: []) } func snapshot(for configuration: TaskWidgetConfigurationIntent, in context: Context) async -> TaskEntry { let tasks = filteredTasks(for: configuration) return TaskEntry(date: Date(), tasks: tasks) } func timeline(for configuration: TaskWidgetConfigurationIntent, in context: Context) async -> Timeline { let tasks = filteredTasks(for: configuration) let entry = TaskEntry(date: Date(), tasks: tasks) let nextUpdate = Date().addingTimeInterval(15 * 60) return Timeline(entries: [entry], policy: .after(nextUpdate)) } // 設定フィルターを適用 private func filteredTasks(for config: TaskWidgetConfigurationIntent) -> [Task] { var tasks = TaskDataManager.shared.fetchTasks() // 優先度によるフィルタリング if config.priorityFilter != .all { let priority = Task.Priority(rawValue: config.priorityFilter.rawValue) ?? .medium tasks = tasks.filter { $0.priority == priority } } // 必要に応じて完了をフィルタリング if !config.showCompleted { tasks = tasks.filter { !$0.isCompleted } } // 数の制限 return Array(tasks.prefix(config.maxTasks)) } } // ユーザー設定付きウィジェット struct ConfigurableTaskWidget: Widget { let kind: String = "ConfigurableTaskWidget" var body: some WidgetConfiguration { // 設定可能ウィジェット用AppIntentConfiguration AppIntentConfiguration( kind: kind, intent: TaskWidgetConfigurationIntent.self, provider: ConfigurableTaskProvider() ) { entry in TaskWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("カスタムタスク") .description("タスクをフィルタリングしてカスタマイズします。") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } ``` ユーザーは長押しでウィジェットを設定でき、アプリケーションに追加コードなしでパーソナライズされた体験を提供します。 ## エラー処理とロード状態 良いUXには、インタラクション中のエラーケースと中間状態の処理が必要です。 ```swift // TaskIntentWithFeedback.swift import AppIntents import WidgetKit struct ToggleTaskWithFeedbackIntent: AppIntent { static var title: LocalizedStringResource = "フィードバック付きタスク切り替え" @Parameter(title: "タスクID") var taskID: String init() {} init(taskID: UUID) { self.taskID = taskID.uuidString } func perform() async throws -> some IntentResult & ReturnsValue { guard let uuid = UUID(uuidString: taskID) else { // サイレント失敗を返す return .result(value: false) } // 非同期操作のシミュレーション(例:サーバー同期) do { try await Task.sleep(for: .milliseconds(100)) TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result(value: true) } catch { // エラー:ウィジェットを更新しない return .result(value: false) } } } // ロード状態付きView struct TaskRowWithLoadingView: View { let task: Task @State private var isLoading = false var body: some View { Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // 条件付きインジケーター Group { if isLoading { ProgressView() .scaleEffect(0.8) } else { Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .foregroundStyle(task.isCompleted ? .green : .secondary) } } .frame(width: 24, height: 24) Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) Spacer() } .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color(.systemBackground).opacity(0.5)) .cornerRadius(8) } .buttonStyle(.plain) .disabled(isLoading) .opacity(isLoading ? 0.6 : 1.0) } } ``` 即時の視覚的フィードバック(不透明度の低下、ロードインジケーター)は、ユーザーにそのアクションが登録されたことを知らせます。 ## ベストプラクティスと最適化 いくつかのパターンが、パフォーマンスと信頼性の高いインタラクティブウィジェットを保証します。 ```swift // WidgetBestPractices.swift import WidgetKit import SwiftUI // 1. 変更後は常にキャッシュを無効化 final class WidgetRefreshManager { static func refreshAllWidgets() { // すべてのアプリウィジェットを更新 WidgetCenter.shared.reloadAllTimelines() } static func refreshWidget(kind: String) { // 特定のウィジェットを更新 WidgetCenter.shared.reloadTimelines(ofKind: kind) } // データ変更後にアプリから呼び出す static func notifyDataChanged() { Task { @MainActor in refreshAllWidgets() } } } // 2. View複雑性を制限 struct OptimizedWidgetView: View { let entry: TaskEntry var body: some View { // GeometryReaderなしのシンプルなviewを優先 VStack(alignment: .leading, spacing: 8) { ForEach(entry.tasks.prefix(3)) { task in // 軽量コンポーネント minimalTaskRow(task) } } .padding() } // パフォーマンス用最小限view @ViewBuilder private func minimalTaskRow(_ task: Task) -> some View { Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack { Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") Text(task.title) .lineLimit(1) } } .buttonStyle(.plain) } } // 3. シンプルな状態には@AppStorageを使用 struct QuickSettingsWidgetView: View { // 共有UserDefaultsへの直接アクセス @AppStorage("showCompletedTasks", store: UserDefaults(suiteName: "group.com.example.app")) private var showCompleted = true var body: some View { // 状態は更新間で持続する Text(showCompleted ? "すべて表示中" : "完了を非表示") } } // 4. プロバイダーでデータを事前計算 struct OptimizedTaskEntry: TimelineEntry { let date: Date let tasks: [Task] // 事前計算されたデータ let completedCount: Int let pendingCount: Int let highPriorityCount: Int init(date: Date, tasks: [Task]) { self.date = date self.tasks = tasks // 計算は1回だけ実行される self.completedCount = tasks.filter(\.isCompleted).count self.pendingCount = tasks.filter { !$0.isCompleted }.count self.highPriorityCount = tasks.filter { $0.priority == .high && !$0.isCompleted }.count } } ``` これらの最適化により、過度にバッテリーを消費しないレスポンシブなウィジェットが保証されます。 > **ウィジェットのデバッグ** > > デバッグにはXcodeのウィジェットスキームを使用してください。キャンバスプレビューにより、デバイスにインストールせずにさまざまなサイズと状態をテストできます。 ## 結論 App Intents付きWidgetKit iOS 17+は、ウィジェットをiOSアプリケーションの真のインタラクティブな拡張に変えます。この宣言的アーキテクチャは、ネイティブで滑らかなユーザー体験を提供しながら、開発を大幅に簡素化します。 ### iOS 17+インタラクティブウィジェットチェックリスト - ✅ データ共有用にApp Groupを設定 - ✅ 適切な更新を備えたTimeline Providerを作成 - ✅ 各アクション用のApp Intentsを実装 - ✅ インタラクティブ用に`Button(intent:)`または`Toggle(intent:)`を使用 - ✅ 変更後に`WidgetCenter.shared.reloadTimelines`を呼び出す - ✅ iOS 17+用に必須の`.containerBackground`を追加 - ✅ 滑らかなトランジションアニメーションを実装 - ✅ ロード状態とエラー状態を処理 - ✅ バッテリーパフォーマンス用にviewを最適化 - ✅ サポートされているすべてのウィジェットサイズでテスト --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/ios/widgetkit-ios17-interactive-widgets-app-intents