Maak je SwiftUI-component generiek voor het type dat aan het protocol voldoet en declareer vervolgens de opgeslagen view als Provider.Content. Zo blijft de relatie tussen de provider en het bijbehorende viewtype in de hele component behouden.

Deze tutorial bouwt een klein macOS-venster met twee synthetische tegels. Het doel is een provider en de view die deze produceert op te slaan zonder het bijbehorende type te vervangen door any View of AnyView. Hetzelfde patroon geldt wanneer een herbruikbare desktopcomponent een aangepast label, accessoire of een contentprovider accepteert.

Welke relatie moeten de opgeslagen eigenschappen behouden?

Een bijbehorend type benoemt een type dat door elke protocolconformiteit wordt gekozen. Als een protocol associatedtype Content: View declareert, moet een provider die eraan voldoet één bepaald contenttype leveren. Generieke code kan naar dat type verwijzen als Provider.Content, ook wanneer de implementatie achter een opaak resultaat verborgen zit. Swift beschrijft bijbehorende typen en generieke beperkingen in het hoofdstuk over generics.

De component heeft daarom twee gekoppelde declaraties nodig: een generieke parameter die aan het providerprotocol is gebonden, en een opgeslagen contenteigenschap waarvan het type het bijbehorende type van die parameter is. Twee onafhankelijke eigenschappen declareren die alleen aan protocollen voldoen, drukt deze koppeling niet uit.

SwiftUI hanteert zelf een verwant contract: View declareert associatedtype Body: View, en Swift leidt dat bijbehorende type af uit de body-implementatie van een aangepaste view. De Content van je provider en de Body van de component zijn afzonderlijke bijbehorende typen; ze hoeven niet gelijk te zijn. Zie Apple’s documentatie over Body.

Bouw een compleet macOS-voorbeeld

Gebruik voor dit voorbeeld een Xcode-macOS-appproject met SwiftUI en een implementatiedoel van macOS 13 of hoger. Vervang de gegenereerde app- en contentviewdeclaraties door het volgende enkele bestand en zorg dat er in het doel precies één @main-declaratie staat. Apple beschrijft deze app-entrystructuur en het gebruik van WindowGroup in App.

Het voorbeeld gebruikt bewust de al lang bestaande syntaxis voor bijbehorende typen en benoemde generieke typen. Opaque resultaten zijn geïntroduceerd in Swift 5.1; de optionele syntaxis voor primaire bijbehorende typen die later aan bod komt, vereist Swift 5.7. Het volledige bestand is bedoeld voor een actuele Swift 6-toolchain. Het is gecontroleerd aan de hand van de opgehaalde documentatie, maar hier niet gecompileerd omdat deze omgeving geen Swift-compiler heeft. Het gekozen macOS-implementatiedoel is een voorwaarde voor dit voorbeeld, geen bewering over de vroegste versie van het besturingssysteem die alle onderliggende functies ondersteunt. Zie SE-0244 en SE-0346.

import SwiftUI

@MainActor
protocol TileProvider {
    associatedtype Content: View

    var title: String { get }
    func makeContent() -> Content
}

@MainActor
struct TextTile: TileProvider {
    let title: String
    let message: String

    func makeContent() -> Text {
        Text(message)
    }
}

@MainActor
struct CircleTile: TileProvider {
    let title: String
    let caption: String

    func makeContent() -> some View {
        HStack(spacing: 8) {
            Circle()
                .fill(Color.blue)
                .frame(width: 16, height: 16)
            Text(caption)
        }
    }
}

@MainActor
struct TileCard<Provider: TileProvider>: View {
    private let provider: Provider
    private let content: Provider.Content

    init(provider: Provider) {
        self.provider = provider
        self.content = provider.makeContent()
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(provider.title)
                .font(.headline)
            content
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Color.gray.opacity(0.12))
    }
}

@MainActor
struct ContentView: View {
    var body: some View {
        VStack(spacing: 12) {
            TileCard(provider: TextTile(
                title: "Text tile",
                message: "Blue square"
            ))
            TileCard(provider: CircleTile(
                title: "Shape tile",
                caption: "Blue circle"
            ))
        }
        .padding()
        .frame(width: 360)
    }
}

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

Bouw en voer het macOS-doel uit. Het beoogde resultaat is een venster met een teksttegel en een tegel met een blauwe cirkel naast het bijschrift. Beide gebruiken dezelfde generieke component, waarbij het providertype op elke aanroepplaats afzonderlijk wordt afgeleid.

De expliciete @MainActor-annotaties houden deze declaraties die UI opleveren in dezelfde actorcontext. In Apple’s actuele View-documentatie wordt het protocol gedeclareerd met isolatie naar de main actor. De annotaties maken ook de bedoelde context van het aangepaste providerprotocol expliciet.

Waarom behoudt de generieke component het type?

