# WidgetKit iOS 17+ : créer des widgets interactifs avec App Intents > Guide complet pour créer des widgets iOS interactifs avec WidgetKit et App Intents. Boutons, toggles, animations et bonnes pratiques pour iOS 17+ en 2026. - Published: 2026-03-04 - Updated: 2026-03-28 - Author: SharpSkill - Tags: widgetkit, ios, app-intents, swift, widgets - Reading time: 14 min --- iOS 17 a révolutionné WidgetKit en introduisant l'interactivité native. Les widgets ne sont plus de simples affichages statiques : ils peuvent désormais réagir aux actions utilisateur directement sur l'écran d'accueil, sans ouvrir l'application. Cette évolution majeure repose sur le framework App Intents, offrant une expérience utilisateur fluide et moderne. > **Ce que couvre cet article** > > Cet article présente la création complète de widgets interactifs iOS 17+, depuis la configuration du projet jusqu'aux patterns avancés avec animations et gestion d'état. ## Architecture des widgets interactifs L'interactivité des widgets iOS 17+ fonctionne via le framework App Intents. Contrairement aux deep links traditionnels qui ouvraient l'app, les App Intents permettent d'exécuter du code directement depuis le widget, puis de rafraîchir l'affichage avec les nouvelles données. ```swift // InteractiveWidgetArchitecture.swift import WidgetKit import SwiftUI import AppIntents // L'architecture repose sur trois composants principaux : // 1. Widget Timeline Provider - fournit les données // 2. Widget View - affiche l'interface avec Button/Toggle // 3. App Intent - exécute l'action au tap struct TaskWidget: Widget { // Identifiant unique du widget let kind: String = "TaskWidget" var body: some WidgetConfiguration { // StaticConfiguration pour widgets sans paramètres StaticConfiguration( kind: kind, provider: TaskTimelineProvider() ) { entry in TaskWidgetView(entry: entry) // Requis pour App Intents .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Tâches") .description("Gérez vos tâches depuis l'écran d'accueil.") .supportedFamilies([.systemSmall, .systemMedium]) } } ``` Le widget déclare sa configuration et spécifie le provider qui fournira les données. L'attribut `.containerBackground` est obligatoire depuis iOS 17 pour les widgets interactifs. ## Création du Timeline Provider Le Timeline Provider détermine quand et comment le widget se rafraîchit. Pour les widgets interactifs, il doit également réagir aux changements déclenchés par les App Intents. ```swift // TaskTimelineProvider.swift import WidgetKit import SwiftUI // Entry représentant l'état du widget à un instant donné struct TaskEntry: TimelineEntry { let date: Date let tasks: [Task] // État de chargement pour feedback visuel var isLoading: Bool = false } // Modèle de données partagé entre app et 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 affiché pendant le chargement initial func placeholder(in context: Context) -> TaskEntry { TaskEntry( date: Date(), tasks: [ Task(id: UUID(), title: "Exemple de tâche", isCompleted: false, priority: .medium) ] ) } // Snapshot pour la galerie de widgets 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 complète avec politique de rafraîchissement func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let tasks = TaskDataManager.shared.fetchTasks() let entry = TaskEntry(date: Date(), tasks: Array(tasks.prefix(3))) // Rafraîchissement dans 15 minutes ou après action utilisateur let nextUpdate = Calendar.current.date( byAdding: .minute, value: 15, to: Date() ) ?? Date() let timeline = Timeline( entries: [entry], policy: .after(nextUpdate) ) completion(timeline) } } ``` Le provider utilise un `TaskDataManager` partagé pour accéder aux données. Cette approche garantit la synchronisation entre l'application principale et le widget. > **App Group requis** > > Pour partager des données entre l'app et le widget, configurez un App Group dans les capabilities du projet. Le UserDefaults ou les fichiers doivent utiliser ce groupe partagé. ## Gestionnaire de données partagé Le partage de données entre l'application et le widget nécessite un conteneur commun accessible via App Group. ```swift // TaskDataManager.swift import Foundation final class TaskDataManager { // Singleton pour accès global static let shared = TaskDataManager() // Identifiant de l'App Group configuré dans Xcode private let appGroupID = "group.com.example.taskapp" // UserDefaults partagé entre app et widget private var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupID) } private let tasksKey = "tasks" private init() {} // Récupération des tâches depuis le stockage partagé func fetchTasks() -> [Task] { guard let data = sharedDefaults?.data(forKey: tasksKey), let tasks = try? JSONDecoder().decode([Task].self, from: data) else { return [] } return tasks } // Sauvegarde avec notification au widget func saveTasks(_ tasks: [Task]) { guard let data = try? JSONEncoder().encode(tasks) else { return } sharedDefaults?.set(data, forKey: tasksKey) } // Mise à jour d'une tâche spécifique func updateTask(_ task: Task) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == task.id }) { tasks[index] = task saveTasks(tasks) } } // Basculement de l'état completed func toggleTaskCompletion(taskID: UUID) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == taskID }) { tasks[index].isCompleted.toggle() saveTasks(tasks) } } } ``` Ce gestionnaire encapsule toute la logique de persistance et sera utilisé à la fois par l'application et les App Intents du widget. ## Création de l'App Intent pour l'interactivité L'App Intent définit l'action exécutée lorsque l'utilisateur interagit avec le widget. iOS exécute cette action en arrière-plan puis rafraîchit automatiquement le widget. ```swift // ToggleTaskIntent.swift import AppIntents import WidgetKit // Intent pour basculer l'état d'une tâche struct ToggleTaskIntent: AppIntent { // Titre affiché dans les raccourcis Siri static var title: LocalizedStringResource = "Basculer l'état d'une tâche" // Description pour l'accessibilité static var description = IntentDescription("Marque une tâche comme complétée ou non complétée.") // Paramètre : l'ID de la tâche à modifier @Parameter(title: "ID de la tâche") var taskID: String // Initializer requis pour AppIntent init() {} // Initializer avec paramètre pour création depuis la vue init(taskID: UUID) { self.taskID = taskID.uuidString } // Exécution de l'action func perform() async throws -> some IntentResult { // Conversion de l'ID string vers UUID guard let uuid = UUID(uuidString: taskID) else { return .result() } // Mise à jour de la tâche TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) // Demande de rafraîchissement du widget WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result() } } ``` L'appel à `WidgetCenter.shared.reloadTimelines` déclenche le rafraîchissement immédiat du widget après l'action, assurant un feedback visuel instantané. ## Vue du widget avec boutons interactifs La vue du widget utilise le composant `Button` standard de SwiftUI avec l'intent comme action. iOS 17+ intercepte automatiquement ces interactions pour exécuter l'App Intent. ```swift // TaskWidgetView.swift import SwiftUI import WidgetKit struct TaskWidgetView: View { let entry: TaskEntry // Adaptation à la taille du widget @Environment(\.widgetFamily) var family var body: some View { VStack(alignment: .leading, spacing: 8) { // Header avec titre et compteur headerView // Liste des tâches avec boutons interactifs ForEach(entry.tasks.prefix(tasksLimit)) { task in TaskRowView(task: task) } Spacer(minLength: 0) } .padding() } // Nombre de tâches selon la taille private var tasksLimit: Int { switch family { case .systemSmall: return 2 case .systemMedium: return 3 default: return 4 } } private var headerView: some View { HStack { Text("Tâches") .font(.headline) .fontWeight(.bold) Spacer() // Badge avec le nombre de tâches restantes 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 { // Button avec App Intent comme action Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // Indicateur de complétion Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // Titre de la tâche Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) .lineLimit(1) Spacer() // Indicateur de priorité 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() } } } ``` La syntaxe `Button(intent:)` connecte directement le bouton à l'App Intent. Au tap, iOS exécute `perform()` puis rafraîchit le widget automatiquement. ## Toggle interactif pour widgets Pour les actions de type on/off, le composant `Toggle` offre une alternative au bouton avec un style natif iOS. ```swift // ToggleWidgetView.swift import SwiftUI import AppIntents // Intent spécifique pour Toggle avec état explicite struct SetTaskCompletionIntent: AppIntent { static var title: LocalizedStringResource = "Définir l'état de la tâche" @Parameter(title: "ID de la tâche") var taskID: String // État cible : true = complété, false = non complété @Parameter(title: "Complété") 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 }) { // Définit l'état explicitement (pas 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() // Toggle interactif avec intent Toggle( isOn: task.isCompleted, intent: SetTaskCompletionIntent( taskID: task.id, isCompleted: !task.isCompleted ) ) .toggleStyle(.switch) .labelsHidden() } .padding(.vertical, 4) } } ``` Le Toggle offre une interaction plus intuitive pour les états binaires et s'intègre naturellement dans les designs iOS. > **Limites des widgets interactifs** > > Les widgets ne peuvent pas afficher d'alertes, de sheets ou de navigation. Toutes les actions doivent être autonomes et mettre à jour l'état visible directement. ## Animations et transitions au rafraîchissement iOS 17+ permet d'animer les transitions lors du rafraîchissement du widget après une action. Le modificateur `.contentTransition` contrôle ces animations. ```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) { // Icône avec animation de transition Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // Animation de l'icône au changement .contentTransition(.symbolEffect(.replace)) Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) // Animation du texte .contentTransition(.opacity) Spacer() } .padding(.vertical, 6) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 8) .fill(task.isCompleted ? Color.green.opacity(0.1) : Color.clear) ) // Animation du fond .animation(.easeInOut(duration: 0.3), value: task.isCompleted) } .buttonStyle(.plain) } } // Widget avec invalidation animée 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("Tâches animées") .description("Widgets avec animations fluides.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) // Active les animations de contenu .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("Tâches") .font(.headline.bold()) Spacer() let completed = entry.tasks.filter(\.isCompleted).count let total = entry.tasks.count // Progression animée Text("\(completed)/\(total)") .font(.caption.bold()) .foregroundStyle(.secondary) .contentTransition(.numericText()) } } } ``` Les animations `.symbolEffect(.replace)` et `.numericText()` créent des transitions fluides entre les états, améliorant significativement l'expérience utilisateur. ## Widget configurable avec AppIntentConfiguration Pour les widgets personnalisables par l'utilisateur (filtres, catégories), AppIntentConfiguration remplace StaticConfiguration. ```swift // ConfigurableTaskWidget.swift import WidgetKit import SwiftUI import AppIntents // Configuration exposée à l'utilisateur struct TaskWidgetConfigurationIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Configuration des tâches" static var description = IntentDescription("Personnalisez l'affichage des tâches.") // Filtre par priorité @Parameter(title: "Priorité", default: .all) var priorityFilter: PriorityFilter // Afficher les tâches complétées @Parameter(title: "Afficher complétées", default: true) var showCompleted: Bool // Nombre maximum de tâches @Parameter(title: "Nombre de tâches", default: 3) var maxTasks: Int } // Enum pour le filtre de priorité enum PriorityFilter: String, AppEnum { case all case high case medium case low static var typeDisplayRepresentation: TypeDisplayRepresentation = "Priorité" static var caseDisplayRepresentations: [PriorityFilter: DisplayRepresentation] = [ .all: "Toutes", .high: "Haute", .medium: "Moyenne", .low: "Basse" ] } // Provider adapté à la configuration 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)) } // Applique les filtres de configuration private func filteredTasks(for config: TaskWidgetConfigurationIntent) -> [Task] { var tasks = TaskDataManager.shared.fetchTasks() // Filtre par priorité if config.priorityFilter != .all { let priority = Task.Priority(rawValue: config.priorityFilter.rawValue) ?? .medium tasks = tasks.filter { $0.priority == priority } } // Filtre les complétées si nécessaire if !config.showCompleted { tasks = tasks.filter { !$0.isCompleted } } // Limite le nombre return Array(tasks.prefix(config.maxTasks)) } } // Widget avec configuration utilisateur struct ConfigurableTaskWidget: Widget { let kind: String = "ConfigurableTaskWidget" var body: some WidgetConfiguration { // AppIntentConfiguration pour widgets configurables AppIntentConfiguration( kind: kind, intent: TaskWidgetConfigurationIntent.self, provider: ConfigurableTaskProvider() ) { entry in TaskWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Tâches personnalisées") .description("Filtrez et personnalisez vos tâches.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } ``` Les utilisateurs peuvent maintenant configurer le widget via un long press, offrant une expérience personnalisée sans code additionnel dans l'application. ## Gestion des erreurs et états de chargement Une bonne UX nécessite de gérer les cas d'erreur et les états intermédiaires lors des interactions. ```swift // TaskIntentWithFeedback.swift import AppIntents import WidgetKit struct ToggleTaskWithFeedbackIntent: AppIntent { static var title: LocalizedStringResource = "Basculer tâche avec feedback" @Parameter(title: "ID de la tâche") 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 { // Retourne un échec silencieux return .result(value: false) } // Simulation d'opération async (sync avec serveur par ex.) do { try await Task.sleep(for: .milliseconds(100)) TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result(value: true) } catch { // Erreur : ne pas mettre à jour le widget return .result(value: false) } } } // Vue avec état de chargement struct TaskRowWithLoadingView: View { let task: Task @State private var isLoading = false var body: some View { Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // Indicateur conditionnel 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) } } ``` Le feedback visuel immédiat (opacité réduite, indicateur de chargement) informe l'utilisateur que son action a été prise en compte. ## Bonnes pratiques et optimisations Plusieurs patterns garantissent des widgets interactifs performants et fiables. ```swift // WidgetBestPractices.swift import WidgetKit import SwiftUI // 1. Toujours invalider le cache après modification final class WidgetRefreshManager { static func refreshAllWidgets() { // Rafraîchit tous les widgets de l'app WidgetCenter.shared.reloadAllTimelines() } static func refreshWidget(kind: String) { // Rafraîchit un widget spécifique WidgetCenter.shared.reloadTimelines(ofKind: kind) } // Appeler depuis l'app après modification de données static func notifyDataChanged() { Task { @MainActor in refreshAllWidgets() } } } // 2. Limiter la complexité des vues struct OptimizedWidgetView: View { let entry: TaskEntry var body: some View { // Préférer les vues simples sans GeometryReader VStack(alignment: .leading, spacing: 8) { ForEach(entry.tasks.prefix(3)) { task in // Composants légers minimalTaskRow(task) } } .padding() } // Vue minimale pour performance @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. Utiliser @AppStorage pour état simple struct QuickSettingsWidgetView: View { // Accès direct aux UserDefaults partagés @AppStorage("showCompletedTasks", store: UserDefaults(suiteName: "group.com.example.app")) private var showCompleted = true var body: some View { // L'état persiste entre les rafraîchissements Text(showCompleted ? "Showing all" : "Hiding completed") } } // 4. Pré-calculer les données dans le provider struct OptimizedTaskEntry: TimelineEntry { let date: Date let tasks: [Task] // Données pré-calculées let completedCount: Int let pendingCount: Int let highPriorityCount: Int init(date: Date, tasks: [Task]) { self.date = date self.tasks = tasks // Calculs effectués une seule fois self.completedCount = tasks.filter(\.isCompleted).count self.pendingCount = tasks.filter { !$0.isCompleted }.count self.highPriorityCount = tasks.filter { $0.priority == .high && !$0.isCompleted }.count } } ``` Ces optimisations garantissent des widgets réactifs qui ne consomment pas excessivement de batterie. > **Debug des widgets** > > Utilisez le schéma de widget dans Xcode pour déboguer. Le canvas preview permet de tester les différentes tailles et états sans installer sur device. ## Conclusion WidgetKit iOS 17+ avec App Intents transforme les widgets en véritables extensions interactives des applications iOS. Cette architecture déclarative simplifie considérablement le développement tout en offrant une expérience utilisateur native et fluide. ### Checklist widgets interactifs iOS 17+ - ✅ Configurer l'App Group pour le partage de données - ✅ Créer le Timeline Provider avec rafraîchissement approprié - ✅ Implémenter les App Intents pour chaque action - ✅ Utiliser `Button(intent:)` ou `Toggle(intent:)` pour l'interactivité - ✅ Appeler `WidgetCenter.shared.reloadTimelines` après modification - ✅ Ajouter `.containerBackground` obligatoire iOS 17+ - ✅ Implémenter des animations de transition fluides - ✅ Gérer les états de chargement et d'erreur - ✅ Optimiser les vues pour la performance batterie - ✅ Tester sur toutes les tailles de widgets supportées --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/ios/widgetkit-ios17-widgets-interactifs-app-intents