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.

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.
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.
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.
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<TaskEntry>) -> 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.
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.
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.
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é.
Prêt à réussir tes entretiens iOS ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
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.
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.
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.
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.
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.
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<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))
}
// 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.
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<Bool> {
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.
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.
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:)ouToggle(intent:)pour l'interactivité - ✅ Appeler
WidgetCenter.shared.reloadTimelinesaprès modification - ✅ Ajouter
.containerBackgroundobligatoire 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
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

App Intents et Siri Shortcuts : automatisation iOS avancée 2026
Guide complet App Intents et Siri Shortcuts iOS 18+. Créez des actions Siri personnalisées, intégrez Apple Intelligence et automatisez votre app Swift en 2026.

Combine vs async/await en Swift : patterns de migration progressive
Guide complet sur la migration de Combine vers async/await en Swift : stratégies progressives, bridging patterns, et coexistence des deux paradigmes dans une codebase iOS.

Questions entretien iOS accessibilité en 2026 : VoiceOver et Dynamic Type
Préparez vos entretiens iOS avec les questions clés sur l'accessibilité : VoiceOver, Dynamic Type, traits sémantiques et audits d'accessibilité.