Voorkom een SwiftUI-geometrische lus door het pad van een gemeten grootte terug naar het frame dat deze heeft opgeleverd te verwijderen. Bereken voor een Mac-venster met labels die dezelfde breedte delen hun afmetingen binnen een Layout-container in plaats van metingen in de status op te slaan.

In deze walkthrough bouw je een klein labelpaneel in het venster van je eigen app. De labels gebruiken synthetische lokale tekst, delen een breedte en lopen door naar een volgende regel wanneer het venster smaller wordt. Het volledige voorbeeld is bedoeld voor macOS 13 of hoger; latere gedeelten leggen uit hoe je dezelfde discipline voor afmetingen toepast wanneer je interface AppKit-tekstweergaven bevat.

Hoe verandert een geometrische meting in een lay-outlus?

Bij SwiftUI-lay-out zijn voorstellen en antwoorden betrokken: een bovenliggend element stelt een grootte voor en de onderliggende elementen geven de groottes door die ze kiezen. Een aangepaste container neemt aan die onderhandeling deel via ProposedViewSize. Een weergave herhaaldelijk meten is daarom normaal; herhaalde metingen op zichzelf betekenen niet dat er iets mis is. Zie Apples documentatie over ProposedViewSize.

De problematische afhankelijkheid ontstaat wanneer een meting een invoer wijzigt voor de lay-out die wordt gemeten. Een label geeft bijvoorbeeld de breedte inclusief opvulling door, een actie slaat die breedte op en een omringend frame gebruikt de opgeslagen waarde plus extra opvulling. Het resulterende frame verandert de volgende meting. Apple beschrijft deze terugkoppellus van geometry reader naar frame expliciet en raadt aan voor dit soort indelingen het lay-outsysteem te gebruiken. Zie Aangepaste lay-outs samenstellen met SwiftUI.

Een eenvoudige berekening laat zien waarom een gelijkheidscontrole mogelijk niet helpt. Stel dat een illustratieve implementatie de breedte van het vorige frame meet plus 16 punten opvulling en het resultaat vervolgens instelt als de breedte van het volgende frame. Als je begint bij 120 punten, krijg je 136, 152 en 168 punten. Elk resultaat verschilt van het vorige, dus een controle die toewijzing van een ongewijzigde waarde voorkomt, stopt de groei nooit. Deze getallen illustreren de afhankelijkheid; het zijn geen metingen van een draaiende app.

Breng eerst de afhankelijkheid in je eigen code in kaart voordat je een oplossing kiest:

  1. Zoek de geometry reader, de preference-handler of de actie die op geometriewijzigingen reageert.
  2. Bepaal welke statuswaarden de metingen ontvangen.
  3. Zoek de frames, opvulling, lettertypekeuzes of voorwaardelijke weergaven die afhankelijk zijn van die waarden.
  4. Controleer of die keuzes invloed hebben op de gemeten weergave of de bovenliggende elementen ervan.
  5. Vervang de gemeten waarde tijdelijk door een constante. Als de herhaalde wijzigingen stoppen, onderzoek dan eerst dat afhankelijkheidspad.

De manier waarop een meting wordt doorgegeven veranderen, verwijdert de lus niet noodzakelijkerwijs. Een preference kan dezelfde grootte teruggeven aan dezelfde bovenliggende weergave. De statusaanpassing uitstellen kan de terugkoppeling verplaatsen naar een latere update. De relevante vraag is of de volgende meting nog steeds afhankelijk is van het vorige resultaat.

Welk mechanisme voor afmetingen moet je gebruiken?

Kies het mechanisme op basis van de beslissing die je moet nemen. Begin met ingebouwde lay-outcontainers; Apple beschrijft aangepaste Layout-implementaties als de aanpak voor gedrag dat meer controle vereist. Zie de documentatie over Layout.

Gewenst resultaat Ondersteunde aanpak Belangrijke voorwaarde
Gewone labels verticaal rangschikken Een ingebouwde stack Laat onderliggende elementen deelnemen aan de normale afmetingsbepaling.
Een brede of compacte presentatie kiezen ViewThatFits Geef alternatieven op in volgorde van voorkeur.
Meerdere labels dezelfde inhoudsafhankelijke breedte geven Een aangepaste Layout Meet en plaats onderliggende elementen binnen lay-outmethoden.
Reageren op de geometrie van een bestaande weergave onGeometryChange Observeer alleen de waarde die de actie nodig heeft.
Een ingesloten AppKit-weergave op maat maken NSViewRepresentable.sizeThatFits Geef een voorkeursgrootte voor het voorstel terug.

ViewThatFits kiest het eerste onderliggende element waarvan de ideale grootte past op de beperkte assen. Het is handig wanneer je eigenlijk moet kiezen tussen presentaties, zoals een horizontale rangschikking en een verticale terugvaloptie. Zo kun je een aparte statusvariabele voor de gemeten breedte overbodig maken. Zie Apples documentatie over ViewThatFits.

