Moteur de focus tvOS : modèles SwiftUI pour la Siri Remote
L’Apple TV est la seule plateforme Apple sans surface tactile. L’utilisateur navigue par balayages directionnels et appuis sur les boutons de la Siri Remote, et chaque interaction passe par le moteur de focus : un système qui décide quel élément reçoit le focus suivant en fonction de la géométrie, de la hiérarchie et de la structure de focus déclarée par le développeur1. SwiftUI sur tvOS expose un vocabulaire focalisé (pardonnez le jeu de mots) pour travailler avec le moteur : .focusable, @FocusState, .focused, .focusSection, .prefersDefaultFocus et .focusEffectDisabled. Les applications qui adoptent ce vocabulaire semblent natives ; celles qui le combattent produisent l’expérience d’une télécommande qui refuse de naviguer là où l’utilisateur s’y attend.
Cet article parcourt la surface API du moteur de focus avec les modèles qui aboutissent. Le cadre est « ce que le moteur suppose et comment SwiftUI vous permet de coopérer », car la conception de focus qui fonctionne sur iOS via le tap-and-scroll échoue souvent sur tvOS, et l’article du cluster Apple Platform Matrix soutenait que tvOS ne mérite sa place qu’avec une UI consciente du focus.
TL;DR
- Le moteur de focus résout le focus par la géométrie : il sélectionne la vue focalisable la plus proche dans la direction du balayage1. Les applications coopèrent en déclarant des vues focalisables, des sections de focus et des cibles de focus par défaut.
@FocusState(avec.focused(_:equals:)) est la primitive SwiftUI pour le contrôle programmatique du focus. Le même property wrapper fonctionne sur iOS, macOS, watchOS et tvOS, mais c’est sur tvOS qu’il prend tout son sens2..focusSection()regroupe plusieurs vues focalisables en une seule cible de focus pour la navigation inter-sections, puis laisse le moteur choisir au sein de la section3. Utilisez-le pour les rangées de boutons, les grilles de cartes, les sections de barre latérale..prefersDefaultFocus(_:in:)déclare quelle vue reçoit le focus lorsque l’utilisateur entre dans un contexte (un écran, une popover, un onglet). Associez-le à@Namespacepour délimiter la portée du défaut4.- L’effet de focus système (la surbrillance qui s’agrandit autour de la vue focalisée) est automatique. Désactivez-le avec
.focusEffectDisabled()uniquement lorsque vous implémentez un visuel de focus personnalisé ; sinon, l’effet natif de la plateforme est le bon.
Comment le moteur de focus décide
Le moteur de focus traite l’entrée de balayage de la Siri Remote et résout « où va le focus ensuite ? » via une recherche hiérarchique1 :
- Lire la direction du balayage (haut, bas, gauche, droite).
- Dans le contexte de focus actuel, trouver les vues focalisables dont les cadres se trouvent dans cette direction par rapport à la vue actuellement focalisée.
- Choisir la plus proche géométriquement le long de l’axe de balayage (avec un léger biais en faveur du maintien de l’alignement avec le centre de la vue actuelle).
- Si aucune vue focalisable ne se trouve dans cette direction, le balayage est consommé sans déplacer le focus.
L’implication : la disposition visuelle des vues focalisables compte autant que leur hiérarchie logique. Deux boutons décalés en diagonale produisent une navigation ambiguë ; deux boutons alignés verticalement produisent un haut/bas prévisible. Le modèle recommandé par le HIG pour les grilles et les listes est l’alignement d’abord, la décoration ensuite.
Les applications participent au moteur via les modificateurs de focus de SwiftUI. Le comportement par défaut est que les vues à intention interactive explicite (Button, NavigationLink, TextField) sont focalisables ; les vues statiques (Text, Image, les vues conteneurs comme VStack) ne le sont pas.
Rendre des vues personnalisées focalisables
Le modificateur .focusable() marque une vue comme cible de focus5. Le paramètre booléen optionnel conditionne la focalisabilité :
struct PosterCard: View {
let movie: Movie
@FocusState private var isFocused: Bool
var body: some View {
VStack {
Image(movie.posterName)
.resizable()
.aspectRatio(2/3, contentMode: .fit)
Text(movie.title)
.font(.headline)
}
.focusable(true)
.focused($isFocused)
.scaleEffect(isFocused ? 1.1 : 1.0)
.animation(.spring(), value: isFocused)
}
}
La vue devient une cible de focus sur laquelle le moteur peut atterrir. Le modèle convient aux cartes cliquables, aux boutons personnalisés et à toute vue composite qui devrait accepter l’attention de l’utilisateur. Sans .focusable(), le groupe Image + Text serait ignoré par le moteur.
@FocusState et .focused(_:equals:) pour le contrôle programmatique
Lorsque l’application doit diriger le focus (après une transition de navigation, après une soumission de recherche, après le rejet d’un modal), @FocusState est la primitive SwiftUI2 :
struct LoginView: View {
enum Field { case username, password, submit }
@FocusState private var focusedField: Field?
@State private var username = ""
@State private var password = ""
var body: some View {
VStack {
TextField("Username", text: $username)
.focused($focusedField, equals: .username)
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
Button("Sign In") { /* ... */ }
.focused($focusedField, equals: .submit)
}
.onAppear {
focusedField = .username
}
}
}
La valeur d’énumération @FocusState indique quel champ est focalisé ; assigner une nouvelle valeur de manière programmatique déplace le focus vers la vue correspondante. La case d’énumération Hashable est la convention ; plusieurs champs avec la même valeur de case seraient ambigus.
Pour une seule vue focalisable, @FocusState var isFocused: Bool plus .focused($isFocused) est la forme la plus simple. La variante booléenne convient lorsque la question est « cette vue est-elle focalisée ? » ; la variante d’énumération convient pour « quelle vue dans cet ensemble ? ».
.focusSection() pour le regroupement
Sans .focusSection(), chaque vue focalisable participe à la recherche géométrique du moteur au même niveau. Avec ce modificateur, un conteneur devient un groupe de focus : la navigation vers/depuis la section est une décision, la navigation au sein de la section en est une autre3. Notez que .focusSection() est exclusif à tvOS et macOS ; il n’a aucun effet sur iOS, iPadOS, watchOS ou visionOS.
HStack {
VStack {
Button("Settings") { ... }
Button("Profile") { ... }
Button("Logout") { ... }
}
.focusSection()
VStack {
ContentList(items: items)
}
.focusSection()
}
Les deux VStack deviennent navigables comme des unités. L’utilisateur balaie vers la droite depuis la barre latérale pour atterrir dans la zone de contenu ; une fois là, le moteur gère la navigation interne à la zone. Sans .focusSection(), les balayages depuis un bouton de la barre latérale pourraient atterrir sur un élément de contenu arbitraire qui se trouve être le plus proche géométriquement, produisant une UX qui semble aléatoire.
Le bon modèle : chaque région d’interface utilisateur ayant une structure de focus interne (barres latérales, grilles de cartes, barres d’onglets, contrôles de pagination) reçoit un modificateur .focusSection() sur son conteneur. Le moteur navigue alors entre les sections au niveau macro et au sein des sections au niveau micro.
.prefersDefaultFocus(_:in:) pour le focus initial
Lorsqu’un écran apparaît ou qu’une popover s’ouvre, quelque chose doit recevoir le focus initial. Sans guidance explicite, le moteur choisit la première vue focalisable dans la disposition, ce qui est souvent erroné (le bouton retour au lieu de l’action principale, une cellule de liste obscure au lieu du bouton play)4.
struct MovieDetailView: View {
let movie: Movie
@Namespace private var detailNamespace
var body: some View {
VStack {
HStack {
Button("Back") { ... }
Spacer()
}
PosterImage(movie: movie)
Button("Play") { ... }
.prefersDefaultFocus(in: detailNamespace)
Button("Add to Watchlist") { ... }
}
.focusScope(detailNamespace)
}
}
Le couple @Namespace et .focusScope() définit la limite du focus, et .prefersDefaultFocus(in:) déclare le focus initial préféré au sein de cette portée. Lorsque l’écran apparaît, le focus atterrit sur Play.
Ce modèle convient à toute vue dans laquelle l’utilisateur entre avec une attente évidente de « quoi faire en premier » : Play sur une page de détails de film, Sign In sur un écran de connexion, Get Started sur un écran d’onboarding.
Effets de focus personnalisés (et quand désactiver le défaut)
L’effet de focus système est la lueur aux contours doux qui s’agrandit autour d’une vue focalisée. Il met légèrement à l’échelle la vue, ajoute une ombre subtile et s’anime avec le timing standard de la plateforme. Pour la plupart des applications, le défaut est correct ; il s’aligne avec toutes les autres applications tvOS et permet aux utilisateurs d’apprendre le vocabulaire de la plateforme.
Pour les applications nécessitant un visuel de focus personnalisé (une lueur spécifique à la marque, un effet sensible au contenu, un anneau de focus qui entre en conflit avec le défaut), .focusEffectDisabled() permet de se retirer du traitement système6 :
Button {
play(movie)
} label: {
PosterImage(movie: movie)
.overlay(focusBorder)
.scaleEffect(isFocused ? 1.05 : 1.0)
}
.focusEffectDisabled()
.focused($isFocused)
La vue personnalisée est responsable de l’indication visuelle du focus ; le système n’interfère plus. Le compromis : chaque visuel de focus doit être conçu et implémenté par l’application plutôt qu’hérité. Pour la plupart des applications, l’effet système est le bon choix.
Échecs de focus tvOS courants
Trois modèles produisent une mauvaise UX tvOS :
Boutons qui n’acceptent pas le focus. Un bouton personnalisé rendu en tant que HStack { Image; Text } sans .focusable() est invisible pour le moteur. Les balayages de la Siri Remote le sautent. Solution : enveloppez le contenu interactif dans Button (qui fournit la participation au focus par défaut) ou appliquez .focusable() explicitement.
Pièges à focus. Une vue qui accepte le focus mais ne fournit aucune voie de sortie (aucun frère gauche/droite/haut/bas focalisable, aucune échappatoire via le bouton Menu) laisse l’utilisateur coincé. Solution : chaque contexte de focus doit avoir un chemin de sortie documenté. Le modèle .focusSection() aide car il donne au moteur une unité vers laquelle s’échapper.
Focus par défaut sur le mauvais élément. Un écran de détails de film qui s’ouvre avec le focus sur Back au lieu de Play est une friction que l’utilisateur paie à chaque visite. Solution : déclarez .prefersDefaultFocus(in:) sur l’action principale.
Effets de focus personnalisés non accessibles. Un anneau de focus qui n’est qu’une bordure de couleur de 1pt à faible contraste échoue en accessibilité. L’effet de focus système est à fort contraste et testé pour le mouvement ; les remplacements personnalisés nécessitent le même soin. L’article du cluster Accessibility as platform couvre le principe plus large.
Quand tvOS mérite sa place
L’article du cluster Apple Platform Matrix soutenait que tvOS est la plateforme avec la plus petite base installée par rapport à iOS, et les applications ont besoin d’un véritable cas d’usage « lean back » ou « mode canapé » pour justifier l’investissement d’ingénierie. Le moteur de focus fait partie de cet investissement : une application tvOS qui n’honore pas le vocabulaire du focus ressemble à une application iPad étirée sur un téléviseur. L’investissement est réel parce que la surface API est réelle ; le travail d’ingénierie est significatif parce que le moteur décide effectivement où va le focus.
Les applications qui méritent leur place sur tvOS partagent généralement trois propriétés : 1. Contenu consommé à distance de visionnage TV. Streaming, diaporamas photo, jeux pilotés par manette. 2. Modèle d’interaction parcimonieux. Quelques actions principales par écran, navigation par entrée directionnelle. 3. Cas d’usage lean-back. L’utilisateur est sur un canapé, peut-être en multitâche avec un autre appareil, peut-être en regardant à moitié.
Pour les applications de ces catégories, l’investissement dans le moteur de focus est juste. Pour les applications qui ne correspondent pas (outils de productivité, applications créatives à grain fin, tout ce qui nécessite beaucoup de saisie de texte), la bonne décision est d’éviter tvOS, comme le recommande l’article matrix.
Ce que ce modèle signifie pour les applications tvOS
Trois enseignements.
-
Intégrez l’intention de focus dans la disposition, pas dans une correction a posteriori. Où l’utilisateur va-t-il commencer ? Où peut-il aller ensuite ? Quelle est l’action principale ? Concevoir un écran sur tvOS commence par le flux de focus, pas par la composition visuelle. Le visuel suit.
-
Utilisez
.focusSection()agressivement pour toute région à structure interne. La navigation géométrique par défaut est souvent erronée pour les grilles, les barres latérales, les barres d’onglets. Le modificateur de section est petit et la différence est grande. -
Conservez l’effet de focus système sauf si vous avez une vraie raison de le remplacer. Les visuels de focus personnalisés représentent un vrai travail d’ingénierie plus un travail d’accessibilité plus des tests sur tous les thèmes. L’effet système est le bon défaut ; recourez à
.focusEffectDisabled()uniquement lorsque la conception nécessite véritablement un traitement personnalisé.
Le cluster Apple Ecosystem complet : les App Intents typés ; les serveurs MCP ; la question de routage ; les Foundation Models ; la distinction LLM runtime vs outillage ; les trois surfaces ; le modèle de source unique de vérité ; Two MCP Servers ; les hooks pour le développement Apple ; les Live Activities ; le contrat d’exécution watchOS ; les internes de SwiftUI ; le modèle mental spatial de RealityKit ; la discipline de schéma SwiftData ; les modèles Liquid Glass ; la livraison multi-plateforme ; la matrice de plateformes ; le framework Vision ; les Symbol Effects ; l’inférence Core ML ; Writing Tools API ; Swift Testing ; Privacy Manifest ; Accessibility as platform ; la typographie SF Pro ; les modèles spatiaux visionOS ; le framework Speech ; les migrations SwiftData ; ce que je refuse d’écrire. Le hub se trouve à la Série Apple Ecosystem. Pour un contexte iOS-avec-agents-IA plus large, consultez le guide iOS Agent Development.
FAQ
.focusable() fonctionne-t-il sur iOS ?
Oui, mais son comportement sur les cibles iOS concerne les interactions clavier et pointeur (clavier Bluetooth, pointeur iPadOS, Magic Keyboard iPad), et non la navigation pilotée par le moteur de focus que tvOS utilise. Le même code peut être utilisé en multi-plateforme ; l’interaction côté utilisateur diffère. Sur tvOS, .focusable() est la voie principale. Sur iOS, c’est un complément pour l’accessibilité.
Quelle est la différence entre .focusable() et Button ?
Button est une construction de plus haut niveau qui inclut la focalisabilité, la gestion des actions, le style de bouton système et les traits d’accessibilité. .focusable() est le marqueur bas niveau qui rend simplement une vue cible de focus. Utilisez Button lorsque la vue est logiquement un bouton ; utilisez .focusable() lorsque vous construisez une vue interactive personnalisée (une carte d’affiche, une tuile dans une grille) qui ne correspond pas au modèle mental du bouton.
Puis-je avoir plusieurs déclarations .prefersDefaultFocus ?
Oui, délimitées par @Namespace. Chaque portée de focus peut avoir son propre défaut préféré. Le modèle convient aux contextes imbriqués (une popover dans un écran, un onglet dans une barre latérale) : chaque portée choisit son propre focus initial.
Comment gérer le focus dans une liste comportant de nombreux éléments ?
Les listes en SwiftUI sont focalisables par défaut ; le moteur gère automatiquement la navigation haut/bas à travers les cellules. Pour les dispositions personnalisées de type liste, enveloppez chaque cellule dans un Button ou appliquez .focusable(), puis placez la liste entière dans une .focusSection() afin que le moteur traite la liste comme une unité par rapport aux autres régions de l’interface utilisateur.
Que fait le bouton Menu dans le modèle de focus ?
Le bouton Menu de la Siri Remote est l’action de rejet/retour à travers tvOS. Il dépile la pile de navigation, sort des modaux, retourne au contexte parent. SwiftUI le gère automatiquement via NavigationStack et le rejet modal standard ; les applications ne l’interceptent généralement pas. Pour une logique de rejet personnalisée, le modificateur de vue onExitCommand capture l’appui.
Comment cela se rapporte-t-il aux autres articles de plateforme du cluster ?
Le moteur de focus tvOS est la surface de navigation spécifique à la plateforme, parallèle au regard-et-pincement de visionOS (couvert dans visionOS spatial patterns) et au tap-and-scroll d’iOS. Chaque plateforme a sa propre métaphore d’entrée ; l’article du cluster Apple Platform Matrix soutient que l’inclusion d’une plateforme exige d’honorer cette métaphore, et le moteur de focus est ce que tvOS exige.
Références
-
Apple Developer : App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote. Le modèle du moteur de focus et les règles de résolution géométrique. ↩↩↩
-
Documentation Apple Developer :
@FocusState. Le property wrapper pour suivre et diriger programmatiquement le focus à travers les plateformes SwiftUI. ↩↩ -
Documentation Apple Developer :
focusSection(). Le modificateur de vue qui regroupe les descendants focalisables en une seule cible de focus pour la navigation inter-sections. ↩↩ -
Documentation Apple Developer :
prefersDefaultFocus(_:in:)etfocusScope(_:). La déclaration de focus par défaut associée aux limites de focus délimitées par espace de noms. ↩↩ -
Documentation Apple Developer :
focusable(_:). Le modificateur de vue marquant une vue comme cible de focus avec un booléen conditionnel optionnel. ↩ -
Documentation Apple Developer :
focusEffectDisabled(_:). L’opt-out pour l’effet de focus système (Bool par défauttrue) ; à associer à des visuels de focus personnalisés au besoin. ↩