# WidgetKit iOS 17+: Interactieve Widgets met App Intents > Volledige gids voor het maken van interactieve iOS-widgets met WidgetKit en App Intents. Knoppen, toggles, animaties en best practices voor iOS 17+ in 2026. - Published: 2026-03-04 - Updated: 2026-04-29 - Author: SharpSkill - Tags: widgetkit, ios, app-intents, swift, widgets - Reading time: 14 min --- iOS 17 heeft WidgetKit gerevolutioneerd door native interactiviteit te introduceren. Widgets zijn niet langer statische weergaven: ze kunnen nu rechtstreeks vanaf het beginscherm reageren op gebruikersacties zonder de app te openen. Deze belangrijke evolutie steunt op het App Intents framework en biedt een vloeiende, moderne gebruikerservaring. > **Wat dit artikel behandelt** > > Dit artikel behandelt het volledige proces van het maken van interactieve iOS 17+ widgets, van projectconfiguratie tot geavanceerde patronen met animaties en statusbeheer. ## Architectuur van Interactieve Widgets De interactiviteit van iOS 17+ widgets werkt via het App Intents framework. In tegenstelling tot traditionele deeplinks die de app zouden openen, maken App Intents het mogelijk om code rechtstreeks vanuit de widget uit te voeren en daarna automatisch de weergave te vernieuwen met de nieuwe gegevens. ```swift // InteractiveWidgetArchitecture.swift import WidgetKit import SwiftUI import AppIntents // De architectuur is gebaseerd op drie hoofdcomponenten: // 1. Widget Timeline Provider - levert de gegevens // 2. Widget View - toont de interface met Button/Toggle // 3. App Intent - voert de actie uit bij aanraking struct TaskWidget: Widget { // Unieke widget-identificatie let kind: String = "TaskWidget" var body: some WidgetConfiguration { // StaticConfiguration voor widgets zonder parameters StaticConfiguration( kind: kind, provider: TaskTimelineProvider() ) { entry in TaskWidgetView(entry: entry) // Verplicht voor App Intents .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Taken") .description("Beheer uw taken vanaf het beginscherm.") .supportedFamilies([.systemSmall, .systemMedium]) } } ``` De widget declareert zijn configuratie en specificeert de provider die de gegevens zal leveren. Het `.containerBackground` attribuut is verplicht sinds iOS 17 voor interactieve widgets. ## Aanmaken van de Timeline Provider De Timeline Provider bepaalt wanneer en hoe de widget wordt vernieuwd. Voor interactieve widgets moet hij ook reageren op wijzigingen veroorzaakt door App Intents. ```swift // TaskTimelineProvider.swift import WidgetKit import SwiftUI // Entry die de widgetstatus op een bepaald moment vertegenwoordigt struct TaskEntry: TimelineEntry { let date: Date let tasks: [Task] // Laadstatus voor visuele feedback var isLoading: Bool = false } // Gegevensmodel gedeeld tussen app en widget 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 weergegeven tijdens initieel laden func placeholder(in context: Context) -> TaskEntry { TaskEntry( date: Date(), tasks: [ Task(id: UUID(), title: "Voorbeeldtaak", isCompleted: false, priority: .medium) ] ) } // Snapshot voor de widgetgalerij func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) { let entry = TaskEntry( date: Date(), tasks: TaskDataManager.shared.fetchTasks().prefix(3).map { $0 } ) completion(entry) } // Volledige timeline met vernieuwingsbeleid func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let tasks = TaskDataManager.shared.fetchTasks() let entry = TaskEntry(date: Date(), tasks: Array(tasks.prefix(3))) // Vernieuwing over 15 minuten of na een gebruikersactie let nextUpdate = Calendar.current.date( byAdding: .minute, value: 15, to: Date() ) ?? Date() let timeline = Timeline( entries: [entry], policy: .after(nextUpdate) ) completion(timeline) } } ``` De provider gebruikt een gedeelde `TaskDataManager` om toegang te krijgen tot de gegevens. Deze aanpak garandeert synchronisatie tussen de hoofdapplicatie en de widget. > **App Group verplicht** > > Om gegevens te delen tussen app en widget moet een App Group worden geconfigureerd in de project capabilities. UserDefaults of bestanden moeten deze gedeelde groep gebruiken. ## De Manager voor Gedeelde Gegevens Het delen van gegevens tussen de applicatie en de widget vereist een gemeenschappelijke container die toegankelijk is via App Group. ```swift // TaskDataManager.swift import Foundation final class TaskDataManager { // Singleton voor globale toegang static let shared = TaskDataManager() // App Group identifier geconfigureerd in Xcode private let appGroupID = "group.com.example.taskapp" // UserDefaults gedeeld tussen app en widget private var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupID) } private let tasksKey = "tasks" private init() {} // Haalt taken op uit gedeelde opslag func fetchTasks() -> [Task] { guard let data = sharedDefaults?.data(forKey: tasksKey), let tasks = try? JSONDecoder().decode([Task].self, from: data) else { return [] } return tasks } // Opslaan met widgetmelding func saveTasks(_ tasks: [Task]) { guard let data = try? JSONEncoder().encode(tasks) else { return } sharedDefaults?.set(data, forKey: tasksKey) } // Werkt een specifieke taak bij func updateTask(_ task: Task) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == task.id }) { tasks[index] = task saveTasks(tasks) } } // Wisselt voltooiingsstatus om func toggleTaskCompletion(taskID: UUID) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == taskID }) { tasks[index].isCompleted.toggle() saveTasks(tasks) } } } ``` Deze manager kapselt alle persistentielogica in en zal worden gebruikt door zowel de applicatie als de App Intents van de widget. ## Aanmaken van het App Intent voor Interactiviteit Het App Intent definieert de actie die wordt uitgevoerd wanneer de gebruiker met de widget interageert. iOS voert deze actie op de achtergrond uit en vernieuwt vervolgens automatisch de widget. ```swift // ToggleTaskIntent.swift import AppIntents import WidgetKit // Intent om de taakstatus om te wisselen struct ToggleTaskIntent: AppIntent { // Titel weergegeven in Siri-snelkoppelingen static var title: LocalizedStringResource = "Taakstatus omwisselen" // Beschrijving voor toegankelijkheid static var description = IntentDescription("Markeert een taak als voltooid of niet voltooid.") // Parameter: ID van de te wijzigen taak @Parameter(title: "Taak-ID") var taskID: String // Vereiste initializer voor AppIntent init() {} // Initializer met parameter voor aanmaak vanuit de view init(taskID: UUID) { self.taskID = taskID.uuidString } // Uitvoering van de actie func perform() async throws -> some IntentResult { // Conversie van string-ID naar UUID guard let uuid = UUID(uuidString: taskID) else { return .result() } // Update van de taak TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) // Vraagt widget-vernieuwing aan WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result() } } ``` De oproep naar `WidgetCenter.shared.reloadTimelines` veroorzaakt een onmiddellijke widget-vernieuwing na de actie en garandeert directe visuele feedback. ## Widget-View met Interactieve Knoppen De widget-view gebruikt het standaard SwiftUI `Button` component met de intent als actie. iOS 17+ onderschept deze interacties automatisch om het App Intent uit te voeren. ```swift // TaskWidgetView.swift import SwiftUI import WidgetKit struct TaskWidgetView: View { let entry: TaskEntry // Aanpassing aan widgetgrootte @Environment(\.widgetFamily) var family var body: some View { VStack(alignment: .leading, spacing: 8) { // Header met titel en teller headerView // Takenlijst met interactieve knoppen ForEach(entry.tasks.prefix(tasksLimit)) { task in TaskRowView(task: task) } Spacer(minLength: 0) } .padding() } // Aantal taken volgens grootte private var tasksLimit: Int { switch family { case .systemSmall: return 2 case .systemMedium: return 3 default: return 4 } } private var headerView: some View { HStack { Text("Taken") .font(.headline) .fontWeight(.bold) Spacer() // Badge met aantal resterende taken 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 { // Knop met App Intent als actie Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // Voltooiingsindicator Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // Taaktitel Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) .lineLimit(1) Spacer() // Prioriteitsindicator 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() } } } ``` De `Button(intent:)` syntax verbindt de knop rechtstreeks met het App Intent. Bij aanraking voert iOS `perform()` uit en vernieuwt vervolgens automatisch de widget. ## Interactieve Toggle voor Widgets Voor aan/uit-acties biedt het `Toggle` component een alternatief voor de knop met een native iOS-stijl. ```swift // ToggleWidgetView.swift import SwiftUI import AppIntents // Specifiek intent voor Toggle met expliciete status struct SetTaskCompletionIntent: AppIntent { static var title: LocalizedStringResource = "Taakstatus instellen" @Parameter(title: "Taak-ID") var taskID: String // Doelstatus: true = voltooid, false = niet voltooid @Parameter(title: "Voltooid") 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 }) { // Stelt status expliciet in (geen 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() // Interactieve toggle met intent Toggle( isOn: task.isCompleted, intent: SetTaskCompletionIntent( taskID: task.id, isCompleted: !task.isCompleted ) ) .toggleStyle(.switch) .labelsHidden() } .padding(.vertical, 4) } } ``` Toggle biedt een intuïtievere interactie voor binaire statussen en integreert natuurlijk in iOS-ontwerpen. > **Beperkingen van interactieve widgets** > > Widgets kunnen geen alerts, sheets of navigatie weergeven. Alle acties moeten autonoom zijn en de zichtbare status direct bijwerken. ## Vernieuwingsanimaties en Overgangen iOS 17+ maakt het mogelijk om overgangen te animeren tijdens widget-vernieuwing na een actie. De `.contentTransition` modifier regelt deze animaties. ```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) { // Icoon met overgangsanimatie Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // Icoonanimatie bij wijziging .contentTransition(.symbolEffect(.replace)) Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) // Tekstanimatie .contentTransition(.opacity) Spacer() } .padding(.vertical, 6) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 8) .fill(task.isCompleted ? Color.green.opacity(0.1) : Color.clear) ) // Achtergrondanimatie .animation(.easeInOut(duration: 0.3), value: task.isCompleted) } .buttonStyle(.plain) } } // Widget met geanimeerde invalidatie 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("Geanimeerde Taken") .description("Widgets met vloeiende animaties.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) // Activering van inhoudsanimaties .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("Taken") .font(.headline.bold()) Spacer() let completed = entry.tasks.filter(\.isCompleted).count let total = entry.tasks.count // Geanimeerde voortgang Text("\(completed)/\(total)") .font(.caption.bold()) .foregroundStyle(.secondary) .contentTransition(.numericText()) } } } ``` De animaties `.symbolEffect(.replace)` en `.numericText()` creëren vloeiende overgangen tussen statussen en verbeteren de gebruikerservaring aanzienlijk. ## Configureerbare Widget met AppIntentConfiguration Voor door gebruikers aanpasbare widgets (filters, categorieën) vervangt AppIntentConfiguration de StaticConfiguration. ```swift // ConfigurableTaskWidget.swift import WidgetKit import SwiftUI import AppIntents // Configuratie blootgesteld aan de gebruiker struct TaskWidgetConfigurationIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Taakconfiguratie" static var description = IntentDescription("Pas de taakweergave aan.") // Filter op prioriteit @Parameter(title: "Prioriteit", default: .all) var priorityFilter: PriorityFilter // Voltooide taken weergeven @Parameter(title: "Voltooide weergeven", default: true) var showCompleted: Bool // Maximaal aantal taken @Parameter(title: "Aantal taken", default: 3) var maxTasks: Int } // Enum voor prioriteitsfilter enum PriorityFilter: String, AppEnum { case all case high case medium case low static var typeDisplayRepresentation: TypeDisplayRepresentation = "Prioriteit" static var caseDisplayRepresentations: [PriorityFilter: DisplayRepresentation] = [ .all: "Alle", .high: "Hoog", .medium: "Gemiddeld", .low: "Laag" ] } // Provider aangepast aan de configuratie 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)) } // Past configuratiefilters toe private func filteredTasks(for config: TaskWidgetConfigurationIntent) -> [Task] { var tasks = TaskDataManager.shared.fetchTasks() // Filtering op prioriteit if config.priorityFilter != .all { let priority = Task.Priority(rawValue: config.priorityFilter.rawValue) ?? .medium tasks = tasks.filter { $0.priority == priority } } // Filtering van voltooide indien nodig if !config.showCompleted { tasks = tasks.filter { !$0.isCompleted } } // Beperking van het aantal return Array(tasks.prefix(config.maxTasks)) } } // Widget met gebruikersconfiguratie struct ConfigurableTaskWidget: Widget { let kind: String = "ConfigurableTaskWidget" var body: some WidgetConfiguration { // AppIntentConfiguration voor configureerbare widgets AppIntentConfiguration( kind: kind, intent: TaskWidgetConfigurationIntent.self, provider: ConfigurableTaskProvider() ) { entry in TaskWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Aangepaste Taken") .description("Filter en pas uw taken aan.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } ``` De gebruiker kan de widget nu configureren door lang in te drukken en zo een gepersonaliseerde ervaring krijgen zonder extra code in de applicatie. ## Foutafhandeling en Laadstatussen Een goede UX vereist het afhandelen van foutgevallen en tussentoestanden tijdens interacties. ```swift // TaskIntentWithFeedback.swift import AppIntents import WidgetKit struct ToggleTaskWithFeedbackIntent: AppIntent { static var title: LocalizedStringResource = "Taak omwisselen met feedback" @Parameter(title: "Taak-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 { // Stille fout retourneren return .result(value: false) } // Simuleert async operatie (bijv. server-sync) do { try await Task.sleep(for: .milliseconds(100)) TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result(value: true) } catch { // Fout: widget niet bijwerken return .result(value: false) } } } // View met laadstatus struct TaskRowWithLoadingView: View { let task: Task @State private var isLoading = false var body: some View { Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // Conditionele indicator 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) } } ``` Onmiddellijke visuele feedback (verminderde opaciteit, laadindicator) informeert de gebruiker dat zijn actie is geregistreerd. ## Best Practices en Optimalisaties Verschillende patronen garanderen prestatieve en betrouwbare interactieve widgets. ```swift // WidgetBestPractices.swift import WidgetKit import SwiftUI // 1. Cache altijd ongeldig maken na wijziging final class WidgetRefreshManager { static func refreshAllWidgets() { // Vernieuwing van alle app-widgets WidgetCenter.shared.reloadAllTimelines() } static func refreshWidget(kind: String) { // Vernieuwing van een specifieke widget WidgetCenter.shared.reloadTimelines(ofKind: kind) } // Oproep vanuit de app na gegevenswijziging static func notifyDataChanged() { Task { @MainActor in refreshAllWidgets() } } } // 2. Beperk view-complexiteit struct OptimizedWidgetView: View { let entry: TaskEntry var body: some View { // Eenvoudige views zonder GeometryReader prefereren VStack(alignment: .leading, spacing: 8) { ForEach(entry.tasks.prefix(3)) { task in // Lichtgewicht componenten minimalTaskRow(task) } } .padding() } // Minimale view voor prestaties @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. Gebruik @AppStorage voor eenvoudige statussen struct QuickSettingsWidgetView: View { // Directe toegang tot gedeelde UserDefaults @AppStorage("showCompletedTasks", store: UserDefaults(suiteName: "group.com.example.app")) private var showCompleted = true var body: some View { // Status blijft behouden tussen vernieuwingen Text(showCompleted ? "Alle weergeven" : "Voltooide verbergen") } } // 4. Pre-bereken gegevens in de provider struct OptimizedTaskEntry: TimelineEntry { let date: Date let tasks: [Task] // Voorberekende gegevens let completedCount: Int let pendingCount: Int let highPriorityCount: Int init(date: Date, tasks: [Task]) { self.date = date self.tasks = tasks // Berekeningen één keer uitgevoerd self.completedCount = tasks.filter(\.isCompleted).count self.pendingCount = tasks.filter { !$0.isCompleted }.count self.highPriorityCount = tasks.filter { $0.priority == .high && !$0.isCompleted }.count } } ``` Deze optimalisaties zorgen voor responsieve widgets die de batterij niet overmatig verbruiken. > **Widgets debuggen** > > Gebruik het widget-schema in Xcode voor debugging. Het canvas-voorbeeld maakt het mogelijk om verschillende groottes en statussen te testen zonder installatie op het apparaat. ## Conclusie WidgetKit iOS 17+ met App Intents transformeert widgets in echte interactieve uitbreidingen van iOS-applicaties. Deze declaratieve architectuur vereenvoudigt de ontwikkeling aanzienlijk en biedt een native, vloeiende gebruikerservaring. ### Checklist Interactieve iOS 17+ Widget - ✅ App Group configureren voor gegevensuitwisseling - ✅ Timeline Provider aanmaken met geschikte vernieuwing - ✅ App Intents implementeren voor elke actie - ✅ `Button(intent:)` of `Toggle(intent:)` gebruiken voor interactiviteit - ✅ `WidgetCenter.shared.reloadTimelines` aanroepen na wijziging - ✅ Verplichte `.containerBackground` toevoegen voor iOS 17+ - ✅ Vloeiende overgangsanimaties implementeren - ✅ Laad- en foutstatussen afhandelen - ✅ Views optimaliseren voor batterijprestaties - ✅ Testen op alle ondersteunde widgetgroottes --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/nl/blog/ios/widgetkit-ios17-interactive-widgets-app-intents