Voor het labelpaneel met gedeelde breedte hieronder is de afmetingsregel expliciet: gebruik de breedste ideale labelbreedte, begrensd door een eindige voorgestelde breedte. Meet vervolgens de hoogte van elk label met die gekozen breedte. Daardoor kan een smaller venster de hoogte van het paneel vergroten doordat tekst doorloopt naar volgende regels, zonder geometrische metingen via de applicatiestatus door te geven.

Bouw een labelpaneel met gedeelde breedte zonder geometrische status

Maak in Xcode een macOS-appproject met SwiftUI, stel het implementatiedoel in op macOS 13 of hoger en vervang de gegenereerde app- en contentviewdeclaraties door dit volledige voorbeeld in één bestand. Houd in het doel slechts één @main-appdeclaratie. De programmacode is gecontroleerd aan de hand van de gedocumenteerde API's; hier is deze niet gecompileerd of uitgevoerd omdat de beschikbare omgeving Linux is.

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()
        }
    }
}

De twee vereiste lay-outmethoden verdelen het werk: sizeThatFits geeft de grootte van de container terug en placeSubviews positioneert de onderliggende elementen. Proxy's voor onderliggende elementen ondersteunen meting en plaatsing zonder rechtstreeks toegang tot de onderliggende weergaven. Beide methoden vervullen de gedocumenteerde verantwoordelijkheden. Zie Apples documentatie over Layout.

De belangrijkste implementatiedetails zijn het breedtevoorstel en het hergebruik ervan. Elk onderliggend element wordt gemeten met een bepaalde breedte en een ongespecificeerde hoogte, zodat de tekst de benodigde hoogte kan bepalen. Bij plaatsing wordt de werkelijke breedte van de containergrenzen gebruikt, met hetzelfde hoogtebeleid. Beginnen bij bounds.minX en bounds.minY voorkomt ook dat je ervan uitgaat dat de plaatsing begint op de oorsprongscoördinaat.

De container verwerkt een lege verzameling door nul terug te geven. Bij een ongespecificeerde breedte kiest deze de ideale breedte; bij een eindige breedte begrenst deze de ideale breedte. Een oneindig breedtevoorstel levert voor deze labels ook de eindige ideale breedte op. Dit zijn bewuste regels voor dit paneel, waarvan de grootte op de inhoud is afgestemd. Apple documenteert dat bovenliggende elementen lay-outs tijdens één lay-outpass kunnen testen met voorstellen van nul, oneindig, ongespecificeerd en andere waarden. Zie sizeThatFits(proposal:subviews:cache:).

Het voorbeeld bevat geen status voor gemeten afmetingen, geen geometriecallback en geen toewijzing van een weergegeven breedte terug aan een frame. De verticale scrollweergave biedt ruimte aan grotere inhoud die doorloopt naar volgende regels. De lay-out gaat uit van onderliggende elementen zoals de meegeleverde labels, die goed kunnen reageren op een eindige breedte. Een element met een vaste breedte dat de beschikbare ruimte overschrijdt, vereist een aparte presentatiekeuze, zoals horizontaal scrollen of een alternatieve weergave.

Wanneer is geometrie observeren nog steeds geschikt?

Geometrie observeren blijft nuttig wanneer de meting een beperkte reactie aanstuurt. Apples API onGeometryChange scheidt een transformatie van een actie: de transformatie haalt een waarde op die op gelijkheid kan worden gecontroleerd, en de actie wordt uitgevoerd wanneer die getransformeerde waarde verandert. Apple raadt aan om brede applicatie-updates te vermijden, omdat geometrie vaak kan veranderen. Zie onGeometryChange(for:of:action:).

Een overlay kan bijvoorbeeld weergeven of een stabiele omvattende regio breder is dan een gekozen breedte. Observeer die omvattende regio en geef vanuit de transformatie een Booleaanse waarde terug. Zorg dat de overlay niet bijdraagt aan de grootte van de regio. Dit ontwerp volgt het gedocumenteerde model met transformatie en actie en verwijdert tegelijk de circulaire afhankelijkheid bij het bepalen van de afmetingen.

Een rechthoek terugbrengen tot een Booleaanse waarde beperkt het aantal meldingen, maar bewijst op zichzelf niet dat de situatie stabiel is. Als de overgang naar de compacte presentatie de gemeten regio voldoende verandert om terug te schakelen, kan de Booleaanse waarde eindeloos afwisselen. Meet een regio waarvan de breedte wordt bepaald door de omringende vensterindeling, of gebruik ViewThatFits om de presentatiekeuze binnen de lay-out te maken.

De beschikbaarheid hangt ook af van de overload. De actie met één nieuwe waarde ondersteunt macOS 13 of hoger wanneer je bouwt met een SDK die deze bevat; de overload met de oude en nieuwe waarde vereist macOS 15 of hoger. Het volledige voorbeeld vermijdt dit onderscheid door alleen Layout te gebruiken. Raadpleeg Apples documentatie over geometriewijzigingen en de overload met oude en nieuwe waarden voordat je een callbacksignatuur kopieert naar een implementatiedoel met een oudere versie.

