WidgetKit iOS 17+:App Intentsによるインタラクティブウィジェット
WidgetKitとApp IntentsでインタラクティブなiOSウィジェットを作成する完全ガイド。2026年のiOS 17+向けボタン、トグル、アニメーション、ベストプラクティス。

iOS 17は、ネイティブなインタラクティブ性を導入することでWidgetKitに革命をもたらしました。ウィジェットはもはや静的な表示ではありません:アプリを開かずに、ホーム画面から直接ユーザーのアクションに応答できるようになりました。この大きな進化はApp Intentsフレームワークに依存しており、滑らかでモダンなユーザー体験を提供します。
この記事では、プロジェクト設定からアニメーションと状態管理を伴う高度なパターンまで、iOS 17+インタラクティブウィジェットの完全な作成を紹介します。
インタラクティブウィジェットのアーキテクチャ
iOS 17+ウィジェットのインタラクティブ性は、App Intentsフレームワークを通じて機能します。アプリを開く従来のディープリンクとは異なり、App Intentsはウィジェットから直接コードを実行し、その後新しいデータで表示を自動的に更新します。
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によって引き起こされる変更にも対応する必要があります。
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<TaskEntry>) -> 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を使用してデータにアクセスします。このアプローチにより、メインアプリケーションとウィジェット間の同期が保証されます。
アプリとウィジェット間でデータを共有するには、プロジェクトのcapabilitiesでApp Groupを設定する必要があります。UserDefaultsまたはファイルはこの共有グループを使用する必要があります。
共有データマネージャー
アプリケーションとウィジェット間でのデータ共有には、App Group経由でアクセス可能な共通コンテナが必要です。
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はこのアクションをバックグラウンドで実行し、その後ウィジェットを自動的に更新します。
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の呼び出しは、アクション後にウィジェットを即座に更新し、即時の視覚的フィードバックを保証します。
iOSの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
インタラクティブボタン付きウィジェットView
ウィジェットViewは、intentをアクションとする標準のSwiftUI Buttonコンポーネントを使用します。iOS 17+はこれらのインタラクションを自動的にインターセプトしてApp Intentを実行します。
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スタイルでボタンの代替を提供します。
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修飾子がこれらのアニメーションを制御します。
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を置き換えます。
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<TaskEntry> {
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には、インタラクション中のエラーケースと中間状態の処理が必要です。
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<Bool> {
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)
}
}即時の視覚的フィードバック(不透明度の低下、ロードインジケーター)は、ユーザーにそのアクションが登録されたことを知らせます。
ベストプラクティスと最適化
いくつかのパターンが、パフォーマンスと信頼性の高いインタラクティブウィジェットを保証します。
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を最適化
- ✅ サポートされているすべてのウィジェットサイズでテスト
今すぐ練習を始めましょう!
面接シミュレーターと技術テストで知識をテストしましょう。
タグ
共有
関連記事

App IntentsとSiri Shortcuts: iOS 2026の高度な自動化
iOS 18+向けApp IntentsとSiri Shortcutsの完全ガイドです。カスタムSiriアクションの構築、Apple Intelligenceの統合、2026年のSwiftアプリ自動化を解説します。

Swift における Combine vs async/await:段階的な移行パターン
Swift で Combine から async/await への移行を進めるための完全ガイド:段階的な戦略、ブリッジングパターン、iOS コードベースにおけるパラダイムの共存。

2026年のiOSアクセシビリティ面接質問: VoiceOverとDynamic Type
iOS面接に向け、VoiceOver、Dynamic Type、セマンティックtraits、監査などアクセシビリティの重要質問を解説します。