Przerwij pętlę geometrii w SwiftUI, usuwając zależność, która przekazuje zmierzony rozmiar z powrotem do ramki, która go wygenerowała. W oknie Maca zawierającym etykiety o jednakowej szerokości obliczaj ich rozmiary wewnątrz kontenera Layout, zamiast zapisywać pomiary w stanie.

W tym przewodniku utworzymy w oknie własnej aplikacji niewielki panel etykiet. Etykiety korzystają z przykładowego tekstu lokalnego, mają jednakową szerokość i zawijają tekst, gdy okno się zwęża. Kompletny przykład jest przeznaczony dla macOS 13 lub nowszego; w dalszych sekcjach wyjaśniono, jak stosować te same zasady określania rozmiaru, gdy interfejs zawiera widoki tekstowe AppKit.

Jak pomiar geometrii prowadzi do pętli układu?

Układ w SwiftUI opiera się na propozycjach i odpowiedziach: rodzic proponuje rozmiar, a jego elementy potomne zgłaszają wybrane przez siebie rozmiary. Własny kontener uczestniczy w tych uzgodnieniach za pośrednictwem ProposedViewSize. Wielokrotne mierzenie widoku jest więc normalne; same powtarzające się pomiary nie oznaczają, że coś jest nie tak. Zobacz dokumentację Apple dotyczącą ProposedViewSize.

Problem pojawia się wtedy, gdy pomiar zmienia dane wejściowe układu, który jest mierzony. Na przykład etykieta zgłasza swoją szerokość z uwzględnieniem wypełnienia, akcja zapisuje tę wartość, a otaczająca ją ramka używa zapisanej wartości powiększonej o dodatkowe wypełnienie. Powstała ramka zmienia kolejny pomiar. Apple wprost opisuje tę pętlę sprzężenia zwrotnego między GeometryReader a ramką i zaleca korzystanie z systemu układu przy tego rodzaju rozmieszczeniu. Zobacz Compose custom layouts with SwiftUI.

Proste obliczenie pokazuje, dlaczego sprawdzenie równości może nie pomóc. Załóżmy, że przykładowa implementacja mierzy szerokość poprzedniej ramki powiększoną o 16 punktów wypełnienia, a następnie ustawia wynik jako szerokość kolejnej ramki. Rozpoczynając od 120 punktów, otrzymujemy 136, 152 i 168 punktów. Każdy wynik różni się od poprzedniego, więc zabezpieczenie przed przypisaniem niezmienionej wartości nie zatrzyma wzrostu. Te liczby obrazują zależność; nie pochodzą z pomiarów działającej aplikacji.

Zanim wybierzesz rozwiązanie, prześledź zależność we własnym kodzie:

  1. Znajdź GeometryReader, obsługę preferencji albo akcję reagującą na zmianę geometrii.
  2. Ustal, które wartości stanu otrzymują jego pomiary.
  3. Znajdź ramki, wypełnienia, wybory czcionek lub widoki warunkowe zależne od tych wartości.
  4. Sprawdź, czy te wybory wpływają na mierzony widok lub jego widoki nadrzędne.
  5. Tymczasowo zastąp zmierzoną wartość stałą. Jeśli powtarzające się zmiany ustaną, najpierw sprawdź tę ścieżkę zależności.

Zmiana sposobu przekazywania pomiaru nie musi usunąć cyklu. Preferencja może przekazać ten sam rozmiar z powrotem do tego samego widoku nadrzędnego. Odroczenie przypisania stanu może przenieść sprzężenie zwrotne do późniejszej aktualizacji. Kluczowe pytanie brzmi: czy kolejny pomiar nadal zależy od poprzedniego wyniku?

Którego mechanizmu określania rozmiaru użyć?

Wybierz mechanizm odpowiedni do decyzji, którą chcesz podjąć. Zacznij od wbudowanych kontenerów układu; Apple wskazuje własne implementacje Layout jako rozwiązanie dające większą kontrolę. Zobacz dokumentację Layout.

Wymagany efekt Obsługiwane podejście Kluczowy warunek
Rozmieść zwykłe etykiety w pionie Wbudowany stos Pozwól elementom potomnym uczestniczyć w standardowym określaniu rozmiaru.
Wybierz układ szeroki lub kompaktowy ViewThatFits Podaj warianty w kolejności preferencji.
Nadaj kilku etykietom wspólną szerokość zależną od treści Własny Layout Mierz i rozmieszczaj elementy potomne w metodach układu.
Reaguj na geometrię istniejącego widoku onGeometryChange Obserwuj tylko wartość potrzebną do wykonania akcji.
Określ rozmiar osadzonego widoku AppKit NSViewRepresentable.sizeThatFits Zwróć preferowany rozmiar dla danej propozycji.