Voor de eerste kaart is Provider gelijk aan TextTile, dus is Provider.Content gelijk aan Text. Voor de tweede kaart is Provider gelijk aan CircleTile, en is het bijbehorende contenttype het opaak resultaat van CircleTile.makeContent(). Je kunt dat type benoemen via CircleTile.Content zonder het geneste SwiftUI-layouttype uit te schrijven.

De essentiële toewijzing is self.content = provider.makeContent(). Het protocol garandeert dat deze methode precies Provider.Content retourneert, en dat is precies het gedeclareerde type van de opgeslagen eigenschap. Dit is een relatie tijdens het compileren, afgeleid van de generieke beperking, en geen vergelijking van typen tijdens runtime. Het hoofdstuk over generics van Swift legt uit hoe je via generieke parameters met beperkingen toegang krijgt tot bijbehorende typen.

Het gebruik van some View in de conformerende implementatie doet niets af aan deze relatie. Een opaak resultaat verbergt de concrete implementatie, maar behoudt de type-identiteit. Daardoor is het geschikt als getuige voor een bijbehorend type. Swift legt dit onderscheid uit in Opaque and Boxed Protocol Types.

Deze kaarten hebben verschillende specialisaties: TileCard<TextTile> en TileCard<CircleTile>. Hergebruik van de brondeclaratie vereist niet dat elke kaart één universeel opgeslagen contenttype heeft.

Waarom lossen onafhankelijke opaque of existential-eigenschappen dit niet op?

De volgende fragmenten zijn bedoeld om problemen te diagnosticeren en vervangen niet het volledige programma:

// Diagnostic fragment: no initializer establishes the opaque type.
let content: some View

// Diagnostic fragment: hides which conforming provider is stored.
let provider: any TileProvider

// Diagnostic fragment: stores an existential view value.
let content: any View

Een opaque opgeslagen eigenschap heeft een initialisator in de declaratie nodig die het onderliggende type vastlegt. some View schrijven maakt geen door de aanroeper gekozen generieke parameter die een willekeurige componentinitialisator later kan invullen. let content: some View = Text("Sample") legt bijvoorbeeld een type vast voor die declaratie; het koppelt de eigenschap niet aan het bijbehorende type van een andere eigenschap. Deze regel voor opgeslagen eigenschappen staat beschreven in SE-0244: Opaque Result Types.

Een existential zoals any TileProvider maakt het mogelijk verschillende typen die aan het protocol voldoen op te slaan. Het verbergt het specifieke conformerende type, waardoor de declaratie de relatie als Provider.Content niet langer zichtbaar maakt. Evenzo is any View een existential-container; dat is niet hetzelfde contract als een benoemd generiek type dat aan View voldoet. Zie Swifts uitleg over ingekaderde protocoltypen.

Swift kan existential-argumenten impliciet openen voor sommige generieke aanroepen. Dat betekent niet dat elke existential in elke generieke initialisator bruikbaar is. SE-0352 beperkt het openen met name wanneer het onbekende onderliggende type ontsnapt binnen een generiek resultaat zoals X<T>. Een specialisatie van een generieke component kan precies tegen die grens aanlopen. Houd de provider generiek voordat je de kaart maakt, in plaats van ervan uit te gaan dat een existential altijd weer kan worden omgezet in een blijvende specialisatie. Zie SE-0352: Implicitly Opened Existentials.

Kies de opslag op basis van wanneer de content moet worden geproduceerd

Het voorbeeld roept makeContent() aan tijdens elke initialisatie van TileCard en slaat de geretourneerde view-waarde op. Het vormt geen blijvende cache voor latere initialisaties van de component. Beide eigenschappen behouden laat de gevraagde typerelatie zien, maar een component die alleen de resulterende view nodig heeft, kan de opgeslagen provider weglaten.

Als de component content moet opvragen telkens wanneer de body wordt geëvalueerd, behoud dan alleen de provider. Gebruik de volgende vervangende componentdeclaratie met het bovenstaande protocol en de app:

@MainActor
struct TileCard<Provider: TileProvider>: View {
    private let provider: Provider

    init(provider: Provider) {
        self.provider = provider
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(provider.title)
                .font(.headline)
            provider.makeContent()
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Color.gray.opacity(0.12))
    }
}

Beide ontwerpen behouden het bijbehorende type. Het verschil volgt rechtstreeks uit de plek van de methodeaanroep: initialisatie in het eerste ontwerp, evaluatie van de body in het tweede. Kies dat moment bewust en zorg dat de contentproductie geen werk bevat dat ervan afhangt precies één keer te worden aangeroepen.

Gebruik deze besliswijzer wanneer je het voorbeeld aanpast:

