Protocole Layout de SwiftUI : créer des mises en page personnalisées de sizeThatFits à placeSubviews
iOS 16 a ajouté le protocole Layout à SwiftUI, l’API publique pour créer des vues conteneurs personnalisées qui participent à la passe de mise en page de SwiftUI1. Avant Layout, les formes de conteneurs personnalisés exigeaient soit des bricolages avec GeometryReader (qui rompent la composition parce qu’ils demandent toute la taille proposée), soit du travail sur ViewModifier personnalisé qui combat le système. Layout est la bonne réponse : un protocole à deux méthodes (sizeThatFits et placeSubviews) plus des extensions facultatives d’espacement et de mise en cache, avec un contrat qui s’intègre proprement au modèle de mise en page parent-propose-enfant-dispose de SwiftUI.
L’article parcourt le protocole en s’appuyant sur la documentation d’Apple. Le cadrage est « ce sur quoi Layout contracte réellement », parce que le motif de mauvais usage (traiter Layout comme un outil d’espace de coordonnées plutôt que comme un outil de négociation de taille) produit des mises en page qui fonctionnent sur un écran et échouent sur un autre, et l’article du cluster What SwiftUI Is Made Of soutenait que l’architecture de SwiftUI se comprend mieux en lisant ses protocoles publics.
TL;DR
Layoutest un protocole avec deux méthodes requises :sizeThatFits(proposal:subviews:cache:)renvoie la taille préférée de la mise en page compte tenu de la proposition du parent ;placeSubviews(in:proposal:subviews:cache:)positionne chaque enfant en appelant sa méthodeplace(at:anchor:proposal:)2.- Le paramètre
proposalest unProposedViewSizeavecwidthetheightcomme CGFloats optionnels.nilsignifie « utilisez votre taille idéale » ; une valeur finie est l’offre du parent ;.infinitysignifie « utilisez autant que vous voulez ». Subviewsest un alias de type pourLayoutSubviews, une collection de proxiesLayoutSubview. Chaque proxy peut être interrogé pour sa taille étant donné toute proposition et placé en tout point. Les proxies sont la seule façon dont Layout interagit avec les enfants.- Les valeurs de mise en page personnalisées circulent des enfants vers le parent via les types
LayoutValueKeyattachés aux vues enfants par.layoutValue(...), lisibles depuis les indicesLayoutSubviewà l’intérieur des méthodes de mise en page. - Le
cachesert à amortir les calculs entresizeThatFitsetplaceSubviews(chaque passe appelle les deux, souvent avec les mêmes valeurs intermédiaires). Typez le cache comme une struct qui contient les tailles précalculées ; construisez-le une fois, réutilisez-le dans les deux méthodes.
Le contrat du protocole
Un Layout est une struct (généralement) qui déclare deux méthodes appelées par le framework d’Apple pendant la passe de mise en page2 :
struct DiagonalLayout: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// Compute and return the size your layout wants
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// Position each subview by calling subview.place(...)
}
}
Utilisez-le comme un conteneur intégré :
DiagonalLayout {
Text("First")
Text("Second")
Text("Third")
}
Le framework appelle sizeThatFits avec la taille proposée par le parent (un ProposedViewSize), puis appelle placeSubviews avec les limites accordées à la mise en page. Les deux méthodes ensemble décrivent le comportement de la mise en page : quelle taille elle veut avoir, et où chaque enfant se place dans cette allocation.
ProposedViewSize : l’offre du parent
La mise en page dans SwiftUI suit un contrat parent-propose-enfant-dispose3. Le parent transmet une taille proposée ; l’enfant renvoie sa taille réelle ; le parent positionne l’enfant dans ses propres limites. Layout participe à ce contrat via ProposedViewSize :
struct ProposedViewSize {
var width: CGFloat?
var height: CGFloat?
}
Les axes optionnels portent une signification sémantique :
nilpour un axe signifie « utilisez votre taille idéale/naturelle ». UnTextà qui l’on propose.zerorenvoie sa largeur minimale (un caractère par ligne) ; à qui l’on proposenil, il renvoie sa largeur idéale (une ligne, sans retour).- Une valeur finie signifie « le parent offre cet espace ; vous décidez quoi en faire ». Un
Textà qui l’on propose une largeur de 100pt peut effectuer un retour à la ligne, peut en utiliser moins, peut en utiliser exactement 100. .infinitysignifie « utilisez autant que vous voulez ». UneColorà qui l’on propose.infinityprend tout l’espace disponible.
La convention ProposedViewSize.unspecified (width: nil, height: nil) est la demande de taille idéale ; ProposedViewSize.zero est la demande de taille minimale ; ProposedViewSize.infinity est la demande d’expansion gourmande.
Le sizeThatFits d’un Layout personnalisé doit respecter la proposition : renvoyer une taille que la mise en page veut réellement pour les limites proposées, pas toujours la même valeur codée en dur. Les tailles codées en dur cassent la capacité de la mise en page à s’adapter à différents conteneurs (une vue de carte, une cellule de liste, une feuille).
Lecture des tailles des sous-vues via LayoutSubview
À l’intérieur de sizeThatFits, la mise en page demande à chaque enfant quelle taille il souhaite pour diverses propositions. La requête passe par le proxy LayoutSubview4 :
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let proposed = ProposedViewSize(
width: proposal.width.map { $0 / CGFloat(subviews.count) },
height: proposal.height
)
let sizes = subviews.map { $0.sizeThatFits(proposed) }
let totalWidth = sizes.reduce(0) { $0 + $1.width }
let maxHeight = sizes.map(\.height).max() ?? 0
return CGSize(width: totalWidth, height: maxHeight)
}
Le motif subviews.map { $0.sizeThatFits(proposal) } est la façon dont une mise en page découvre les tailles que ses enfants souhaitent. La méthode sizeThatFits(_:) du proxy LayoutSubview n’est pas la même que la méthode du protocole Layout ; c’est l’interrogation par le proxy de la taille préférée de l’enfant pour une proposition donnée. Les deux partagent un nom parce qu’elles participent à la même négociation, mais ce sont des couches différentes du contrat.
Une mise en page qui veut connaître les tailles des enfants appelle proxy.sizeThatFits(_:). Une mise en page qui veut positionner les enfants appelle proxy.place(at:anchor:proposal:) à l’intérieur de placeSubviews.
Placement des sous-vues
placeSubviews est l’endroit où la mise en page prend les décisions de positionnement2 :
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
let y = bounds.midY
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
subview.place(
at: CGPoint(x: x + size.width / 2, y: y),
anchor: .center,
proposal: ProposedViewSize(size)
)
x += size.width
}
}
L’appel place(at:anchor:proposal:) positionne une sous-vue unique. Trois paramètres :
at: la position dans l’espace de coordonnées du parent.anchor: quel point de la sous-vue se trouve àat..centerplace le centre de la sous-vue àat;.topLeadingy place le coin supérieur gauche.proposal: la taille à laquelle la sous-vue doit être rendue. Passez la taille renvoyée par lesizeThatFitsde la même sous-vue pour honorer sa préférence, ou passez une proposition personnalisée pour la contraindre.
Chaque sous-vue doit être placée exactement une fois par appel à placeSubviews. Sauter une sous-vue la laisse non positionnée (elle disparaît de la mise en page rendue) ; en placer une deux fois est une erreur d’exécution.
Valeurs de mise en page personnalisées via LayoutValueKey
Quand un enfant a besoin de communiquer quelque chose à sa mise en page parente (une priorité, une étendue, une catégorie), le canal est LayoutValueKey5 :
struct PriorityKey: LayoutValueKey {
static let defaultValue: Int = 0
}
extension View {
func layoutPriority(_ value: Int) -> some View {
layoutValue(key: PriorityKey.self, value: value)
}
}
// Inside the Layout:
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let sortedSubviews = subviews.sorted {
$0[PriorityKey.self] > $1[PriorityKey.self]
}
// ... place sortedSubviews
}
Le protocole LayoutValueKey fournit un canal typé pour la communication parent-enfant. L’enfant attache une valeur via le modificateur de valeur de mise en page ; le parent la lit via l’indice LayoutSubview. Chaque clé a une valeur par défaut pour les sous-vues qui n’en spécifient pas une explicitement.
Ce motif est conceptuellement ce qu’expriment les modificateurs intégrés comme .layoutPriority(_:). Le framework expose cette valeur spécifique via une propriété dédiée priority: Double sur LayoutSubview plutôt que via un LayoutValueKey public, donc l’accès via le proxy pour la priorité de mise en page est subview.priority plutôt qu’un indice de clé. Les mises en page personnalisées déclarent leurs propres types LayoutValueKey pour toute autre donnée structurée dont elles ont besoin de la part des enfants.
Le paramètre cache
Les deux méthodes de mise en page reçoivent un paramètre cache: inout. Le cache est l’endroit où la mise en page peut amortir le travail entre sizeThatFits et placeSubviews6 :
struct DiagonalLayout: Layout {
struct Cache {
var sizes: [CGSize]
}
func makeCache(subviews: Subviews) -> Cache {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
return Cache(sizes: sizes)
}
func updateCache(_ cache: inout Cache, subviews: Subviews) {
cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let totalWidth = cache.sizes.reduce(0) { $0 + $1.width }
let totalHeight = cache.sizes.reduce(0) { $0 + $1.height }
return CGSize(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
var x = bounds.minX
var y = bounds.minY
for (subview, size) in zip(subviews, cache.sizes) {
subview.place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(size)
)
x += size.width
y += size.height
}
}
}
Le type cache par défaut est Void. La plupart des mises en page peuvent ignorer le cache ; il gagne sa place quand le calcul de taille est réellement coûteux (mesures récursives, décisions de dimensionnement dynamique) et que les mêmes intermédiaires alimentent les deux méthodes de mise en page.
makeCache(subviews:) s’exécute une fois par passe de mise en page ; updateCache(_:subviews:) s’exécute quand les sous-vues changent entre les passes. Ce motif permet à la mise en page d’invalider correctement l’état mis en cache quand les enfants eux-mêmes changent.
Mises en page personnalisées courantes qui valent la peine d’être construites
Trois motifs qui valent la peine d’être construits soi-même :
Mise en page de flux (éléments en retour à la ligne). Les éléments passent à plusieurs lignes quand ils débordent de la largeur disponible. Le HStack d’Apple ne fait pas de retour à la ligne. Une Layout personnalisée le peut : mesurer chaque enfant, placer de gauche à droite, passer à la ligne suivante quand la largeur de la ligne dépasse la largeur de la proposition.
Pile diagonale. Les éléments s’échelonnent en diagonale (chaque enfant positionné légèrement en bas à droite du précédent). Utile pour les UI de cartes empilées, les mises en page d’aperçus de galerie, les piles donnant un sentiment de parallaxe.
Mise en page en tarte/cercle. Les éléments arrangés autour de la circonférence d’un cercle. Utile pour les menus radiaux, les UI temporelles, les étiquettes catégorielles à espacement égal.
Chacune de celles-ci est implémentable avec sizeThatFits + placeSubviews + (optionnellement) un cache personnalisé. Le framework gère la négociation parent-propose-enfant-dispose ; le développeur gère les calculs de placement.
Échecs courants de mise en page
Trois motifs qui produisent des mises en page personnalisées cassées :
Tailles codées en dur qui ignorent la proposition. Une mise en page qui renvoie toujours CGSize(width: 200, height: 100) ne s’adapte pas à son conteneur. Le résultat : la mise en page semble correcte dans le simulateur mais se casse sur des écrans plus petits, dans différentes orientations, ou à l’intérieur de conteneurs redimensionnables.
Sauter des sous-vues dans placeSubviews. Chaque sous-vue doit être placée exactement une fois par appel. Une boucle for qui a un continue pour certaines conditions laisse ces sous-vues non positionnées ; elles disparaissent de la sortie rendue.
Utiliser GeometryReader à l’intérieur des enfants d’une mise en page personnalisée. GeometryReader propose toujours tout l’espace reçu à son contenu, ce qui combat les propositions par enfant de la mise en page. La combinaison produit des tailles absurdes. Les mises en page personnalisées ne devraient pas mettre GeometryReader à l’intérieur d’elles-mêmes ; si un enfant a besoin de connaître sa taille allouée, le mécanisme de proposition du protocole de mise en page est le bon canal.
Quand recourir à Layout (et quand ne pas le faire)
Trois signaux indiquant qu’une Layout personnalisée est le bon outil :
- La forme n’est pas exprimable avec une composition de HStack/VStack/ZStack/Grid. Mises en page en tarte, grilles en maçonnerie, retour à la ligne de flux personnalisé. Les primitives intégrées ne peuvent pas se composer en ces formes.
- L’information par enfant pilote le positionnement. Mises en page où les enfants ont des priorités, des poids ou des catégories que le parent utilise pour les positionner.
LayoutValueKeyest le bon canal. - Le dimensionnement de la mise en page dépend d’une négociation avec les enfants. Les mises en page qui demandent « quelle est la plus petite hauteur qui contient la ligne la plus longue ? » ou « quelle largeur donne des colonnes égales à N enfants ? » ont besoin d’accéder aux requêtes
subviews.sizeThatFits(...).
Trois signaux indiquant que la composition intégrée suffit :
- Empilement standard horizontal/vertical/en profondeur.
HStack,VStack,ZStackcouvrent les cas courants. - Grille avec des lignes/colonnes régulières.
GridetLazyVGrid/LazyHGridgèrent la plupart des cas de grille. - Un peu de positionnement par superposition.
.overlay,.background,ZStackavec alignement couvrent la plupart des motifs « X au-dessus de Y ».
La règle empirique : ne construisez pas de Layout personnalisée pour une forme que les primitives intégrées gèrent. Construisez-en une quand la forme est réellement au-delà de l’ensemble d’expression des primitives intégrées.
Ce que signifie ce motif pour les applications iOS 26+
Trois enseignements.
-
Honorez la proposition dans
sizeThatFits. Une mise en page qui renvoie la même taille indépendamment deproposalne participe pas correctement au système de mise en page de SwiftUI. Lisez la proposition, renvoyez une taille appropriée à celle-ci. -
Utilisez
LayoutValueKeypour la communication parent-enfant structurée. Passer des données via des clés attachées par modificateur de vue est le motif natif de SwiftUI. Ne recourez pas à@Environmentou à desPreferenceKeypersonnalisés pour des données spécifiquement liées aux décisions au niveau de la mise en page ;LayoutValueKeyest le canal typé pour cela. -
Construisez un cache uniquement quand la mesure est coûteuse. Le cache
Voidpar défaut convient à la plupart des mises en page. Recourez à un type de cache personnalisé seulement quand le même calcul coûteux apparaît à la fois danssizeThatFitsetplaceSubviews.
Le cluster Apple Ecosystem complet : les App Intents typés ; les serveurs MCP ; la question du routage ; Foundation Models ; la distinction LLM runtime vs outillage ; les trois surfaces ; le motif source unique de vérité ; Two MCP Servers ; les hooks pour le développement Apple ; les Live Activities ; le runtime watchOS ; les internes de SwiftUI ; le modèle mental spatial de RealityKit ; la discipline de schéma SwiftData ; les motifs Liquid Glass ; la livraison multi-plateformes ; la matrice des plateformes ; le framework Vision ; les Symbol Effects ; l’inférence Core ML ; l’API Writing Tools ; Swift Testing ; le Privacy Manifest ; l’accessibilité comme plateforme ; la typographie SF Pro ; les motifs spatiaux visionOS ; le framework Speech ; les migrations SwiftData ; le moteur de focus tvOS ; les internes de @Observable ; ce que je refuse d’écrire. Le hub se trouve à la Apple Ecosystem Series. Pour un contexte plus large iOS-avec-agents-IA, consultez le guide iOS Agent Development.
FAQ
Pourquoi ne pas simplement utiliser GeometryReader ?
GeometryReader propose toujours toute la taille reçue à son contenu (il n’a pas d’opinion sur ce que veut son contenu). Le résultat est que toute vue à l’intérieur d’un GeometryReader reçoit infinity proposé pour les axes que le reader ne contraint pas, et les vues comme Text se dimensionnent gourmandement. La composition se combat elle-même : le reader laisse passer sans changement, le contenu demande la taille maximale, la mise en page se casse. Layout est le bon outil parce qu’il permet au développeur de prendre des décisions explicites par enfant sur la taille proposée.
Puis-je écrire un remplacement personnalisé de HStack ?
Oui. Une Layout personnalisée équivalente à HStack lit les tailles préférées des enfants, somme leurs largeurs, prend la hauteur maximale et les place de gauche à droite. Le véritable HStack fait davantage (espacement, alignement, résolution de priorité de mise en page), mais la forme de base est directe en Layout. L’exercice est un moyen utile d’intérioriser le fonctionnement du protocole.
Comment prendre en charge .layoutPriority(_:) dans ma mise en page personnalisée ?
Lisez-la via la propriété dédiée priority: Double du proxy LayoutSubview : subview.priority. SwiftUI expose .layoutPriority(_:) directement sur le proxy plutôt que via un LayoutValueKey public. La valeur par défaut est 0. Utilisez la priorité lors de la distribution d’espace supplémentaire (donnez-le préférentiellement aux enfants à haute priorité) ou lors de la troncature (tronquez d’abord les enfants à basse priorité).
Quelle est la différence entre proposal: .infinity et proposal: .zero ?
.infinity propose la taille maximale sur chaque axe (width: .infinity, height: .infinity). Les enfants qui répondent aux propositions gourmandes (comme Color) prennent tout l’espace disponible. .zero propose la taille minimale (width: 0, height: 0). Les enfants renvoient leur taille minimale (Text renvoie la taille de son plus long jeton insécable). Les deux sont des points utiles pour mesurer la plage de dimensionnement des enfants ; de nombreuses mises en page utilisent .unspecified (les deux à nil) pour demander « quelle est votre taille idéale ? ».
Layout fonctionne-t-il sur watchOS, tvOS et visionOS ?
Oui. Le protocole Layout se trouve dans le cœur multiplateforme de SwiftUI. Les mises en page personnalisées fonctionnent de la même manière sur iOS, iPadOS, macOS, watchOS, tvOS et visionOS. L’article du cluster Apple Platform Matrix soutient que l’inclusion d’une plateforme est une décision produit ; le mécanisme Layout de SwiftUI est agnostique à la plateforme pour les cas où plusieurs plateformes s’appliquent.
Comment Layout interagit-il avec les modèles @Observable ?
Layout est une struct qui ne détient directement aucun état observable ; elle ne suit pas les changements. Quand un modèle se met à jour, le body de la vue parente est réévalué, ce qui fait que Layout se réexécute avec quels que soient les enfants que le body produit. La Layout est réactive via le body dans lequel elle vit, et non via ses propres hooks d’observation. L’article du cluster @Observable internals couvre le côté observation.
Références
-
Apple Developer Documentation:
Layout. The protocol reference coveringsizeThatFitsandplaceSubviewsrequirements, plus the optionalmakeCache,updateCache,spacing, and explicit-alignment hooks. ↩ -
Apple Developer Documentation:
sizeThatFits(proposal:subviews:cache:)andplaceSubviews(in:proposal:subviews:cache:). The two required methods of theLayoutprotocol. ↩↩↩ -
Apple Developer Documentation:
ProposedViewSize. The two-optional-CGFloat type that carries the parent’s size proposal, with the convention values.unspecified,.zero, and.infinity. ↩ -
Apple Developer Documentation:
LayoutSubview. The proxy type representing a child view insideLayoutmethods, withsizeThatFits(_:)for querying preferred sizes andplace(at:anchor:proposal:)for positioning. ↩ -
Apple Developer Documentation:
LayoutValueKeyandlayoutValue(key:value:). The typed channel for child-to-parent layout-level data, accessed via subscript onLayoutSubview. ↩ -
Apple Developer: Composing custom layouts with SwiftUI. The Apple guide covering caching, alignment guides, and when to reach for
Layoutversus built-in containers. ↩