ViewThatFits wybiera pierwsze dziecko, którego rozmiar idealny mieści się w ograniczonych osiach. Przydaje się, gdy faktycznym wymaganiem jest wybór spośród układów, na przykład układu poziomego i pionowego wariantu zapasowego. Dzięki temu można uniknąć osobnej zmiennej stanu przechowującej zmierzoną szerokość. Zobacz dokumentację Apple dotyczącą ViewThatFits.

W poniższym panelu etykiet o wspólnej szerokości obowiązuje jawna reguła określania rozmiaru: użyj szerokości idealnej najszerszej etykiety, ograniczonej do skończonej proponowanej szerokości. Następnie zmierz wysokość każdej etykiety przy wybranej szerokości. Dzięki temu zwężenie okna zwiększa wysokość panelu przez zawijanie tekstu, a pomiary geometrii nie trafiają do stanu aplikacji.

Utwórz panel etykiet o wspólnej szerokości bez stanu geometrii

Utwórz w Xcode projekt aplikacji macOS używający SwiftUI, ustaw minimalną wersję docelową na macOS 13 lub nowszą i zastąp wygenerowane deklaracje aplikacji oraz widoku treści tym kompletnym przykładem w jednym pliku. W celu kompilacji pozostaw tylko jedną deklarację aplikacji @main. Przykład sprawdzono pod kątem zgodności z udokumentowanymi interfejsami API; nie został tu skompilowany ani uruchomiony, ponieważ dostępne środowisko to Linux.

import SwiftUI

private struct EqualWidthColumn: Layout {
    private let gap: CGFloat = 8

    private func columnWidth(
        proposal: ProposedViewSize,
        subviews: Subviews
    ) -> CGFloat {
        let idealWidth = subviews.reduce(CGFloat.zero) { result, view in
            max(result, view.sizeThatFits(.unspecified).width)
        }

        guard let offeredWidth = proposal.width,
              offeredWidth.isFinite else {
            return idealWidth
        }

        return min(idealWidth, max(0, offeredWidth))
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        guard !subviews.isEmpty else { return .zero }

        let width = columnWidth(proposal: proposal, subviews: subviews)
        let childProposal = ProposedViewSize(width: width, height: nil)
        let contentHeight = subviews.reduce(CGFloat.zero) { result, view in
            result + view.sizeThatFits(childProposal).height
        }
        let totalGap = gap * CGFloat(subviews.count - 1)

        return CGSize(width: width, height: contentHeight + totalGap)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let childProposal = ProposedViewSize(
            width: bounds.width,
            height: nil
        )
        var y = bounds.minY

        for view in subviews {
            let height = view.sizeThatFits(childProposal).height
            view.place(
                at: CGPoint(x: bounds.minX, y: y),
                anchor: .topLeading,
                proposal: childProposal
            )
            y += height + gap
        }
    }
}

private struct ContentView: View {
    private let labels = [
        "Amber tile",
        "Blue tile with a longer local label",
        "Green tile"
    ]

