← Wszystkie wpisy

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

  • Layout to 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 proposal to ProposedViewSize z polami width i height typu opcjonalnego CGFloat. nil oznacza „użyj swojego idealnego rozmiaru”; wartość skończona to oferta rodzica; .infinity oznacza „użyj tyle, ile chcesz”.
  • Subviews to typealias dla LayoutSubviews — kolekcji proxies typu LayoutSubview. 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 LayoutValueKey przypisywane przez .layoutValue(...) na widokach potomnych, odczytywane z indeksów LayoutSubview wewnątrz metod układu.
  • cache służy do amortyzowania obliczeń pomiędzy sizeThatFits i placeSubviews (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:

  • nil dla osi oznacza „użyj swojego idealnego/naturalnego rozmiaru”. Text z propozycją .zero zwraca minimalną szerokość (jeden znak na linię); z propozycją nil zwraca szerokość idealną (jedna linia, bez zawijania).
  • Wartość skończona oznacza „rodzic oferuje tyle miejsca; ty decydujesz, co zrobić”. Text z propozycją 100pt szerokości może zawinąć tekst, użyć mniej miejsca lub dokładnie 100.
  • .infinity oznacza „użyj tyle, ile chcesz”. Color z propozycją .infinity zajmuje 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ę w at. .center umieszcza środek widoku w at; .topLeading umieszcza tam lewy górny róg.
  • proposal: rozmiar, w którym widok potomny powinien być wyrenderowany. Należy przekazać rozmiar zwrócony z sizeThatFits tego 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:

  1. 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.
  2. 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. LayoutValueKey jest właściwym kanałem.
  3. 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:

  1. Standardowe stosowanie poziome/pionowe/głębokościowe. HStack, VStack, ZStack pokrywają typowe przypadki.
  2. Siatka z regularnymi wierszami/kolumnami. Grid oraz LazyVGrid/LazyHGrid obsługują większość przypadków siatek.
  3. Trochę pozycjonowania nakładkowego. .overlay, .background, ZStack z 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.

  1. Uszanować propozycję w sizeThatFits. Układ, który zwraca ten sam rozmiar niezależnie od proposal, nie uczestniczy prawidłowo w systemie układu SwiftUI. Należy odczytać propozycję i zwrócić rozmiar do niej dopasowany.

  2. Używać LayoutValueKey dla ustrukturyzowanej komunikacji rodzic-dziecko. Przekazywanie danych przez klucze przypisane modyfikatorem widoku to wzorzec natywny dla SwiftUI. Nie należy sięgać po @Environment ani niestandardowy PreferenceKey dla danych dotyczących konkretnie decyzji na poziomie układu; LayoutValueKey jest typowanym kanałem do tego celu.

  3. Budować cache tylko wtedy, gdy pomiar jest kosztowny. Domyślny cache typu Void wystarcza 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 w sizeThatFits, jak i placeSubviews.

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


  1. Dokumentacja Apple Developer: Layout. Referencja protokołu obejmująca wymagania sizeThatFits i placeSubviews, plus opcjonalne haki makeCache, updateCache, spacing i jawnego wyrównania. 

  2. Dokumentacja Apple Developer: sizeThatFits(proposal:subviews:cache:) i placeSubviews(in:proposal:subviews:cache:). Dwie wymagane metody protokołu Layout

  3. Dokumentacja Apple Developer: ProposedViewSize. Typ z dwoma opcjonalnymi CGFloat, który niesie propozycję rozmiaru rodzica, z wartościami konwencyjnymi .unspecified, .zero i .infinity

  4. Dokumentacja Apple Developer: LayoutSubview. Typ proxy reprezentujący widok potomny wewnątrz metod Layout, z sizeThatFits(_:) do odpytywania o preferowane rozmiary i place(at:anchor:proposal:) do pozycjonowania. 

  5. Dokumentacja Apple Developer: LayoutValueKey i layoutValue(key:value:). Typowany kanał dla danych na poziomie układu od dziecka do rodzica, dostępny przez indeks na LayoutSubview

  6. Apple Developer: Composing custom layouts with SwiftUI. Przewodnik Apple obejmujący buforowanie, wytyczne wyrównania oraz to, kiedy sięgać po Layout zamiast wbudowanych kontenerów. 

Powiązane artykuły

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 min czytania

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 min czytania

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min czytania