Hoe verwerk je AppKit- en TextKit-metingen in een SwiftUI-lay-out?

Gebruik de afmetingsgrens van de representable wanneer de inhoud een AppKit-weergave is. NSViewRepresentable.sizeThatFits(_:nsView:context:) is beschikbaar op macOS 13 of hoger, geeft een voorkeursgrootte voor een voorstel terug en kan tijdens één pass meerdere keren worden aangeroepen met verschillende voorstellen. Als je nil teruggeeft, wordt het standaardalgoritme voor afmetingen aangevraagd. Beschouw dit als een afmetingsaanvraag, niet als een plek om gemeten afmetingen naar SwiftUI-status te publiceren. Zie de afmetingsmethode van de representable.

SwiftUI beheert de frame en bounds van de gerepresenteerde AppKit-weergave. Apple waarschuwt expliciet om deze lay-outeigenschappen niet zelf in te stellen, omdat dit in conflict komt met SwiftUI en ongedefinieerd gedrag veroorzaakt. Configureer de inhoud in de updatemethode van de representable en geef afmetingen door via het ondersteunde afmetingsmechanisme. Zie NSViewRepresentable.

Stel voor tekst eerst vast welke lay-outengine de weergave gebruikt. TextKit 2 biedt NSTextLayoutManager.usageBoundsForTextContainer, beschikbaar op macOS 12 of hoger, als meting van het gebruik van een tekstcontainer. Apple documenteert dat deze via KVO kan worden geobserveerd. Die observatie kan een verzoek om de grootte aan te passen ondersteunen, maar neemt niet weg dat breedte- en hoogteafhankelijkheden consistent moeten blijven. Zie usageBoundsForTextContainer.

Voor een bestaande TextKit 1-implementatie geeft NSLayoutManager.usedRect(for:) de ruimte terug die al door lay-out weergegeven glyphs innemen; deze genereert geen glyphs en voert geen lay-out uit. Wanneer de meting voltooide lay-out vereist, roep je ensureLayout(for:) aan voor de betreffende container voordat je de gebruikte rechthoek uitleest. Beide API's zijn beschikbaar vanaf macOS 10.7. Zie usedRect(for:) en ensureLayout(for:).

Benader de verouderde layoutManager van een TextKit 2-tekstweergave niet alleen om een meting te verkrijgen. Apple legt uit dat toegang tot de verouderde lay-outmanager een tekstweergave kan overschakelen naar de compatibiliteitsmodus voor TextKit 1. Controleer eerst textLayoutManager en houd verouderde meetcode in de daarvoor geschikte terugvaltak. Zie Nieuw in TextKit en tekstweergaven.

Maak bij een tekstweergave met regelterugloop de voorgestelde breedte een invoer voor de tekstlay-out en leid de voorkeurs­hoogte af uit die configuratie. Houd expliciet rekening met de inzetstukken van de tekstweergave en de opvulling van de container; een rechthoek in containercoördinaten is niet automatisch de grootte van de buitenste weergave. Houd AppKit-interfacewerk binnen de actorisolatie. Het hoofdstuk Concurrency van Swift legt uitvoering op de main actor uit; werk naar een taak verplaatsen verandert de circulaire afhankelijkheid in een afmetingsformule niet.

Hoe controleer je of de lay-out nu tot rust komt?

Voer het voorbeeld uit in Xcode en wijzig de grootte van het venster langzaam in beide richtingen. Het beoogde gedrag is een gedeelde labelbreedte, tekst die bij smallere breedtes doorloopt naar volgende regels en verticaal scrollen wanneer de inhoud hoger is dan het zichtbare gebied. Herhaal dit vervolgens met je werkelijke weergavestructuur nadat je de afhankelijkheid van de gemeten status hebt verwijderd.

Gebruik deze controles om verwachte lay-outverwerking te onderscheiden van aanhoudende terugkoppeling:

  • Laat het venster stilstaan na het wijzigen van de grootte. Controleer of metingen en statustoewijzingen blijven veranderen zonder dat de inhoud verandert.
  • Test een langer synthetisch label en een groter lettertype. Controleer of het paneel verticaal kan meegroeien zonder tekst af te kappen.
  • Test een lege verzameling labels. De lay-out moet nul teruggeven voordat de omringende opvulling wordt toegepast.
  • Controleer zowel de voorstellen voor meting als die voor plaatsing. Een onderliggend element met regelterugloop moet consistente breedteaannames krijgen.
  • Als je later caching toevoegt, neem dan de afmetingsinvoer op in de regels voor geldigheid; hergebruik geen hoogtes die voor een andere breedte zijn berekend.

Meerdere aanroepen voor het bepalen van afmetingen tijdens één pass zijn gedocumenteerd gedrag. Het praktische doel is een stabiel resultaat bij ongewijzigde inhoud en beperkingen. Houd metingen binnen de lay-outonderhandeling wanneer ze de rangschikking bepalen en bewaar het observeren van geometrie voor reacties die de waargenomen afmetingen niet opnieuw creëren.

Bronnen