    var body: some View {
        ScrollView(.vertical) {
            EqualWidthColumn {
                ForEach(labels, id: \.self) { label in
                    Text(label)
                        .padding(10)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(
                            RoundedRectangle(cornerRadius: 8)
                                .fill(Color.blue.opacity(0.12))
                        )
                }
            }
            .padding(20)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(minWidth: 220, minHeight: 180)
    }
}

@main
struct GeometryLoopDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Dwie wymagane metody układu dzielą się zadaniami: sizeThatFits zgłasza rozmiar kontenera, a placeSubviews ustawia położenie jego elementów potomnych. Proxy elementów potomnych umożliwiają ich mierzenie i rozmieszczanie bez bezpośredniego dostępu do widoków źródłowych. Obie metody pełnią te udokumentowane funkcje. Zobacz dokumentację Layout firmy Apple.

Najważniejsze szczegóły implementacji to propozycja szerokości i jej ponowne użycie. Każdy element potomny jest mierzony przy określonej szerokości i nieokreślonej wysokości, co pozwala tekstowi ustalić wymaganą wysokość. Podczas rozmieszczania używana jest rzeczywista szerokość obszaru kontenera oraz ta sama zasada określania wysokości. Rozpoczęcie od bounds.minX i bounds.minY pozwala też uniknąć założenia, że rozmieszczanie zaczyna się w punkcie początkowym układu współrzędnych.

Kontener obsługuje pustą kolekcję, zwracając zero. Przy nieokreślonej szerokości wybiera szerokość idealną, a przy skończonej — ogranicza do niej szerokość idealną. Nieskończona proponowana szerokość również daje skończoną szerokość idealną dla tych etykiet. Są to celowo przyjęte reguły dla panelu o rozmiarze dopasowanym do treści. Apple wyjaśnia, że podczas jednego przebiegu układu widoki nadrzędne mogą sprawdzać układy, przekazując propozycje zerowe, nieskończone, nieokreślone i inne. Zobacz sizeThatFits(proposal:subviews:cache:).

Przykład nie zawiera stanu przechowującego zmierzony rozmiar, wywołania zwrotnego geometrii ani przypisania szerokości wyświetlanego elementu z powrotem do ramki. Pionowy widok przewijany zapewnia miejsce na wyższą, zawiniętą treść. Układ zakłada, że elementy potomne, takie jak dostarczone etykiety, potrafią poprawnie reagować na skończoną szerokość. Dziecko o stałej szerokości, które przekracza dostępną przestrzeń, wymaga osobnej decyzji dotyczącej prezentacji, na przykład przewijania w poziomie lub alternatywnego widoku.

Kiedy obserwowanie geometrii nadal ma sens?

Obserwowanie geometrii jest przydatne, gdy pomiar steruje ograniczoną reakcją. Interfejs API Apple onGeometryChange rozdziela transformację od akcji: transformacja wyodrębnia wartość porównywalną, a akcja uruchamia się, gdy ta przekształcona wartość się zmieni. Apple zaleca unikanie szerokich aktualizacji aplikacji, ponieważ geometria może zmieniać się często. Zobacz onGeometryChange(for:of:action:).

Na przykład nakładka może wyświetlać informację, czy stabilny obszar zawierający przekracza wybraną szerokość. Obserwuj ten obszar i zwracaj z transformacji wartość logiczną. Dopilnuj, by nakładka nie wpływała na rozmiar tego obszaru. Ten projekt jest zgodny z udokumentowanym modelem transformacji i akcji, a jednocześnie usuwa cykliczną zależność określania rozmiaru.

Zredukowanie prostokąta do wartości logicznej ogranicza liczbę powiadomień, ale samo w sobie nie gwarantuje stabilności. Jeśli przełączenie na układ kompaktowy zmieni mierzony obszar na tyle, że nastąpi przełączenie z powrotem, wartość logiczna może zmieniać się bez końca. Mierz obszar, którego szerokość wynika z układu otaczającego okna, albo użyj ViewThatFits, aby podjąć decyzję o prezentacji w ramach układu.

Dostępność zależy też od przeciążenia. Akcja przyjmująca tylko nową wartość jest dostępna w macOS 13 lub nowszym, jeśli używany zestaw SDK ją zawiera; przeciążenie przyjmujące starą i nową wartość wymaga macOS 15 lub nowszego. Kompletny przykład omija to rozróżnienie, korzystając wyłącznie z Layout. Przed skopiowaniem sygnatury wywołania zwrotnego do projektu ze starszą wersją docelową sprawdź dokumentację zmiany geometrii Apple oraz przeciążenie przyjmujące starą i nową wartość.

Jak włączyć pomiary AppKit i TextKit do układu SwiftUI?

Gdy treść jest widokiem AppKit, skorzystaj z mechanizmu określania rozmiaru reprezentanta. NSViewRepresentable.sizeThatFits(_:nsView:context:) jest dostępne w macOS 13 lub nowszym, zwraca preferowany rozmiar dla danej propozycji i może zostać wywołane wielokrotnie w jednym przebiegu dla różnych propozycji. Zwrócenie nil powoduje użycie domyślnego algorytmu określania rozmiaru. Traktuj tę metodę jako zapytanie o rozmiar, a nie miejsce publikowania zmierzonych wymiarów w stanie SwiftUI. Zobacz metodę określania rozmiaru reprezentanta.

SwiftUI kontroluje właściwości frame i bounds reprezentowanego widoku AppKit. Apple wyraźnie ostrzega przed samodzielnym ustawianiem tych właściwości układu, ponieważ jest to sprzeczne z działaniem SwiftUI i prowadzi do niezdefiniowanego zachowania. Konfiguruj treść w metodzie aktualizacji reprezentanta i zgłaszaj rozmiar za pomocą obsługiwanego mechanizmu. Zobacz NSViewRepresentable.

W przypadku tekstu najpierw ustal, którego mechanizmu układu używa widok. TextKit 2 udostępnia NSTextLayoutManager.usageBoundsForTextContainer — dostępne od macOS 12 — jako pomiar obszaru zajmowanego w kontenerze tekstowym. Apple dokumentuje możliwość obserwowania go za pomocą KVO. Taka obserwacja może posłużyć do zlecenia zmiany rozmiaru, ale nie eliminuje konieczności zachowania spójności zależności między szerokością a wysokością. Zobacz usageBoundsForTextContainer.

W istniejącej implementacji TextKit 1 metoda NSLayoutManager.usedRect(for:) zgłasza obszar zajmowany przez już rozmieszczone glify; nie generuje glifów ani nie wykonuje układu. Gdy pomiar wymaga ukończenia układu, przed odczytaniem wykorzystanego prostokąta wywołaj ensureLayout(for:) dla odpowiedniego kontenera. Oba interfejsy API są dostępne od macOS 10.7. Zobacz usedRect(for:) i ensureLayout(for:).

Nie odwołuj się do starszej właściwości layoutManager widoku tekstowego TextKit 2 wyłącznie po to, by uzyskać pomiar. Apple wyjaśnia, że dostęp do starszego menedżera układu może przełączyć widok tekstowy w tryb zgodności z TextKit 1. Najpierw sprawdź textLayoutManager i umieść starszy kod pomiarowy w odpowiedniej gałęzi zapasowej. Zobacz What’s new in TextKit and text views.

W przypadku zawijanego widoku tekstowego ustaw proponowaną szerokość jako dane wejściowe układu tekstu i wyprowadź preferowaną wysokość z tej konfiguracji. Uwzględnij jawnie wstawki widoku tekstowego i wypełnienie kontenera; prostokąt we współrzędnych kontenera nie odpowiada automatycznie rozmiarowi zewnętrznego widoku. Pracę z interfejsem AppKit wykonuj w odpowiedniej izolacji aktora. Rozdział Concurrency w podręczniku języka Swift wyjaśnia wykonywanie w głównym aktorze; przeniesienie pracy do zadania nie zmienia cyklicznej zależności we wzorze określającym rozmiar.

Jak sprawdzić, czy układ osiąga stabilny stan?

Uruchom przykład w Xcode i powoli zmieniaj szerokość okna w obu kierunkach. Oczekiwane działanie to wspólna szerokość etykiet, zawijanie tekstu przy mniejszej szerokości i przewijanie w pionie, gdy treść przekracza widoczną wysokość. Następnie powtórz próbę z rzeczywistą strukturą widoków po usunięciu zależności od zmierzonego stanu.

Skorzystaj z poniższych kontroli, aby odróżnić oczekiwane działanie układu od utrzymującego się sprzężenia zwrotnego:

  • Po zmianie rozmiaru pozostaw okno nieruchome. Sprawdź, czy pomiary i przypisania stanu nadal się zmieniają mimo braku zmian treści.
  • Przetestuj dłuższą przykładową etykietę i większą czcionkę. Sprawdź, czy panel może zwiększyć wysokość bez przycinania tekstu.
  • Przetestuj pustą kolekcję etykiet. Układ powinien zwrócić zero przed zastosowaniem wypełnienia otaczającego widoku.
  • Sprawdź propozycje zarówno podczas pomiaru, jak i rozmieszczania. Zawijane dziecko powinno otrzymywać spójne założenia dotyczące szerokości.
  • Jeśli później dodasz buforowanie, uwzględnij dane wejściowe określające rozmiar w regułach jego poprawności; nie używaj ponownie wysokości obliczonych dla innej szerokości.

Wielokrotne wywołania metod określających rozmiar w jednym przebiegu są udokumentowanym zachowaniem. Celem jest uzyskanie stabilnego wyniku przy niezmienionej treści i ograniczeniach. Gdy pomiary decydują o rozmieszczeniu, utrzymuj je w ramach negocjacji układu, a obserwowanie geometrii stosuj do reakcji, które nie odtwarzają obserwowanych wymiarów.

Źródła