# 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 // 아키텍처는 세 가지 주요 구성 요소를 기반으로 합니다: // 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 { // 초기 로딩 중 표시되는 placeholder 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 // 계산은 한 번만 수행됩니다 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/ko/blog/ios/widgetkit-ios17-interactive-widgets-app-intents