# WidgetKit iOS 17+: Interaktywne Widgety z App Intents > Kompletny przewodnik tworzenia interaktywnych widgetów iOS z WidgetKit i App Intents. Przyciski, przełączniki, animacje i najlepsze praktyki dla iOS 17+ w 2026 roku. - Published: 2026-03-04 - Updated: 2026-04-29 - Author: SharpSkill - Tags: widgetkit, ios, app-intents, swift, widgets - Reading time: 14 min --- iOS 17 zrewolucjonował WidgetKit, wprowadzając natywną interaktywność. Widgety nie są już statycznymi wyświetleniami: teraz mogą reagować na działania użytkownika bezpośrednio z ekranu głównego, bez otwierania aplikacji. Ta ważna ewolucja opiera się na frameworku App Intents i zapewnia płynne, nowoczesne doświadczenie użytkownika. > **Co obejmuje ten artykuł** > > Artykuł przedstawia kompletne tworzenie interaktywnych widgetów iOS 17+, od konfiguracji projektu po zaawansowane wzorce z animacjami i zarządzaniem stanem. ## Architektura Interaktywnych Widgetów Interaktywność widgetów iOS 17+ działa poprzez framework App Intents. W przeciwieństwie do tradycyjnych deep linków, które otwierałyby aplikację, App Intents pozwalają wykonywać kod bezpośrednio z widgetu, a następnie automatycznie odświeżać wyświetlanie z nowymi danymi. ```swift // InteractiveWidgetArchitecture.swift import WidgetKit import SwiftUI import AppIntents // Architektura opiera się na trzech głównych komponentach: // 1. Widget Timeline Provider - dostarcza dane // 2. Widget View - wyświetla interfejs z Button/Toggle // 3. App Intent - wykonuje akcję po dotknięciu struct TaskWidget: Widget { // Unikalny identyfikator widgetu let kind: String = "TaskWidget" var body: some WidgetConfiguration { // StaticConfiguration dla widgetów bez parametrów StaticConfiguration( kind: kind, provider: TaskTimelineProvider() ) { entry in TaskWidgetView(entry: entry) // Wymagane dla App Intents .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Zadania") .description("Zarządzaj swoimi zadaniami z ekranu głównego.") .supportedFamilies([.systemSmall, .systemMedium]) } } ``` Widget deklaruje swoją konfigurację i określa providera, który będzie dostarczać dane. Atrybut `.containerBackground` jest obowiązkowy od iOS 17 dla interaktywnych widgetów. ## Tworzenie Timeline Provider Timeline Provider określa, kiedy i jak widget jest odświeżany. Dla interaktywnych widgetów musi również reagować na zmiany powodowane przez App Intents. ```swift // TaskTimelineProvider.swift import WidgetKit import SwiftUI // Entry reprezentujący stan widgetu w danej chwili struct TaskEntry: TimelineEntry { let date: Date let tasks: [Task] // Stan ładowania dla wizualnej informacji zwrotnej var isLoading: Bool = false } // Model danych współdzielony między aplikacją a widgetem 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 wyświetlany podczas początkowego ładowania func placeholder(in context: Context) -> TaskEntry { TaskEntry( date: Date(), tasks: [ Task(id: UUID(), title: "Przykładowe zadanie", isCompleted: false, priority: .medium) ] ) } // Snapshot dla galerii widgetów func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) { let entry = TaskEntry( date: Date(), tasks: TaskDataManager.shared.fetchTasks().prefix(3).map { $0 } ) completion(entry) } // Pełna timeline z polityką odświeżania func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let tasks = TaskDataManager.shared.fetchTasks() let entry = TaskEntry(date: Date(), tasks: Array(tasks.prefix(3))) // Odświeżenie za 15 minut lub po akcji użytkownika let nextUpdate = Calendar.current.date( byAdding: .minute, value: 15, to: Date() ) ?? Date() let timeline = Timeline( entries: [entry], policy: .after(nextUpdate) ) completion(timeline) } } ``` Provider używa współdzielonego `TaskDataManager` do dostępu do danych. To podejście gwarantuje synchronizację między główną aplikacją a widgetem. > **App Group wymagane** > > Aby współdzielić dane między aplikacją a widgetem, trzeba skonfigurować App Group w capabilities projektu. UserDefaults lub pliki muszą używać tej współdzielonej grupy. ## Menedżer Współdzielonych Danych Współdzielenie danych między aplikacją a widgetem wymaga wspólnego kontenera dostępnego przez App Group. ```swift // TaskDataManager.swift import Foundation final class TaskDataManager { // Singleton dla globalnego dostępu static let shared = TaskDataManager() // Identyfikator App Group skonfigurowany w Xcode private let appGroupID = "group.com.example.taskapp" // UserDefaults współdzielony między aplikacją a widgetem private var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupID) } private let tasksKey = "tasks" private init() {} // Pobiera zadania ze współdzielonego magazynu func fetchTasks() -> [Task] { guard let data = sharedDefaults?.data(forKey: tasksKey), let tasks = try? JSONDecoder().decode([Task].self, from: data) else { return [] } return tasks } // Zapisuje z powiadomieniem widgetu func saveTasks(_ tasks: [Task]) { guard let data = try? JSONEncoder().encode(tasks) else { return } sharedDefaults?.set(data, forKey: tasksKey) } // Aktualizuje konkretne zadanie func updateTask(_ task: Task) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == task.id }) { tasks[index] = task saveTasks(tasks) } } // Przełącza stan ukończenia func toggleTaskCompletion(taskID: UUID) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == taskID }) { tasks[index].isCompleted.toggle() saveTasks(tasks) } } } ``` Menedżer ten enkapsuluje całą logikę persystencji i będzie używany zarówno przez aplikację, jak i App Intents widgetu. ## Tworzenie App Intent dla Interaktywności App Intent definiuje akcję wykonywaną gdy użytkownik wchodzi w interakcję z widgetem. iOS wykonuje tę akcję w tle, a następnie automatycznie odświeża widget. ```swift // ToggleTaskIntent.swift import AppIntents import WidgetKit // Intent do przełączania stanu zadania struct ToggleTaskIntent: AppIntent { // Tytuł wyświetlany w skrótach Siri static var title: LocalizedStringResource = "Przełącz stan zadania" // Opis dla dostępności static var description = IntentDescription("Oznacza zadanie jako ukończone lub nieukończone.") // Parametr: ID zadania do modyfikacji @Parameter(title: "ID zadania") var taskID: String // Wymagany inicjalizator dla AppIntent init() {} // Inicjalizator z parametrem do tworzenia z widoku init(taskID: UUID) { self.taskID = taskID.uuidString } // Wykonanie akcji func perform() async throws -> some IntentResult { // Konwersja ID string na UUID guard let uuid = UUID(uuidString: taskID) else { return .result() } // Aktualizacja zadania TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) // Żąda odświeżenia widgetu WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result() } } ``` Wywołanie `WidgetCenter.shared.reloadTimelines` powoduje natychmiastowe odświeżenie widgetu po akcji, gwarantując natychmiastową wizualną informację zwrotną. ## Widok Widgetu z Interaktywnymi Przyciskami Widok widgetu używa standardowego komponentu SwiftUI `Button` z intentem jako akcją. iOS 17+ automatycznie przechwytuje te interakcje, aby wykonać App Intent. ```swift // TaskWidgetView.swift import SwiftUI import WidgetKit struct TaskWidgetView: View { let entry: TaskEntry // Adaptacja do rozmiaru widgetu @Environment(\.widgetFamily) var family var body: some View { VStack(alignment: .leading, spacing: 8) { // Nagłówek z tytułem i licznikiem headerView // Lista zadań z interaktywnymi przyciskami ForEach(entry.tasks.prefix(tasksLimit)) { task in TaskRowView(task: task) } Spacer(minLength: 0) } .padding() } // Liczba zadań w zależności od rozmiaru private var tasksLimit: Int { switch family { case .systemSmall: return 2 case .systemMedium: return 3 default: return 4 } } private var headerView: some View { HStack { Text("Zadania") .font(.headline) .fontWeight(.bold) Spacer() // Plakietka z liczbą pozostałych zadań 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 { // Przycisk z App Intent jako akcją Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // Wskaźnik ukończenia Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // Tytuł zadania Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) .lineLimit(1) Spacer() // Wskaźnik priorytetu 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() } } } ``` Składnia `Button(intent:)` bezpośrednio łączy przycisk z App Intent. Po dotknięciu iOS wykonuje `perform()`, a następnie automatycznie odświeża widget. ## Interaktywny Toggle dla Widgetów Dla akcji typu włącz/wyłącz komponent `Toggle` oferuje alternatywę dla przycisku z natywnym stylem iOS. ```swift // ToggleWidgetView.swift import SwiftUI import AppIntents // Specyficzny intent dla Toggle z jawnym stanem struct SetTaskCompletionIntent: AppIntent { static var title: LocalizedStringResource = "Ustaw stan zadania" @Parameter(title: "ID zadania") var taskID: String // Stan docelowy: true = ukończone, false = nieukończone @Parameter(title: "Ukończone") 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 }) { // Ustawia stan jawnie (nie 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() // Interaktywny toggle z intentem Toggle( isOn: task.isCompleted, intent: SetTaskCompletionIntent( taskID: task.id, isCompleted: !task.isCompleted ) ) .toggleStyle(.switch) .labelsHidden() } .padding(.vertical, 4) } } ``` Toggle zapewnia bardziej intuicyjną interakcję dla stanów binarnych i naturalnie integruje się z designem iOS. > **Ograniczenia interaktywnych widgetów** > > Widgety nie mogą wyświetlać alertów, sheet ani nawigacji. Wszystkie akcje muszą być autonomiczne i bezpośrednio aktualizować widoczny stan. ## Animacje Odświeżania i Przejścia iOS 17+ pozwala animować przejścia podczas odświeżania widgetu po akcji. Modyfikator `.contentTransition` kontroluje te animacje. ```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) { // Ikona z animacją przejścia Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // Animacja ikony przy zmianie .contentTransition(.symbolEffect(.replace)) Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) // Animacja tekstu .contentTransition(.opacity) Spacer() } .padding(.vertical, 6) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 8) .fill(task.isCompleted ? Color.green.opacity(0.1) : Color.clear) ) // Animacja tła .animation(.easeInOut(duration: 0.3), value: task.isCompleted) } .buttonStyle(.plain) } } // Widget z animowaną inwalidacją 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("Animowane Zadania") .description("Widgety z płynnymi animacjami.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) // Aktywacja animacji zawartości .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("Zadania") .font(.headline.bold()) Spacer() let completed = entry.tasks.filter(\.isCompleted).count let total = entry.tasks.count // Animowany postęp Text("\(completed)/\(total)") .font(.caption.bold()) .foregroundStyle(.secondary) .contentTransition(.numericText()) } } } ``` Animacje `.symbolEffect(.replace)` i `.numericText()` tworzą płynne przejścia między stanami, znacząco poprawiając doświadczenie użytkownika. ## Konfigurowalny Widget z AppIntentConfiguration Dla widgetów konfigurowalnych przez użytkownika (filtry, kategorie) AppIntentConfiguration zastępuje StaticConfiguration. ```swift // ConfigurableTaskWidget.swift import WidgetKit import SwiftUI import AppIntents // Konfiguracja udostępniona użytkownikowi struct TaskWidgetConfigurationIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Konfiguracja zadań" static var description = IntentDescription("Dostosuj wyświetlanie zadań.") // Filtr według priorytetu @Parameter(title: "Priorytet", default: .all) var priorityFilter: PriorityFilter // Pokaż ukończone zadania @Parameter(title: "Pokaż ukończone", default: true) var showCompleted: Bool // Maksymalna liczba zadań @Parameter(title: "Liczba zadań", default: 3) var maxTasks: Int } // Enum dla filtra priorytetu enum PriorityFilter: String, AppEnum { case all case high case medium case low static var typeDisplayRepresentation: TypeDisplayRepresentation = "Priorytet" static var caseDisplayRepresentations: [PriorityFilter: DisplayRepresentation] = [ .all: "Wszystkie", .high: "Wysoki", .medium: "Średni", .low: "Niski" ] } // Provider dostosowany do konfiguracji 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)) } // Stosuje filtry konfiguracyjne private func filteredTasks(for config: TaskWidgetConfigurationIntent) -> [Task] { var tasks = TaskDataManager.shared.fetchTasks() // Filtrowanie według priorytetu if config.priorityFilter != .all { let priority = Task.Priority(rawValue: config.priorityFilter.rawValue) ?? .medium tasks = tasks.filter { $0.priority == priority } } // Filtrowanie ukończonych w razie potrzeby if !config.showCompleted { tasks = tasks.filter { !$0.isCompleted } } // Ograniczenie liczby return Array(tasks.prefix(config.maxTasks)) } } // Widget z konfiguracją użytkownika struct ConfigurableTaskWidget: Widget { let kind: String = "ConfigurableTaskWidget" var body: some WidgetConfiguration { // AppIntentConfiguration dla konfigurowalnych widgetów AppIntentConfiguration( kind: kind, intent: TaskWidgetConfigurationIntent.self, provider: ConfigurableTaskProvider() ) { entry in TaskWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Spersonalizowane Zadania") .description("Filtruj i personalizuj swoje zadania.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } ``` Użytkownik może teraz konfigurować widget poprzez długie naciśnięcie, otrzymując spersonalizowane doświadczenie bez dodatkowego kodu w aplikacji. ## Obsługa Błędów i Stanów Ładowania Dobre UX wymaga obsługi przypadków błędów i stanów pośrednich podczas interakcji. ```swift // TaskIntentWithFeedback.swift import AppIntents import WidgetKit struct ToggleTaskWithFeedbackIntent: AppIntent { static var title: LocalizedStringResource = "Przełącz zadanie z feedbackiem" @Parameter(title: "ID zadania") 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 { // Zwraca cichy błąd return .result(value: false) } // Symuluje operację asynchroniczną (np. sync z serwerem) do { try await Task.sleep(for: .milliseconds(100)) TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result(value: true) } catch { // Błąd: nie aktualizuj widgetu return .result(value: false) } } } // Widok ze stanem ładowania struct TaskRowWithLoadingView: View { let task: Task @State private var isLoading = false var body: some View { Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // Wskaźnik warunkowy 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) } } ``` Natychmiastowa wizualna informacja zwrotna (zmniejszona przezroczystość, wskaźnik ładowania) informuje użytkownika, że jego akcja została zarejestrowana. ## Najlepsze Praktyki i Optymalizacje Kilka wzorców gwarantuje wydajne i niezawodne interaktywne widgety. ```swift // WidgetBestPractices.swift import WidgetKit import SwiftUI // 1. Zawsze unieważniaj cache po modyfikacji final class WidgetRefreshManager { static func refreshAllWidgets() { // Odświeżenie wszystkich widgetów aplikacji WidgetCenter.shared.reloadAllTimelines() } static func refreshWidget(kind: String) { // Odświeżenie konkretnego widgetu WidgetCenter.shared.reloadTimelines(ofKind: kind) } // Wywołanie z aplikacji po modyfikacji danych static func notifyDataChanged() { Task { @MainActor in refreshAllWidgets() } } } // 2. Ogranicz złożoność widoków struct OptimizedWidgetView: View { let entry: TaskEntry var body: some View { // Preferuj proste widoki bez GeometryReader VStack(alignment: .leading, spacing: 8) { ForEach(entry.tasks.prefix(3)) { task in // Lekkie komponenty minimalTaskRow(task) } } .padding() } // Minimalny widok dla wydajności @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. Używaj @AppStorage dla prostych stanów struct QuickSettingsWidgetView: View { // Bezpośredni dostęp do współdzielonych UserDefaults @AppStorage("showCompletedTasks", store: UserDefaults(suiteName: "group.com.example.app")) private var showCompleted = true var body: some View { // Stan utrzymuje się między odświeżeniami Text(showCompleted ? "Pokazuję wszystkie" : "Ukrywam ukończone") } } // 4. Wstępnie obliczaj dane w providerze struct OptimizedTaskEntry: TimelineEntry { let date: Date let tasks: [Task] // Wstępnie obliczone dane let completedCount: Int let pendingCount: Int let highPriorityCount: Int init(date: Date, tasks: [Task]) { self.date = date self.tasks = tasks // Obliczenia wykonywane raz self.completedCount = tasks.filter(\.isCompleted).count self.pendingCount = tasks.filter { !$0.isCompleted }.count self.highPriorityCount = tasks.filter { $0.priority == .high && !$0.isCompleted }.count } } ``` Te optymalizacje gwarantują responsywne widgety, które nie zużywają nadmiernie baterii. > **Debugowanie widgetów** > > Używaj schematu widget w Xcode do debugowania. Podgląd canvas pozwala testować różne rozmiary i stany bez instalacji na urządzeniu. ## Podsumowanie WidgetKit iOS 17+ z App Intents przekształca widgety w prawdziwe interaktywne rozszerzenia aplikacji iOS. Ta deklaratywna architektura znacząco upraszcza rozwój, oferując natywne i płynne doświadczenie użytkownika. ### Lista Kontrolna Interaktywnego Widgetu iOS 17+ - ✅ Skonfiguruj App Group dla współdzielenia danych - ✅ Stwórz Timeline Provider z odpowiednim odświeżaniem - ✅ Zaimplementuj App Intents dla każdej akcji - ✅ Używaj `Button(intent:)` lub `Toggle(intent:)` dla interaktywności - ✅ Wywołuj `WidgetCenter.shared.reloadTimelines` po modyfikacji - ✅ Dodaj obowiązkowy `.containerBackground` dla iOS 17+ - ✅ Zaimplementuj płynne animacje przejścia - ✅ Obsłuż stany ładowania i błędów - ✅ Optymalizuj widoki pod kątem wydajności baterii - ✅ Testuj na wszystkich obsługiwanych rozmiarach widgetu --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/pl/blog/ios/widgetkit-ios17-interactive-widgets-app-intents