Vereiste van de component Declaratiestrategie Typerelatie
De provider en de geproduceerde view opslaan Generieke provider plus Provider.Content Behoudt zowel de identiteit van de provider als het contenttype
Content produceren tijdens de evaluatie van de body Sla de generieke provider op Behoudt het bijbehorende type zonder een afzonderlijke contentwaarde op te slaan
Providers met een bekend gemeenschappelijk contenttype opslaan Overweeg een existential met beperkingen Behoudt de opgegeven gelijkheid van contenttypen en verbergt de identiteit van de provider
Verschillende view-implementaties bewust uitwissen Gebruik AnyView op een gekozen grens Slaat het wrapper-type op in plaats van het oorspronkelijke viewtype beschikbaar te maken

Maken primaire bijbehorende typen generics overbodig?

Primaire bijbehorende typen bieden een kortere manier om geselecteerde beperkingen voor gelijke typen uit te drukken. Als je het protocol beheert, maakt deze alternatieve header Content primair; de overige protocolvereisten blijven hetzelfde:

// Alternative protocol header, requiring Swift 5.7 or later.
@MainActor
protocol TileProvider<Content> {
    associatedtype Content: View

    var title: String { get }
    func makeContent() -> Content
}

Met die declaratie beschrijft any TileProvider<Text> een existential waarvan het bijbehorende contenttype bekend is als Text. Verschillende providerimplementaties kunnen die opslag delen alleen als hun conformiteiten precies die gelijkheid van contenttypen opleveren. De syntaxis legt een beperking op aan een bijbehorend type; ze maakt het protocol niet tot een generieke struct. Zie SE-0346 en SE-0353.

Hiermee worden willekeurige onbekende viewtypen niet samengevoegd tot één behouden concreet type. CircleTile biedt met name een opaak contentresultaat, dus aanroepers kunnen dat resultaat niet zomaar behandelen als Text. Gebruik de oorspronkelijke generieke component wanneer het bijbehorende contenttype van de provider onbegrensd moet blijven.

Existentials met beperkingen zijn geïntroduceerd in Swift 5.7, en SE-0353 benoemt expliciet beperkingen rond runtime en achterwaartse implementatiedoelen. Ondersteuning door de compiler alleen is daarom onvoldoende om compatibiliteit met het implementatiedoel voor dat optionele ontwerp vast te stellen. Controleer de beschikbaarheidsdiagnostiek van de doeltoolchain voordat je dit overneemt. Het hoofdvoorbeeld is niet afhankelijk van opslag met existentials met beperkingen. Zie de ABI-bespreking van SE-0353.

Wat moet je controleren als compileren mislukt?

Volg het type van de protocolvereiste naar de opgeslagen eigenschap voordat je de structuur aanpast:

  1. Controleer of het protocol een bijbehorend type declareert dat aan View is gebonden.
  2. Controleer of de component een benoemde generieke providerparameter heeft die aan dat protocol is gebonden.
  3. Declareer opgeslagen content als Provider.Content, overeenkomstig het retourtype van de methode.
  4. Controleer of een bovenliggende component de provider al heeft omgezet naar any TileProvider.
  5. Controleer bij een modifier vóór de opslag het resultaattype: de aangepaste view is mogelijk niet langer Provider.Content.
  6. Controleer diagnostiek over actorisolatie apart van diagnostiek over bijbehorende typen.

Apple documenteert dat view-modifiers een view in een andere view wikkelen. Daardoor verandert het toewijzen van provider.makeContent().padding() aan een eigenschap die is gedeclareerd als Provider.Content het contract waaraan de toewijzing moet voldoen. Sla de oorspronkelijke content op en pas presentatiemodifiers toe in body, of herontwerp het opgeslagen type zodat het het aangepaste resultaat beschrijft. Zie View.

Builders hebben ook een versiegrens. Apple’s actuele documentatie beschrijft het gedrag van ViewBuilder voor Xcode 26 en eerder, en daarna ander gedrag voor het opbouwen van content vanaf Xcode 27. Het voorbeeld vermijdt een aangepaste buildervereiste: elke provider retourneert één expliciete expressie. Als je het uitbreidt met provider-API’s die builders gebruiken, volg dan de documentatie voor de Xcode-versie waarmee je daadwerkelijk bouwt. Zie ViewBuilder.

Als je bewust kiest voor AnyView, houd dan rekening met het gedocumenteerde gedrag: als het type van de ingepakte view verandert, wordt de oude hiërarchie vernietigd en een nieuwe aangemaakt. Dat is een afzonderlijke ontwerpbeslissing, los van het behouden van een bijbehorend type in de opslag. Apple documenteert dit in AnyView.

Behoud voor deze component de relatie bij de opslaggrens: een generieke Provider, een resultaat van Provider.Content en een opgeslagen eigenschap van datzelfde type. Zo kun je de interne lay-out van een provider wijzigen terwijl de compiler het bijbehorende viewtype door de component heen draagt.

Bronnen