Live Activities Are a State Machine, Not a Badge
TITLE : Les Live Activities sont une machine à états, pas un badge DESCRIPTION : La Live Activity de Return ressemble à un compte à rebours sur l’écran verrouillé. C’est en réalité une machine de cycle de vie à cinq états avec trois chemins de fermeture. Le code de production et les bugs. BODY: Genre : shipped-code. Cet article documente la Live Activity que j’ai intégrée à Return, le minuteur de méditation SwiftUI qu’utilisent ma femme, ma mère et quelques milliers d’inconnus.1 Les patterns sont ceux qui ont survécu à la mise en production. Le pied de page de brutale honnêteté indique ce que je ne sais pas encore.
La Live Activity de Return ressemble à un compteur sur l’écran verrouillé et sur la Dynamic Island.2 Ce n’est pas un nombre. C’est une machine à cinq états de cycle de vie avec trois chemins de fermeture externes et un chemin de démarrage réentrant qui doit se défendre contre lui-même.
J’ai livré une v1 qui traitait la Live Activity comme un badge. Le « temps restant actuel » était la donnée ; le reste n’était que décoration. Cette version comportait trois bugs que j’ai détectés en TestFlight et un que j’ai détecté en production :
- Toucher démarrer pendant qu’un démarrage était déjà en cours créait une seconde activité qui orphelinait la première.
- Le compte à rebours s’affichait correctement sur la Dynamic Island, mais la vue de l’écran verrouillé évaluait
endTime <= Date()pour les minuteurs en pause et affichait0:00jusqu’à ce que l’utilisateur reprenne. - La Live Activity restait visible longtemps après que l’utilisateur ait réinitialisé le minuteur, parce que la politique de fermeture était
.default, qu’Apple maintient visible pendant un certain temps allant jusqu’à quatre heures. - (Production.) Sur les locales de langues écrites de droite à gauche (arabe, hébreu), les chiffres s’affichaient à l’envers dans la zone compact-trailing de la Dynamic Island. Chiffres latins, mise en page RTL. Le correctif tenait en une ligne.
Chacun de ces bugs était un bug de machine à états. Le compteur lui-même fonctionnait. Le nombre n’est pas le produit. Le produit, c’est l’état.
La machine à états ci-dessous est ce qui a survécu à ces bugs.
TL;DR
- Le
LiveActivityManagerlivré expose 5 méthodes de transition (startActivity,updateActivity,showCycleComplete,showFinalCompletion,endActivity) plus 1 lecture (hasActiveActivity). Les 224 lignes de production protègent contre un risque spécifique à l’intérieur destartActivity: les appels concurrents de démarrage plus les vérifications d’annulation à chaque frontièreawaitde cette méthode.3 - Le
ContentStateporte 6 champs :endTime,currentCycle,totalCycles,isPaused,isCompleted,remainingSeconds. Les cinq premiers sont les étiquettes de la machine à états. Le sixième (remainingSeconds) est un repli d’affichage statique que letimerIntervalen direct d’ActivityKit ne peut pas servir. - La décision de la politique de fermeture est le véritable choix produit.
.immediatepour la réinitialisation par l’utilisateur,.after(Date().addingTimeInterval(3))pour la complétion, jamais le défaut système. - La zone compact-trailing de la Dynamic Island nécessite
.environment(\.layoutDirection, .leftToRight)sur le texte du minuteur pour conserver les chiffres latins en LTR sous des locales système RTL.
La machine à états
La Live Activity livrée comporte un état inactif, trois états en direct que l’utilisateur peut observer, un état terminal et une porte réentrante que le développeur doit observer :
┌──────────────────────────────────────────────────────────────────┐
│ Lifecycle states │
├──────────────────────────────────────────────────────────────────┤
│ IDLE currentActivity == nil; no Live Activity present │
│ RUNNING isPaused=false, endTime > Date() │
│ PAUSED isPaused=true, remainingSeconds=N │
│ CYCLE_END isPaused=false, endTime <= Date(), isCompleted=false│
│ COMPLETE isCompleted=true (terminal; transitions to IDLE) │
└──────────────────────────────────────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────────────────────┐
│ Dismissal policies (Apple) │
├──────────────────────────────────────────────────────────────────┤
│ .immediate user reset │
│ .after(now + 3s) completion display window │
│ .default system decides; can stay up to 4 hours │
└──────────────────────────────────────────────────────────────────┘
Reentrancy gate inside startActivity():
isStartingActivity flag + cancellable startActivityTask
prevents two concurrent startActivity() calls from creating
two Live Activities for one timer. Cancellation checks across
each await keep the in-flight task safe to abort.
Le chemin de rendu vérifie isPaused en premier ; cet ordre est ce qui empêche un minuteur en pause de s’afficher comme CYCLE_END lorsque l’horloge murale a dépassé endTime.7
Les noms d’états ne sont pas des étiquettes posées sur le nombre. Les noms d’états sont le contrat entre LiveActivityManager (côté app, où vivent mes vues SwiftUI) et ReturnLiveActivity (l’extension de widget, où le processus d’Apple rend la surface).
Le contrat est TimerActivityAttributes.ContentState, ses 6 champs :3
public struct ContentState: Codable, Hashable {
var endTime: Date
var currentCycle: Int
var totalCycles: Int?
var isPaused: Bool
var isCompleted: Bool = false
var remainingSeconds: Int = 0
}
Chaque transition d’état mute cette structure et demande à ActivityKit de la livrer à travers les frontières de processus à l’extension de widget. Le widget effectue alors un nouveau rendu. Il n’y a pas de mémoire partagée. Il n’y a pas de callback. Il y a une struct Codable qui traverse une frontière de processus à chaque transition.
Ce fait écarte tout ce que je pourrais vouloir faire avec des closures, des view models, des observable objects ou des computed properties. L’état doit être exprimable sous forme de données sérialisables. S’il ne peut pas être encodé, il ne peut pas effectuer de transition.
Le démarrage réentrant
Les Live Activities ont une limite stricte sur les activités concurrentes et une limite souple sur ce qui se passe si vous appelez Activity.request deux fois en parallèle. La limite stricte est bien documentée.4 La limite souple est : « le second appel peut réussir et créer un orphelin ». L’orphelin est la Live Activity qui n’est plus associée à currentActivity dans votre manager. Elle survit. Elle n’a aucun chemin de retour vers votre code. Elle se ferme finalement par son propre minuteur de péremption. L’utilisateur voit un minuteur en double jusque-là.
L’orphelin était le bug v1 que Return a livré. Le correctif est la porte réentrante plus une Task annulable dans LiveActivityManager.swift :3
private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?
func startActivity(...) {
#if os(iOS)
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard !isStartingActivity else { return }
isStartingActivity = true
startActivityTask?.cancel()
startActivityTask = Task {
defer {
isStartingActivity = false
startActivityTask = nil
}
guard !Task.isCancelled else { return }
await endActivity() // explicit cleanup of any prior state
guard !Task.isCancelled else { return }
// ... build attributes + contentState ...
do {
let activity = try Activity.request(...)
guard !Task.isCancelled else { return }
currentActivity = activity
} catch {
// log; flag clears via defer
}
}
#endif
}
Trois choses à propos de ce pattern que la documentation ne signale pas :
Le drapeau isStartingActivity est la protection active ; startActivityTask?.cancel() est un nettoyage défensif. Le drapeau court-circuite tout second appel à startActivity pendant que le premier est en cours, donc vous ne provoquez pas réellement de course sur le chemin public. La danse cancel-then-replace reste pertinente parce que la Task en vol est asynchrone et peut survivre à un appelant éphémère ; l’annulation empêche une Task obsolète de continuer après que l’appelant est passé à autre chose.
Les vérifications guard !Task.isCancelled à chaque frontière await. L’annulation est coopérative en Swift. Même si cancel est appelé, la Task continue de s’exécuter jusqu’à ce qu’elle vérifie explicitement. Chaque await est une opportunité de vérifier. Sans les vérifications post-await, une Task annulée continue de construire l’état d’activité, appelle Activity.request et crée silencieusement un orphelin en cas de succès.
Le defer libère le drapeau avant que le corps de la Task se termine. Sans defer, un return précoce (depuis la vérification d’annulation) laisse isStartingActivity = true en permanence et l’activité ne redémarre jamais jusqu’au relancement de l’app. Le drapeau est un verrou ; le verrou doit se libérer sur chaque chemin de sortie.
L’argument pushType: nil. Return n’utilise pas les mises à jour de Live Activity poussées par APNs. L’app met à jour l’activité localement via activity.update. Si vous avez besoin de mises à jour pilotées par push (suivi de livraison, scores sportifs, données temps réel), le type est pushType: .token et le contrat est nettement plus complexe.5 Les mises à jour locales sont plus simples et couvrent tout workflow minuteur / compteur / mono-app.
Le problème de la pause
ActivityKit livre une magnifique vue Text(timerInterval: Date()...endTime, countsDown: true) qui rend un compte à rebours en direct sans aucune mise à jour de la part de l’app.6 Vous fixez l’heure de fin, le système rend un minuteur en direct. Pas de Timer.publish, pas de rafraîchissement de widget, pas de drain de batterie.
C’est fantastique quand le minuteur tourne. C’est faux quand le minuteur est en pause.
Le texte timerInterval compte vers endTime indépendamment de tout signal de « pause » dans l’état. Il n’existe pas de mode « figé à 10:23 » dans le TERM_18 d’Apple. Si vous passez endTime = Date().addingTimeInterval(623) et que l’utilisateur met en pause à la marque de 10:23, le texte du minuteur continue de décompter jusqu’à zéro dans le widget. Le champ d’état dit en pause. Le widget affiche en marche.
Le correctif consiste à rendre deux vues différentes à partir du même état :7
if context.state.isPaused {
// static text
Text(formatTime(context.state.remainingSeconds))
.monospacedDigit()
} else if context.state.endTime > Date() {
// live countdown
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.monospacedDigit()
} else {
// post-end static
Text("0:00")
.monospacedDigit()
}
Le rendu à deux pistes est la raison pour laquelle le ContentState porte remainingSeconds comme champ séparé. Il est redondant lorsque le minuteur tourne (le système le calcule à partir de endTime). C’est la seule source de vérité lorsque le minuteur est en pause. Les deux moitiés de la struct servent deux modes de rendu différents ; le booléen isPaused choisit entre eux.
Les politiques de fermeture
activity.end(_:dismissalPolicy:) accepte l’une des trois valeurs ActivityUIDismissalPolicy, et choisir mal est ce qui a fait que ma v1 restait sur l’écran verrouillé de l’utilisateur pendant ce qui semblait une éternité après une réinitialisation :13
| Politique | Quand l’utiliser | Ce que vous obtenez |
|---|---|---|
.immediate |
Réinitialisation par l’utilisateur, erreur, app en arrière-plan sans activité à suivre | L’activité disparaît maintenant. Aucune fenêtre de grâce |
.after(date) |
Affichage de complétion : « votre méditation est terminée » doit rester lisible un moment. La date doit se situer dans la fenêtre de quatre heures qu’Apple autorise | L’activité montre l’état final, puis se ferme à date |
.default |
Quand vous voulez vraiment qu’Apple décide via ses heuristiques | Le système la garde visible « pendant un certain temps » (formulation d’Apple), jusqu’à quatre heures après l’appel à end |
Return utilise .after(Date().addingTimeInterval(3)) pour le chemin de complétion naturelle :3
await activity.end(
.init(state: contentState, staleDate: nil),
dismissalPolicy: .after(Date().addingTimeInterval(3))
)
Trois secondes correspondent au temps dont un utilisateur a besoin pour jeter un œil à l’écran verrouillé, enregistrer que le minuteur s’est terminé et ressentir la satisfaction de la coche. Moins de trois, c’est saccadé. Plus de trois, on a l’impression que l’activité ignore qu’elle est terminée.
Pour une réinitialisation déclenchée par l’utilisateur, l’appel est dismissalPolicy: .immediate. Aucune fenêtre. L’utilisateur sait déjà.
Le mauvais choix en v1 était .default. Pour un minuteur de méditation terminé, le système gardait l’activité visible suffisamment longtemps pour que les utilisateurs pensent que l’app n’avait pas du tout enregistré la complétion. La documentation d’Apple indique que .default garde l’activité terminée « visible pendant un certain temps » jusqu’à quatre heures ;13 la bonne posture pour un minuteur est de rendre la fermeture explicite.
La zone compacte de la Dynamic Island
La Dynamic Island a trois modes de rendu et vous avez besoin des trois même pour un simple minuteur :2
- Compact (forme par défaut de la Dynamic Island) : icône leading + minuteur trailing
- Minimal (lorsqu’une autre Live Activity dispute la même Dynamic Island) : icône leading uniquement
- Étendu (appui long) : quatre régions nommées (
leading,trailing,center,bottom)
Le pattern qui a gagné sa place dans Return est de rendre la vue étendue presque identique au compact :8
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image("AppIconSmall")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
DynamicIslandExpandedRegion(.trailing) {
TimerText(...)
}
DynamicIslandExpandedRegion(.center) { EmptyView() }
DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
Image("AppIconSmall")...
} compactTrailing: {
TimerText(...)
} minimal: {
Image("AppIconSmall")...
}
La plupart des tutoriels sur les Live Activities s’appuient sur la vue étendue comme le « véritable » design, avec un contenu riche dans la région bottom. Pour un minuteur de méditation, l’expansion est du poids mort. L’utilisateur ouvre la vue étendue par un appui long, et l’appui long lui donne déjà le retour haptique indiquant que quelque chose s’est passé. Ajouter du contenu fait dire à l’expansion quelque chose que l’utilisateur n’a pas demandé. Les régions vides en mode étendu ne sont pas un échec de design ; elles sont le design.
Le bug RTL
Le bug de production. Des utilisateurs arabophones et hébraïques sur iOS ont signalé que le minuteur compact-trailing de la Dynamic Island affichait les chiffres à l’envers. La chaîne numérique latine 5:23 s’affichait 32:5 parce que la direction de mise en page de la zone compact-trailing héritait du paramètre RTL de la locale système.
SwiftUI hérite de la direction de mise en page système à l’intérieur du processus du widget, donc le texte du minuteur de la Dynamic Island prenait le RTL lorsque le téléphone de l’utilisateur était réglé en arabe ou en hébreu. Les chiffres latins devraient s’afficher en LTR même au sein d’une UI par ailleurs RTL. Le correctif consiste à fixer la direction de mise en page sur les vues de texte numérique :7
.environment(\.layoutDirection, .leftToRight)
L’override va sur les vues Text numériques à l’intérieur de TimerText (Dynamic Island compact / étendu) et à l’intérieur de la vue de l’écran verrouillé, pas sur la vue entière. Les chiffres latins se lisent de gauche à droite indépendamment de la locale système de l’utilisateur ; les libellés de cycle comme « Cycle 2 of 3 » restent localisés afin de suivre la direction de mise en page du système.
Le bug ne se manifeste pas en TestFlight sur des locales domestiques. Il se manifeste à l’instant où un véritable utilisateur RTL ouvre le minuteur. La leçon : livrez l’override d’environnement épinglant le LTR sur chaque vue de texte à chiffres latins dans toute Live Activity susceptible de tourner sur des locales RTL.
L’histoire de la localisation
TimerActivityAttributes porte un champ languageCode: String défini par l’app à la création de l’activité :9
let attributes = TimerActivityAttributes(
timerDuration: duration,
languageCode: settings.appLanguage // app's selected language, not system's
)
L’extension de widget lit ceci pour rendre les chaînes localisées :
private var locale: Locale {
let code = context.attributes.languageCode
return code.isEmpty ? .current : Locale(identifier: code)
}
private func localized(_ key: String.LocalizationValue) -> String {
String(localized: key, locale: locale)
}
Pourquoi l’app passe son propre code de langue plutôt que de laisser le widget lire Locale.current : l’extension de widget tourne dans son propre processus. Son Locale.current est la locale système, pas la locale sélectionnée par l’app. Si un utilisateur a réglé Return sur le coréen tandis que son iPhone est en anglais, le widget parlerait anglais sans cet override. La préférence de langue de l’app voyage dans les attributs d’activité ; le widget l’honore.
Localizable.xcstrings vit dans la cible widget aux côtés de celui de l’app, mais ce sont des fichiers séparés. Les chaînes utilisées dans le widget doivent exister dans ReturnWidgets/Localizable.xcstrings même si la même chaîne existe dans Return/Localizable.xcstrings. Oublier cela signifie que le widget retombe sur la langue de développement pendant que l’app parle coréen.
Ce que je construirais différemment
Rendre ContentState plus petit. Six champs, c’est trop. La redondance entre endTime et remainingSeconds est le prix du contournement de l’absence de mode pause dans timerInterval. Si je recommençais, je porterais une seule enum displayMode (running, paused(remainingSeconds: Int), cycleEnd, complete) et laisserais le code de rendu dispatcher sur le cas. Six champs sont plus difficiles à maintenir correctement mutés à travers cinq méthodes de transition que ne le sont quatre cas.
Ajouter des boutons de Live Activity interactifs (iOS 17+). Return n’expose pas actuellement de contrôles pause/reprise dans la Dynamic Island. L’utilisateur doit ouvrir l’app pour mettre en pause. iOS 17 a ajouté Button(intent:) pour les App Intents à l’intérieur des Live Activities.10 Un contrôle de pause interactif est l’extension évidente et la prochaine chose que je livrerai pour Return.
Live Activities mises à jour par push pour la synchronisation de minuteur entre appareils. Return synchronise les sessions à travers iPhone, iPad, Watch et Apple TV via NSUbiquitousKeyValueStore (couvert dans Five Apple Platforms, Three Shared Files). Aujourd’hui, l’activité est démarrée localement depuis l’app iPhone ou iPad et mise à jour localement. Un utilisateur démarrant un minuteur sur Apple Watch pourrait idéalement voir la Live Activity refléter cela sur l’iPhone en temps réel. Le push APNs vers la Live Activity est le chemin.5 Pas encore construit.
Quand ne pas utiliser les Live Activities
État transitoire ponctuel. Un toast « enregistré ! » ne mérite pas une Live Activity. Le système a une bannière. Utilisez-la.
Données changeant fréquemment sans dimension de minuteur. Les Live Activities fonctionnent au mieux pour des choses ayant un ancrage temporel clair (un minuteur, une ETA de livraison, une horloge de match, une durée d’appel téléphonique). Les tickers boursiers et les scores sportifs fonctionnent parce qu’ils ont une fenêtre de session. Un dashboard polyvalent, non.
Apps sans cas d’usage écran verrouillé / standby. Les Live Activities exigent un véritable investissement d’ingénierie (configuration de cible, design du ContentState, décisions de politique de fermeture, gestion RTL, plomberie de localisation). Les apps que l’utilisateur ouvre directement sans jamais consulter l’écran verrouillé pendant l’usage ne sont pas la bonne forme. Un éditeur photo n’en a pas besoin. Un tracker d’entraînement, oui.
Sur des surfaces non iOS, avec mises en garde. Le LiveActivityManager de Return livre son implémentation derrière #if os(iOS) parce que le minuteur est démarré depuis l’app iPhone ou iPad. ActivityKit lui-même décrit la bannière de l’écran verrouillé, la Dynamic Island, le Smart Stack d’Apple Watch, le Mac et CarPlay comme surfaces de présentation ; iOS 26 en a élargi plusieurs.4 watchOS a toujours ses propres complications TERM_18 pour le rendu plein écran. macOS a des apps de barre de menus. iPadOS prend en charge les Live Activities depuis iPadOS 17 sans région Dynamic Island. Le manager de Return comporte 8 gardes #if os(iOS) à travers un fichier de 224 lignes.
Ce que le pattern signifie pour les apps livrant sur iOS 26+
Deux enseignements.
-
Traitez la Live Activity comme une machine à états, pas comme un nombre. La machine à états a des états clairs, des transitions claires et des règles de fermeture claires. Le nombre à l’écran est un rendu d’un état. Mettez d’abord les états au point.
-
La garde de réentrance est le bug que vous n’avez pas encore rencontré. Chaque manager de Live Activity que j’ai vu en production qui n’implémente pas
isStartingActivity+ Task annulable a livré au moins un bug d’activité orpheline. La garde tient en 6 lignes. Écrivez-la une fois.
Associez cet article à mes écrits précédents pour la même famille d’apps : les App Intents typés pour Apple Intelligence ; les serveurs TERM_17 pour des agents inter-TERM_23 ; les patterns Liquid Glass pour la couche visuelle ; la livraison multi-plateformes pour la portée inter-appareils. Les Live Activities sont la couche écran-verrouillé-iOS-et-Dynamic-Island de la même pile. L’ensemble complet vit sur le hub de la série Apple Ecosystem. Pour le contexte plus large iOS-avec-agents-IA, consultez le guide iOS Agent Development.
FAQ
Quelle est la différence entre les Live Activities et les widgets WidgetKit ?
Les widgets WidgetKit s’affichent à des intervalles définis par TimelineProvider ; le système décide quand rafraîchir et le widget effectue un nouveau rendu à partir d’une timeline statique.11 Les Live Activities s’affichent en réponse à des appels activity.update(...) spécifiques pilotés par l’app et vivent pour la durée de l’activité sous-jacente (un minuteur, une livraison, un entraînement). Les deux sont livrés dans la cible d’extension widget ; la différence est le modèle de déclenchement.
Les Live Activities fonctionnent-elles sur iPad ?
Oui, depuis iPadOS 17+. La bannière de l’écran verrouillé est la principale surface de rendu ; l’iPad n’a pas de Dynamic Island. Le même code ActivityConfiguration fonctionne ; attendez-vous simplement à ce que les régions de la Dynamic Island ne s’affichent jamais sur iPad.
Une Live Activity peut-elle survivre au processus de mon app ?
Oui. Une fois que Activity.request réussit, ActivityKit possède l’activité. Le processus de l’app peut être terminé par le système ; l’activité continue de s’afficher sur l’écran verrouillé et la Dynamic Island jusqu’à ce que vous y mettiez fin explicitement (ou que les règles de péremption du système la ferment). Les appels explicites à endActivity() importent pour cette raison ; sans une fin explicite à la réinitialisation de l’app, l’activité survit au minuteur.
Pourquoi cet article ne couvre-t-il pas les Live Activities mises à jour par push ?
Je n’ai pas livré de Live Activities mises à jour par push dans Return. Selon la règle de genre pour ce cluster : les articles shipped-code documentent uniquement ce que fait le code de production. Les mises à jour par push sont mentionnées dans « Ce que je construirais différemment » ; un futur article les couvrira après que je les aurai livrées.
Quelle est la disposition réelle des fichiers pour les Live Activities dans une app SwiftUI ?
- Dans la cible app principale :
LiveActivityManager.swift(gère le cycle de vie de l’activité),TimerActivityAttributes.swift(la structActivityAttributespartagée avec le widget ; les deux cibles compilent ce fichier). - Dans une cible d’extension widget :
ReturnLiveActivity.swift(la conformanceWidgetavec un corpsActivityConfiguration),ReturnWidgetsBundle.swift(le@main WidgetBundle). - Configuration :
Info.plistavecNSSupportsLiveActivities = YESdans la cible app.
La cible d’extension widget nécessite les imports ActivityKit et WidgetKit. TimerActivityAttributes est le seul fichier partagé entre les deux cibles ; tout le reste est isolé par cible.
La Live Activity n’est pas un nombre sur l’écran verrouillé. C’est une machine à états qui traverse une frontière de processus à chaque transition. Mettez les états au point, gardez la réentrance, choisissez la politique de fermeture en connaissance de cause et épinglez la direction de mise en page. Le nombre s’occupe de lui-même.
Références
-
Return de l’auteur, un minuteur de méditation SwiftUI publié sur l’App Store le 21 avril 2026, disponible pour iPhone, iPad, Mac, Apple Watch et Apple TV. Les Live Activities sont livrées uniquement sur la cible iOS. ↩
-
Apple Developer, « ActivityKit framework ». Bannière d’écran verrouillé, modes Dynamic Island compact / minimal / étendu, cycle de vie de l’activité. Disponible iOS 16.1+ ; Dynamic Island disponible sur iPhone 14 Pro et plus récent. ↩↩
-
Code de production dans
Return/Return/LiveActivityManager.swift(224 lignes, 8 blocs#if os(iOS)) etReturn/Return/TimerActivityAttributes.swift(43 lignes). Partagé entre la cible app et la cible d’extension widget via l’appartenance à la cible. ↩↩↩↩↩ -
Apple Developer, « Displaying live data with Live Activities ». Limites de concurrence, plateformes prises en charge (iOS 16.1+, iPadOS 17+), clé Info.plist
NSSupportsLiveActivities. ↩↩ -
Apple Developer, « Updating and ending your Live Activity with ActivityKit push notifications ». Le chemin
pushType: .tokenrequiert une clé d’authentification APNs séparée, l’enregistrement du token push côté serveur et un protocole de mise à jour différent des appels locauxactivity.update(...). ↩↩ -
Apple Developer, « Text(timerInterval:pauseTime:countsDown:showsHours:) ». Compte à rebours en direct rendu par le système ; s’affiche sans mises à jour de l’app pendant que l’activité tourne. ↩
-
Code de production dans
Return/ReturnWidgets/ReturnLiveActivity.swift(232 lignes). La conformanceWidgetde l’extension de widget avec un corpsActivityConfiguration<TimerActivityAttributes>. La vueTimerTextaux lignes 61-102 gère le rendu à trois états : en pause / en marche / post-fin. ↩↩↩↩ -
Apple Developer, « DynamicIsland ». Les quatre régions étendues nommées (
leading,trailing,center,bottom) plus trois vues du mode compact (compactLeading,compactTrailing,minimal). ↩ -
L’extension de widget tourne dans son propre processus et hérite de la locale système, pas de la locale sélectionnée par l’app. Les apps prenant en charge la commutation de langue intra-app (Return prend en charge 27 langues) doivent passer le code de langue via
ActivityAttributesafin que le widget puisse s’afficher dans la langue choisie par l’utilisateur. Pattern :Locale(identifier: context.attributes.languageCode)plutôt queLocale.current. ↩ -
Apple Developer, « Button(intent:) ». Disponible dans les vues widget et Live Activity à partir d’iOS 17+. Pont des App Intents vers les contrôles écran verrouillé / Dynamic Island sans nécessiter le passage au premier plan de l’app. ↩
-
Apple Developer, « TimelineProvider ». Le modèle de rafraîchissement de widget antérieur aux Live Activities ; entrées précalculées avec fenêtres de rechargement gérées par le système. ↩
-
Code de production dans
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 lignes). Le@main WidgetBundlequi enregistreReturnLiveActivitycomme unique widget de l’extension de widget. Pattern requis pour les extensions de widget ; le bundle est ce que le système charge. ↩ -
Apple Developer, « ActivityUIDismissalPolicy ». Trois cas :
.default,.immediate,.after(_:). Apple indique que.defaultgarde une Live Activity terminée visible « pendant un certain temps » jusqu’à quatre heures, et que.after(_:)accepte une date dans la même fenêtre de quatre heures. ↩↩