Protokół Layout w SwiftUI: Tworzenie własnych układów od sizeThatFits do placeSubviews
W iOS 16 dodano do SwiftUI protokół Layout — publiczny API do budowania własnych widoków kontenerowych, które uczestniczą w przebiegu rozmieszczania SwiftUI1. Przed pojawieniem się Layout własne kształty kontenerów wymagały albo sztuczek z GeometryReader (które łamią kompozycję, ponieważ żądają pełnego proponowanego rozmiaru), albo niestandardowych rozwiązań z ViewModifier, które walczą z systemem. Layout jest właściwą odpowiedzią: dwumetodowy protokół (sizeThatFits i placeSubviews) plus opcjonalne rozszerzenia dotyczące odstępów i buforowania, z kontraktem, który czysto integruje się z modelem rozmieszczania SwiftUI „rodzic proponuje, dziecko dysponuje”.
Wpis omawia protokół na podstawie dokumentacji Apple. Ujęcie brzmi „na czym faktycznie opiera się kontrakt Layout”, ponieważ błędny wzorzec użycia (traktowanie Layout jako narzędzia do przestrzeni współrzędnych, a nie do negocjacji rozmiaru) prowadzi do układów, które działają na jednym ekranie i zawodzą na innym. Wpis What SwiftUI Is Made Of z tego samego klastra dowodził, że architekturę SwiftUI najlepiej zrozumieć, czytając jej publiczne protokoły.
TL;DR
Layoutto protokół z dwiema wymaganymi metodami:sizeThatFits(proposal:subviews:cache:)zwraca preferowany rozmiar układu na podstawie propozycji rodzica;placeSubviews(in:proposal:subviews:cache:)pozycjonuje każde dziecko, wywołując jego metodęplace(at:anchor:proposal:)2.- Parametr
proposaltoProposedViewSizez polamiwidthiheighttypu opcjonalnego CGFloat.niloznacza „użyj swojego idealnego rozmiaru”; wartość skończona to oferta rodzica;.infinityoznacza „użyj tyle, ile chcesz”. Subviewsto typealias dlaLayoutSubviews— kolekcji proxies typuLayoutSubview. Każdy proxy można odpytać o rozmiar dla dowolnej propozycji i umieścić w dowolnym punkcie. Proxies są jedynym sposobem, w jaki Layout komunikuje się z dziećmi.- Niestandardowe wartości układu przepływają od dzieci do rodzica poprzez typy
LayoutValueKeyprzypisywane przez.layoutValue(...)na widokach potomnych, odczytywane z indeksówLayoutSubviewwewnątrz metod układu. cachesłuży do amortyzowania obliczeń pomiędzysizeThatFitsiplaceSubviews(każdy przebieg wywołuje obie metody, często z tymi samymi wartościami pośrednimi). Należy zdefiniować cache jako strukturę przechowującą wstępnie obliczone rozmiary; zbudować go raz i ponownie wykorzystać w obu metodach.
Kontrakt protokołu
Layout to (zazwyczaj) struktura, która deklaruje dwie metody wywoływane przez framework Apple podczas przebiegu rozmieszczania2:
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(...)
}
}
Można go użyć jak wbudowanego kontenera:
DiagonalLayout {
Text("First")
Text("Second")
Text("Third")
}
Framework wywołuje sizeThatFits z proponowanym rozmiarem od rodzica (ProposedViewSize), a następnie wywołuje placeSubviews z granicami przyznanymi układowi. Obie metody razem opisują zachowanie układu: jak duży chce być i gdzie znajduje się każde dziecko w ramach przyznanego obszaru.
ProposedViewSize: oferta rodzica
Rozmieszczanie w SwiftUI podlega kontraktowi „rodzic proponuje, dziecko dysponuje”3. Rodzic przekazuje proponowany rozmiar; dziecko zwraca swój rzeczywisty rozmiar; rodzic pozycjonuje dziecko w obrębie swoich granic. Layout uczestniczy w tym kontrakcie poprzez ProposedViewSize:
struct ProposedViewSize {
var width: CGFloat?
var height: CGFloat?
}
Opcjonalne osie niosą znaczenie semantyczne:
nildla osi oznacza „użyj swojego idealnego/naturalnego rozmiaru”.Textz propozycją.zerozwraca minimalną szerokość (jeden znak na linię); z propozycjąnilzwraca szerokość idealną (jedna linia, bez zawijania).- Wartość skończona oznacza „rodzic oferuje tyle miejsca; ty decydujesz, co zrobić”.
Textz propozycją 100pt szerokości może zawinąć tekst, użyć mniej miejsca lub dokładnie 100. .infinityoznacza „użyj tyle, ile chcesz”.Colorz propozycją.infinityzajmuje całą dostępną przestrzeń.
Konwencja ProposedViewSize.unspecified (width: nil, height: nil) to żądanie idealnego rozmiaru; ProposedViewSize.zero to żądanie minimalnego rozmiaru; ProposedViewSize.infinity to żądanie zachłannej ekspansji.
Metoda sizeThatFits w niestandardowym Layout powinna respektować propozycję: zwracać rozmiar, którego układ faktycznie potrzebuje dla proponowanych granic, a nie zawsze tę samą zakodowaną wartość. Sztywno zakodowane rozmiary łamią zdolność układu do dostosowywania się do różnych kontenerów (widok karty, komórka listy, arkusz).
Odczytywanie rozmiarów widoków potomnych przez LayoutSubview
Wewnątrz sizeThatFits układ pyta każde dziecko, jakiego rozmiaru chce dla różnych propozycji. Zapytanie przechodzi przez 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)
}
Wzorzec subviews.map { $0.sizeThatFits(proposal) } to sposób, w jaki układ poznaje rozmiary, których chcą jego dzieci. Metoda sizeThatFits(_:) proxy LayoutSubview nie jest tym samym, co metoda protokołu Layout; to zapytanie proxy o preferowany rozmiar dziecka przy danej propozycji. Obie dzielą nazwę, bo uczestniczą w tej samej negocjacji, ale są to różne warstwy kontraktu.
Układ, który chce poznać rozmiary dzieci, wywołuje proxy.sizeThatFits(_:). Układ, który chce pozycjonować dzieci, wywołuje proxy.place(at:anchor:proposal:) wewnątrz placeSubviews.
Pozycjonowanie widoków potomnych
placeSubviews to miejsce, w którym układ podejmuje decyzje pozycjonowania2:
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
}
}
Wywołanie place(at:anchor:proposal:) pozycjonuje pojedynczy widok potomny. Trzy parametry:
at: pozycja w przestrzeni współrzędnych rodzica.anchor: który punkt widoku potomnego znajduje się wat..centerumieszcza środek widoku wat;.topLeadingumieszcza tam lewy górny róg.proposal: rozmiar, w którym widok potomny powinien być wyrenderowany. Należy przekazać rozmiar zwrócony zsizeThatFitstego samego widoku, aby uszanować jego preferencje, lub przekazać własną propozycję, by go ograniczyć.
Każdy widok potomny musi zostać umieszczony dokładnie raz na każde wywołanie placeSubviews. Pominięcie widoku pozostawia go bez pozycji (znika z wyrenderowanego układu); umieszczenie go dwukrotnie to błąd wykonania.
Niestandardowe wartości układu poprzez LayoutValueKey
Gdy dziecko musi przekazać coś swojemu rodzicowi-układowi (priorytet, rozpiętość, kategorię), kanałem jest 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
}
Protokół LayoutValueKey zapewnia typowany kanał komunikacji rodzic-dziecko. Dziecko przypisuje wartość przez modyfikator wartości układu; rodzic odczytuje ją przez indeks LayoutSubview. Każdy klucz ma wartość domyślną dla widoków potomnych, które nie określają jej jawnie.
Wzorzec ten odpowiada koncepcyjnie temu, co wyrażają wbudowane modyfikatory takie jak .layoutPriority(_:). Framework eksponuje tę konkretną wartość przez dedykowaną właściwość priority: Double na LayoutSubview, a nie przez publiczny LayoutValueKey, więc dostęp przez proxy do priorytetu układu odbywa się przez subview.priority, a nie przez indeks klucza. Niestandardowe układy deklarują własne typy LayoutValueKey dla wszelkich innych ustrukturyzowanych danych potrzebnych od dzieci.
Parametr cache
Obie metody układu otrzymują parametr cache: inout. Cache to miejsce, w którym układ amortyzuje pracę pomiędzy sizeThatFits i 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
}
}
}
Domyślnym typem cache jest Void. Większość układów może zignorować cache; zarabia on na siebie wtedy, gdy obliczanie rozmiaru jest naprawdę kosztowne (rekurencyjne pomiary, dynamiczne decyzje o rozmiarze) i te same wartości pośrednie zasilają obie metody układu.
makeCache(subviews:) wykonuje się raz na przebieg układu; updateCache(_:subviews:) wykonuje się, gdy widoki potomne zmieniają się między przebiegami. Wzorzec ten pozwala układowi prawidłowo unieważnić zbuforowany stan, gdy zmieniają się same dzieci.
Typowe niestandardowe układy warte zbudowania
Trzy wzorce warte zbudowania samodzielnie:
Układ przepływowy (zawijające się elementy). Elementy przechodzą do kolejnych wierszy, gdy przekraczają dostępną szerokość. HStack od Apple nie zawija. Niestandardowy Layout potrafi: zmierzyć każde dziecko, umieszczać od lewej do prawej, przejść do następnego wiersza, gdy szerokość wiersza przekroczy szerokość propozycji.
Stos diagonalny. Elementy układają się skośnie (każde dziecko pozycjonowane lekko w dół i w prawo od poprzedniego). Przydatne dla interfejsów ze stosami kart, układów podglądów galerii, stosów z efektem paralaksy.
Układ kołowy/torta. Elementy rozmieszczone na obwodzie koła. Przydatne dla menu radialnych, interfejsów opartych na czasie, etykiet kategorialnych z równymi odstępami.
Każdy z nich można zaimplementować za pomocą sizeThatFits + placeSubviews + (opcjonalnie) niestandardowego cache. Framework obsługuje negocjację „rodzic proponuje, dziecko dysponuje”; programista zajmuje się matematyką pozycjonowania.
Typowe błędy w układach
Trzy wzorce, które prowadzą do zepsutych niestandardowych układów:
Sztywno zakodowane rozmiary, które ignorują propozycję. Układ, który zawsze zwraca CGSize(width: 200, height: 100), nie dostosowuje się do swojego kontenera. Rezultat: układ wygląda dobrze w symulatorze, ale łamie się na mniejszych ekranach, w innych orientacjach lub wewnątrz kontenerów o zmiennym rozmiarze.
Pomijanie widoków potomnych w placeSubviews. Każdy widok potomny musi zostać umieszczony dokładnie raz na wywołanie. Pętla for z continue dla pewnego warunku zostawia te widoki bez pozycji; znikają z wyrenderowanego wyjścia.
Używanie GeometryReader wewnątrz dzieci niestandardowego Layout. GeometryReader zawsze proponuje pełną otrzymaną przestrzeń swojej zawartości, co walczy z propozycjami układu dla poszczególnych dzieci. Połączenie produkuje bezsensowne rozmiary. Niestandardowe układy nie powinny umieszczać GeometryReader wewnątrz siebie; jeśli dziecko musi znać swój przydzielony rozmiar, mechanizm propozycji w protokole układu jest właściwym kanałem.
Kiedy sięgać po Layout (a kiedy nie)
Trzy sygnały, że niestandardowy Layout jest właściwym narzędziem:
- Kształtu nie da się wyrazić kompozycją HStack/VStack/ZStack/Grid. Układy kołowe, siatki murowane, niestandardowe zawijanie przepływowe. Wbudowane prymitywy nie potrafią się skomponować w takie kształty.
- Informacja o poszczególnych dzieciach steruje pozycjonowaniem. Układy, w których dzieci mają priorytety, wagi lub kategorie, których rodzic używa do ich pozycjonowania.
LayoutValueKeyjest właściwym kanałem. - Rozmiar układu zależy od negocjacji z dziećmi. Układy, które pytają „jaka jest najmniejsza wysokość, która zmieści najdłuższą linię?” lub „jaka szerokość daje równe kolumny dla N dzieci?”, potrzebują dostępu do zapytań
subviews.sizeThatFits(...).
Trzy sygnały, że wbudowana kompozycja wystarczy:
- Standardowe stosowanie poziome/pionowe/głębokościowe.
HStack,VStack,ZStackpokrywają typowe przypadki. - Siatka z regularnymi wierszami/kolumnami.
GridorazLazyVGrid/LazyHGridobsługują większość przypadków siatek. - Trochę pozycjonowania nakładkowego.
.overlay,.background,ZStackz wyrównaniem pokrywają większość wzorców „X na Y”.
Praktyczna zasada: nie buduj niestandardowego Layout dla kształtu, który obsługują wbudowane elementy. Buduj go wtedy, gdy kształt naprawdę wykracza poza zbiór ekspresji wbudowanych prymitywów.
Co ten wzorzec oznacza dla aplikacji iOS 26+
Trzy wnioski.
-
Uszanować propozycję w
sizeThatFits. Układ, który zwraca ten sam rozmiar niezależnie odproposal, nie uczestniczy prawidłowo w systemie układu SwiftUI. Należy odczytać propozycję i zwrócić rozmiar do niej dopasowany. -
Używać
LayoutValueKeydla ustrukturyzowanej komunikacji rodzic-dziecko. Przekazywanie danych przez klucze przypisane modyfikatorem widoku to wzorzec natywny dla SwiftUI. Nie należy sięgać po@Environmentani niestandardowyPreferenceKeydla danych dotyczących konkretnie decyzji na poziomie układu;LayoutValueKeyjest typowanym kanałem do tego celu. -
Budować cache tylko wtedy, gdy pomiar jest kosztowny. Domyślny cache typu
Voidwystarcza większości układów. Po niestandardowy typ cache należy sięgać tylko wtedy, gdy te same kosztowne obliczenia pojawiają się zarówno wsizeThatFits, jak iplaceSubviews.
Pełny klaster Apple Ecosystem: typowane App Intents; serwery MCP; pytanie o routing; Foundation Models; rozróżnienie LLM runtime vs tooling; trzy powierzchnie; wzorzec single source of truth; Two MCP Servers; hooki w rozwoju Apple; Live Activities; runtime watchOS; wewnętrzne mechanizmy SwiftUI; model mentalny przestrzeni RealityKit; dyscyplina schematów SwiftData; wzorce Liquid Glass; wieloplatformowe wdrażanie; macierz platform; framework Vision; Symbol Effects; inferencja Core ML; API Writing Tools; Swift Testing; Privacy Manifest; Dostępność jako platforma; typografia SF Pro; wzorce przestrzenne visionOS; framework Speech; migracje SwiftData; silnik fokusu tvOS; wewnętrzne mechanizmy @Observable; o czym odmawiam pisać. Hub znajduje się pod Apple Ecosystem Series. Szerszy kontekst iOS z agentami AI można znaleźć w iOS Agent Development guide.
FAQ
Dlaczego nie użyć po prostu GeometryReader?
GeometryReader zawsze proponuje pełny otrzymany rozmiar swojej zawartości (nie ma żadnej opinii o tym, czego chce jego zawartość). Skutkuje to tym, że każdy widok wewnątrz GeometryReader otrzymuje propozycję infinity dla osi, których reader nie ogranicza, a widoki takie jak Text zachłannie się rozszerzają. Kompozycja walczy sama ze sobą: reader przekazuje bez zmian, zawartość prosi o maksymalny rozmiar, układ się załamuje. Layout jest właściwym narzędziem, ponieważ pozwala programiście podejmować jawne decyzje dla poszczególnych dzieci dotyczące proponowanego rozmiaru.
Czy mogę napisać własny zamiennik HStack?
Tak. Niestandardowy Layout równoważny HStack odczytuje preferowane rozmiary dzieci, sumuje ich szerokości, bierze maksymalną wysokość i umieszcza je od lewej do prawej. Faktyczny HStack robi więcej (odstępy, wyrównanie, rozwiązywanie priorytetu układu), ale podstawowy kształt jest prosty w Layout. Ćwiczenie to przydatny sposób na zinternalizowanie tego, jak działa protokół.
Jak obsłużyć .layoutPriority(_:) w moim niestandardowym układzie?
Należy odczytać go przez dedykowaną właściwość priority: Double proxy LayoutSubview: subview.priority. SwiftUI eksponuje .layoutPriority(_:) bezpośrednio na proxy, a nie przez publiczny LayoutValueKey. Wartością domyślną jest 0. Priorytet należy używać przy rozdzielaniu dodatkowej przestrzeni (przyznaj ją preferencyjnie dzieciom o wysokim priorytecie) lub przy obcinaniu (najpierw obcinaj dzieci o niskim priorytecie).
Jaka jest różnica między proposal: .infinity a proposal: .zero?
.infinity proponuje maksymalny rozmiar na każdej osi (width: .infinity, height: .infinity). Dzieci, które reagują na zachłanne propozycje (jak Color), zajmują całą dostępną przestrzeń. .zero proponuje minimalny rozmiar (width: 0, height: 0). Dzieci zwracają swój minimalny rozmiar (Text zwraca rozmiar swojego najdłuższego niełamliwego tokenu). Oba są przydatnymi punktami końcowymi do mierzenia zakresu rozmiarów dzieci; wiele układów używa .unspecified (oba nil), aby zapytać „jaki jest twój idealny rozmiar?”.
Czy Layout działa na watchOS, tvOS i visionOS?
Tak. Protokół Layout znajduje się w wieloplatformowym rdzeniu SwiftUI. Niestandardowe układy działają tak samo w iOS, iPadOS, macOS, watchOS, tvOS i visionOS. Wpis Apple Platform Matrix z tego klastra dowodzi, że włączenie platformy to decyzja produktowa; mechanizm Layout w SwiftUI jest niezależny od platformy w przypadkach, gdy stosuje się wiele platform.
Jak Layout współdziała z modelami @Observable?
Layout to struktura, która nie przechowuje bezpośrednio żadnego obserwowalnego stanu; nie śledzi zmian. Gdy model się aktualizuje, ciało widoku rodzica jest ponownie obliczane, co powoduje ponowne uruchomienie Layout z dziećmi, które ciało produkuje. Layout jest reaktywny przez ciało, w którym żyje, a nie przez własne mechanizmy obserwacji. Wpis @Observable internals z tego klastra omawia stronę obserwacji.
Bibliografia
-
Dokumentacja Apple Developer:
Layout. Referencja protokołu obejmująca wymaganiasizeThatFitsiplaceSubviews, plus opcjonalne hakimakeCache,updateCache,spacingi jawnego wyrównania. ↩ -
Dokumentacja Apple Developer:
sizeThatFits(proposal:subviews:cache:)iplaceSubviews(in:proposal:subviews:cache:). Dwie wymagane metody protokołuLayout. ↩↩↩ -
Dokumentacja Apple Developer:
ProposedViewSize. Typ z dwoma opcjonalnymi CGFloat, który niesie propozycję rozmiaru rodzica, z wartościami konwencyjnymi.unspecified,.zeroi.infinity. ↩ -
Dokumentacja Apple Developer:
LayoutSubview. Typ proxy reprezentujący widok potomny wewnątrz metodLayout, zsizeThatFits(_:)do odpytywania o preferowane rozmiary iplace(at:anchor:proposal:)do pozycjonowania. ↩ -
Dokumentacja Apple Developer:
LayoutValueKeyilayoutValue(key:value:). Typowany kanał dla danych na poziomie układu od dziecka do rodzica, dostępny przez indeks naLayoutSubview. ↩ -
Apple Developer: Composing custom layouts with SwiftUI. Przewodnik Apple obejmujący buforowanie, wytyczne wyrównania oraz to, kiedy sięgać po
Layoutzamiast wbudowanych kontenerów. ↩