SwiftUI Layout-Protokoll: Eigene Layouts mit sizeThatFits und placeSubviews entwickeln
iOS 16 hat das Layout-Protokoll zu SwiftUI hinzugefügt – die öffentliche API zum Erstellen eigener Container-Views, die am Layout-Pass von SwiftUI teilnehmen1. Vor Layout erforderten eigene Container-Formen entweder GeometryReader-Tricks (die die Komposition brechen, weil sie die volle vorgeschlagene Größe anfordern) oder eigene ViewModifier-Konstruktionen, die gegen das System ankämpfen. Layout ist die richtige Antwort: ein Protokoll mit zwei Methoden (sizeThatFits und placeSubviews) plus optionale Erweiterungen für Abstände und Caching, mit einem Vertrag, der sich sauber in das Parent-proposes-Child-disposes-Layout-Modell von SwiftUI einfügt.
Der Beitrag arbeitet das Protokoll anhand der Apple-Dokumentation durch. Der Rahmen lautet: „Worauf legt sich Layout tatsächlich vertraglich fest?” Denn das Fehlanwendungsmuster (Layout als Werkzeug für Koordinatenräume statt für Größenverhandlung zu behandeln) erzeugt Layouts, die auf einem Bildschirm funktionieren und auf einem anderen versagen. Der Beitrag What SwiftUI Is Made Of im Cluster argumentierte, dass die Architektur von SwiftUI am besten durch das Lesen seiner öffentlichen Protokolle zu verstehen ist.
TL;DR
Layoutist ein Protokoll mit zwei erforderlichen Methoden:sizeThatFits(proposal:subviews:cache:)gibt die bevorzugte Größe des Layouts angesichts des Vorschlags des Parent zurück;placeSubviews(in:proposal:subviews:cache:)positioniert jedes Kind, indem es dessenplace(at:anchor:proposal:)-Methode aufruft2.- Der Parameter
proposalist einProposedViewSizemitwidthundheightals optionalen CGFloats.nilbedeutet „verwende deine ideale Größe”; ein endlicher Wert ist das Angebot des Parent;.infinitybedeutet „verwende so viel, wie du möchtest”. Subviewsist ein Typealias fürLayoutSubviews, eine Sammlung vonLayoutSubview-Proxies. Jeder Proxy kann nach seiner Größe für einen beliebigen Vorschlag abgefragt und an einem beliebigen Punkt platziert werden. Die Proxies sind die einzige Möglichkeit, wie Layout mit Kindern interagiert.- Eigene Layout-Werte fließen von Kindern zum Parent durch
LayoutValueKey-Typen, die über.layoutValue(...)an Kind-Views angehängt werden, lesbar ausLayoutSubview-Subscripts innerhalb der Layout-Methoden. - Der
cachedient dazu, Berechnungen zwischensizeThatFitsundplaceSubviewszu amortisieren (jeder Pass ruft beide auf, oft mit denselben Zwischenwerten). Typisieren Sie den Cache als Struct, das die vorausberechneten Größen hält; bauen Sie ihn einmal auf und verwenden Sie ihn in beiden Methoden wieder.
Der Protokoll-Vertrag
Ein Layout ist (typischerweise) ein Struct, das zwei Methoden deklariert, die Apples Framework während des Layout-Pass aufruft2:
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(...)
}
}
Verwenden Sie es wie einen integrierten Container:
DiagonalLayout {
Text("First")
Text("Second")
Text("Third")
}
Das Framework ruft sizeThatFits mit der vom Parent vorgeschlagenen Größe (einem ProposedViewSize) auf und ruft dann placeSubviews mit den Bounds auf, die dem Layout zugewiesen wurden. Die beiden Methoden zusammen beschreiben das Verhalten des Layouts: wie groß es sein möchte und wo jedes Kind innerhalb dieser Zuweisung landet.
ProposedViewSize: Das Angebot des Parent
Das Layout in SwiftUI folgt einem Parent-proposes-Child-disposes-Vertrag3. Der Parent übergibt eine vorgeschlagene Größe; das Kind gibt seine tatsächliche Größe zurück; der Parent positioniert das Kind innerhalb seiner eigenen Bounds. Layout nimmt an diesem Vertrag über ProposedViewSize teil:
struct ProposedViewSize {
var width: CGFloat?
var height: CGFloat?
}
Die optionalen Achsen tragen semantische Bedeutung:
nilfür eine Achse bedeutet „verwende deine ideale/natürliche Größe”. EinText, dem.zerovorgeschlagen wird, gibt seine Mindestbreite zurück (ein Zeichen pro Zeile); bei vorgeschlagenemnilgibt er seine ideale Breite zurück (eine Zeile, kein Umbruch).- Ein endlicher Wert bedeutet „der Parent bietet so viel Platz an; du entscheidest, was du tust”. Ein
Textmit vorgeschlagener Breite von 100pt kann umbrechen, kann weniger verwenden, kann genau 100 verwenden. .infinitybedeutet „verwende so viel, wie du möchtest”. EineColormit vorgeschlagenem.infinitynimmt den vollen verfügbaren Platz ein.
Die Konvention ProposedViewSize.unspecified (width: nil, height: nil) ist die Anforderung der idealen Größe; ProposedViewSize.zero ist die Anforderung der minimalen Größe; ProposedViewSize.infinity ist die Anforderung nach gieriger Ausdehnung.
Das sizeThatFits eines eigenen Layout sollte den Vorschlag respektieren: Geben Sie eine Größe zurück, die das Layout für die vorgeschlagenen Bounds tatsächlich möchte, nicht immer denselben fest codierten Wert. Fest codierte Größen brechen die Fähigkeit des Layouts, sich an verschiedene Container anzupassen (eine Card-View, eine List-Cell, ein Sheet).
Subview-Größen über LayoutSubview lesen
Innerhalb von sizeThatFits fragt das Layout jedes Kind, welche Größe es für verschiedene Vorschläge möchte. Die Abfrage geht über den LayoutSubview-Proxy4:
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)
}
Das Muster subviews.map { $0.sizeThatFits(proposal) } ist die Art, wie ein Layout entdeckt, welche Größen seine Kinder möchten. Die sizeThatFits(_:)-Methode des LayoutSubview-Proxy ist nicht dieselbe wie die Layout-Protokollmethode; sie ist die Abfrage des Proxy nach der bevorzugten Größe des Kindes für einen Vorschlag. Beide teilen einen Namen, weil sie an derselben Verhandlung teilnehmen, aber sie sind unterschiedliche Schichten des Vertrags.
Ein Layout, das die Größen der Kinder kennen möchte, ruft proxy.sizeThatFits(_:) auf. Ein Layout, das Kinder positionieren möchte, ruft innerhalb von placeSubviews proxy.place(at:anchor:proposal:) auf.
Subviews platzieren
placeSubviews ist der Ort, an dem das Layout Positionierungsentscheidungen trifft2:
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
}
}
Der place(at:anchor:proposal:)-Aufruf positioniert eine einzelne Subview. Drei Parameter:
at: die Position im Koordinatenraum des Parent.anchor: welcher Punkt der Subview sich beiatbefindet..centersetzt das Zentrum der Subview anat;.topLeadingsetzt die linke obere Ecke dorthin.proposal: die Größe, in der die Subview gerendert werden soll. Übergeben Sie die vonsizeThatFitsderselben Subview zurückgegebene Größe, um ihre Präferenz zu respektieren, oder übergeben Sie einen eigenen Vorschlag, um sie einzuschränken.
Jede Subview muss pro placeSubviews-Aufruf genau einmal platziert werden. Das Überspringen einer Subview lässt sie unpositioniert (sie verschwindet aus dem gerenderten Layout); eine doppelt zu platzieren ist ein Laufzeitfehler.
Eigene Layout-Werte über LayoutValueKey
Wenn ein Kind seinem übergeordneten Layout etwas mitteilen muss (eine Priorität, eine Spannweite, eine Kategorie), ist der Kanal 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
}
Das LayoutValueKey-Protokoll bietet einen typisierten Kanal für die Parent-Child-Kommunikation. Das Kind hängt einen Wert über den Layout-Wert-Modifier an; der Parent liest ihn über das Subscript von LayoutSubview. Jeder Schlüssel hat einen Standardwert für Subviews, die keinen explizit angeben.
Das Muster ist konzeptionell das, was integrierte Modifier wie .layoutPriority(_:) ausdrücken. Das Framework legt diesen spezifischen Wert über eine dedizierte priority: Double-Eigenschaft auf LayoutSubview offen statt über einen öffentlichen LayoutValueKey, sodass der Proxy-Zugriff für die Layout-Priorität subview.priority lautet und nicht ein Schlüssel-Subscript. Eigene Layouts deklarieren ihre eigenen LayoutValueKey-Typen für alle anderen strukturierten Daten, die sie von Kindern benötigen.
Der cache-Parameter
Beide Layout-Methoden erhalten einen cache: inout-Parameter. Der Cache ist der Ort des Layouts, um Arbeit zwischen sizeThatFits und placeSubviews zu amortisieren6:
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
}
}
}
Der Standard-cache-Typ ist Void. Die meisten Layouts können den Cache ignorieren; er rechtfertigt seinen Platz, wenn die Größenberechnung wirklich aufwendig ist (rekursive Messungen, dynamische Größenentscheidungen) und dieselben Zwischenwerte beide Layout-Methoden speisen.
makeCache(subviews:) läuft einmal pro Layout-Pass; updateCache(_:subviews:) läuft, wenn sich die Subviews zwischen den Pässen ändern. Das Muster lässt das Layout den gecachten Zustand korrekt invalidieren, wenn sich die Kinder selbst ändern.
Verbreitete eigene Layouts, die sich lohnen
Drei Muster, die es sich lohnt, selbst zu bauen:
Flow-Layout (umbrechende Items). Items werden auf mehrere Zeilen umgebrochen, wenn sie die verfügbare Breite überschreiten. Apples HStack bricht nicht um. Ein eigenes Layout kann das: jedes Kind messen, von links nach rechts platzieren, in die nächste Zeile rücken, wenn die Zeilenbreite die Vorschlagsbreite überschreitet.
Diagonaler Stapel. Items staffeln sich diagonal (jedes Kind ist leicht nach unten und rechts vom vorherigen positioniert). Nützlich für gestapelte Card-UIs, Galerie-Vorschau-Layouts, Stapel mit Parallax-Anmutung.
Pie-/Kreis-Layout. Items rund um den Umfang eines Kreises angeordnet. Nützlich für radiale Menüs, zeitbasierte UIs, gleichmäßig verteilte kategoriale Labels.
Jedes davon ist mit sizeThatFits + placeSubviews + (optional) einem eigenen Cache umsetzbar. Das Framework übernimmt die Parent-proposes-Child-disposes-Verhandlung; der Entwickler übernimmt die Platzierungsmathematik.
Verbreitete Layout-Fehler
Drei Muster, die zu kaputten eigenen Layouts führen:
Fest codierte Größen, die den Vorschlag ignorieren. Ein Layout, das immer CGSize(width: 200, height: 100) zurückgibt, passt sich nicht an seinen Container an. Das Ergebnis: Das Layout sieht im Simulator gut aus, bricht aber auf kleineren Bildschirmen, in anderen Ausrichtungen oder innerhalb von größenveränderbaren Containern.
Subviews in placeSubviews überspringen. Jede Subview muss pro Aufruf genau einmal platziert werden. Eine for-Schleife mit einem continue für eine Bedingung lässt diese Subviews unpositioniert; sie verschwinden aus der gerenderten Ausgabe.
GeometryReader innerhalb der Kinder eines eigenen Layout verwenden. GeometryReader schlägt seinem Inhalt immer den vollen empfangenen Platz vor, was gegen die Pro-Kind-Vorschläge des Layouts ankämpft. Die Kombination erzeugt unsinnige Größen. Eigene Layouts sollten GeometryReader nicht in sich selbst platzieren; wenn ein Kind seine zugewiesene Größe kennen muss, ist der Vorschlagsmechanismus des Layout-Protokolls der richtige Kanal.
Wann zu Layout greifen (und wann nicht)
Drei Signale, dass ein eigenes Layout das richtige Werkzeug ist:
- Die Form lässt sich nicht durch Komposition von HStack/VStack/ZStack/Grid ausdrücken. Pie-Layouts, Masonry-Grids, eigene Flow-Umbrüche. Die integrierten Primitive können sich nicht zu diesen Formen komponieren.
- Pro-Kind-Informationen steuern die Positionierung. Layouts, bei denen Kinder Prioritäten, Gewichtungen oder Kategorien haben, die der Parent zur Positionierung verwendet.
LayoutValueKeyist der richtige Kanal. - Die Größenbestimmung des Layouts hängt von der Verhandlung mit Kindern ab. Layouts, die fragen: „Was ist die kleinste Höhe, die in die längste Zeile passt?” oder „Welche Breite ergibt gleiche Spalten für N Kinder?” benötigen Zugriff auf
subviews.sizeThatFits(...)-Abfragen.
Drei Signale, dass die integrierte Komposition ausreicht:
- Standardmäßiges horizontales/vertikales/Tiefen-Stacking.
HStack,VStack,ZStackdecken die häufigen Fälle ab. - Grid mit regelmäßigen Zeilen/Spalten.
GridundLazyVGrid/LazyHGridbewältigen die meisten Grid-Fälle. - Etwas Overlay-Positionierung.
.overlay,.background,ZStackmit Alignment decken die meisten „X über Y”-Muster ab.
Die Faustregel: Bauen Sie kein eigenes Layout für eine Form, die die integrierten Komponenten beherrschen. Bauen Sie eines, wenn die Form wirklich jenseits des Ausdrucksspektrums der integrierten Komponenten liegt.
Was dieses Muster für iOS 26+ Apps bedeutet
Drei Erkenntnisse.
-
Respektieren Sie den Vorschlag in
sizeThatFits. Ein Layout, das unabhängig vomproposaldieselbe Größe zurückgibt, nimmt nicht ordnungsgemäß am Layout-System von SwiftUI teil. Lesen Sie den Vorschlag, geben Sie eine dazu passende Größe zurück. -
Verwenden Sie
LayoutValueKeyfür strukturierte Parent-Child-Kommunikation. Daten über View-Modifier-angehängte Schlüssel zu übergeben, ist das SwiftUI-native Muster. Greifen Sie nicht zu@Environmentoder eigenemPreferenceKeyfür Daten, die spezifisch um Layout-Level-Entscheidungen gehen;LayoutValueKeyist der typisierte Kanal dafür. -
Bauen Sie einen Cache nur, wenn die Messung aufwendig ist. Der Standard-
Void-Cache reicht für die meisten Layouts. Greifen Sie nur dann zu einem eigenen Cache-Typ, wenn dieselbe aufwendige Berechnung sowohl insizeThatFitsals auch inplaceSubviewsvorkommt.
Der vollständige Apple-Ecosystem-Cluster: typisierte App Intents; MCP-Server; die Routing-Frage; Foundation Models; die Runtime-vs-Tooling LLM-Unterscheidung; drei Oberflächen; das Single-Source-of-Truth-Muster; zwei MCP-Server; Hooks für Apple-Entwicklung; Live Activities; die watchOS-Runtime; SwiftUI-Interna; RealityKits räumliches mentales Modell; SwiftData-Schemadisziplin; Liquid-Glass-Muster; Multi-Plattform-Auslieferung; die Plattform-Matrix; Vision-Framework; Symbol Effects; Core-ML-Inferenz; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility als Plattform; SF-Pro-Typografie; visionOS-Raummuster; Speech-Framework; SwiftData-Migrationen; tvOS-Focus-Engine; @Observable-Interna; worüber ich mich weigere zu schreiben. Der Hub befindet sich in der Apple Ecosystem Series. Für einen breiteren Kontext zu iOS mit AI-Agenten siehe den iOS Agent Development Guide.
FAQ
Warum nicht einfach GeometryReader verwenden?
GeometryReader schlägt seinem Inhalt immer seine volle empfangene Größe vor (er hat keine Meinung darüber, was sein Inhalt möchte). Das Ergebnis ist, dass jede View innerhalb eines GeometryReader infinity für die Achsen vorgeschlagen bekommt, die der Reader nicht einschränkt, und Views wie Text sich gierig dimensionieren. Die Komposition kämpft gegen sich selbst: Der Reader gibt unverändert weiter, der Inhalt fordert die maximale Größe an, das Layout bricht. Layout ist das richtige Werkzeug, weil es dem Entwickler erlaubt, explizite Pro-Kind-Entscheidungen über die vorgeschlagene Größe zu treffen.
Kann ich einen eigenen HStack-Ersatz schreiben?
Ja. Ein HStack-äquivalentes eigenes Layout liest die bevorzugten Größen der Kinder, summiert ihre Breiten, nimmt die maximale Höhe und platziert sie von links nach rechts. Der tatsächliche HStack macht mehr (Abstände, Alignment, Layout-Prioritätsauflösung), aber die grundlegende Form ist in Layout unkompliziert. Die Übung ist eine nützliche Methode, um zu verinnerlichen, wie das Protokoll funktioniert.
Wie unterstütze ich .layoutPriority(_:) in meinem eigenen Layout?
Lesen Sie es über die dedizierte priority: Double-Eigenschaft des LayoutSubview-Proxy: subview.priority. SwiftUI legt .layoutPriority(_:) direkt am Proxy offen statt über einen öffentlichen LayoutValueKey. Der Standardwert ist 0. Verwenden Sie die Priorität bei der Verteilung zusätzlichen Platzes (geben Sie ihn bevorzugt an Kinder mit hoher Priorität) oder beim Kürzen (kürzen Sie zuerst Kinder mit niedriger Priorität).
Was ist der Unterschied zwischen proposal: .infinity und proposal: .zero?
.infinity schlägt die maximale Größe auf jeder Achse vor (width: .infinity, height: .infinity). Kinder, die auf gierige Vorschläge reagieren (wie Color), nehmen den vollen verfügbaren Platz ein. .zero schlägt die minimale Größe vor (width: 0, height: 0). Kinder geben ihre minimale Größe zurück (Text gibt die Größe seines längsten unzerbrechlichen Tokens zurück). Beide sind nützliche Endpunkte zur Messung des Größenbereichs der Kinder; viele Layouts verwenden .unspecified (beide nil), um zu fragen: „Was ist deine ideale Größe?”.
Funktioniert Layout auf watchOS, tvOS und visionOS?
Ja. Das Layout-Protokoll befindet sich im plattformübergreifenden Kern von SwiftUI. Eigene Layouts funktionieren auf iOS, iPadOS, macOS, watchOS, tvOS und visionOS gleich. Der Beitrag Apple Platform Matrix im Cluster argumentiert, dass die Plattformaufnahme eine Produktentscheidung ist; der Layout-Mechanismus von SwiftUI ist plattformunabhängig für die Fälle, in denen mehrere Plattformen zutreffen.
Wie interagiert Layout mit @Observable-Modellen?
Layout ist ein Struct, das keinen beobachtbaren Zustand direkt hält; es verfolgt keine Änderungen. Wenn ein Modell aktualisiert wird, wird der Body der Parent-View neu ausgewertet, was dazu führt, dass das Layout mit den Kindern, die der Body produziert, neu ausgeführt wird. Das Layout ist reaktiv durch den Body, in dem es lebt, nicht durch eigene Observation-Hooks. Der Beitrag @Observable internals im Cluster behandelt die Observation-Seite.
Quellen
-
Apple Developer Documentation:
Layout. Die Protokollreferenz, die die AnforderungensizeThatFitsundplaceSubviewssowie die optionalen HooksmakeCache,updateCache,spacingund explizites Alignment abdeckt. ↩ -
Apple Developer Documentation:
sizeThatFits(proposal:subviews:cache:)undplaceSubviews(in:proposal:subviews:cache:). Die zwei erforderlichen Methoden desLayout-Protokolls. ↩↩↩ -
Apple Developer Documentation:
ProposedViewSize. Der Typ mit zwei optionalen CGFloats, der den Größenvorschlag des Parent trägt, mit den Konventionswerten.unspecified,.zeround.infinity. ↩ -
Apple Developer Documentation:
LayoutSubview. Der Proxy-Typ, der eine Kind-View innerhalb vonLayout-Methoden repräsentiert, mitsizeThatFits(_:)zum Abfragen bevorzugter Größen undplace(at:anchor:proposal:)zur Positionierung. ↩ -
Apple Developer Documentation:
LayoutValueKeyundlayoutValue(key:value:). Der typisierte Kanal für Layout-Level-Daten von Kind zu Parent, zugegriffen über Subscript aufLayoutSubview. ↩ -
Apple Developer: Composing custom layouts with SwiftUI. Die Apple-Anleitung, die Caching, Alignment-Guides und die Frage, wann zu
Layoutversus integrierten Containern zu greifen ist, abdeckt. ↩