Gestalte deine SwiftUI-Komponente generisch über den Typ, der dem Protokoll entspricht, und deklariere dann den gespeicherten View als Provider.Content. So bleibt die Beziehung zwischen dem Provider und seinem zugehörigen View-Typ in der gesamten Komponente erhalten.

Dieses Tutorial erstellt ein kleines macOS-Fenster mit zwei künstlich erzeugten Kacheln. Dabei sollen ein Provider und der von ihm erzeugte View gespeichert werden, ohne den zugehörigen Typ durch any View oder AnyView zu ersetzen. Dasselbe Muster eignet sich, wenn eine wiederverwendbare Desktop-Komponente ein benutzerdefiniertes Label, Zubehör oder einen Content-Provider akzeptiert.

Welche Beziehung müssen die gespeicherten Eigenschaften erhalten?

Ein zugehöriger Typ bezeichnet einen Typ, der von jeder Protokollkonformität festgelegt wird. Deklariert ein Protokoll associatedtype Content: View, muss ein konformer Provider einen bestimmten Content-Typ bereitstellen. Generischer Code kann auf diesen Typ als Provider.Content verweisen, auch wenn seine Implementierung hinter einem opaken Rückgabewert verborgen ist. Swift beschreibt zugehörige Typen und generische Einschränkungen im Kapitel Generics.

Die Komponente benötigt daher zwei miteinander verbundene Deklarationen: einen generischen Parameter, der auf das Provider-Protokoll eingeschränkt ist, und eine gespeicherte Content-Eigenschaft, deren Typ dem zugehörigen Typ dieses Parameters entspricht. Zwei unabhängige Eigenschaften zu deklarieren, die lediglich Protokolle erfüllen, drückt diese Verbindung nicht aus.

SwiftUI verwendet selbst einen ähnlichen Vertrag: View deklariert associatedtype Body: View, und Swift leitet diesen zugehörigen Typ aus der body-Implementierung eines benutzerdefinierten Views ab. Das Content deines Providers und das Body der Komponente sind separate zugehörige Typen; sie müssen nicht identisch sein. Siehe Apples Dokumentation zu Body.

Ein vollständiges macOS-Beispiel erstellen

Verwende für dieses Beispiel ein Xcode-macOS-App-Projekt mit SwiftUI und einem Deployment Target für macOS 13 oder neuer. Ersetze die generierten App- und Content-View-Deklarationen durch die folgende einzelne Datei und behalte genau eine @main-Deklaration im Target bei. Apple dokumentiert diese App-Einstiegsstruktur und die Verwendung von WindowGroup unter App.

Das Beispiel verwendet bewusst die etablierte Syntax für zugehörige Typen und benannte Generics. Opake Rückgabewerte wurden in Swift 5.1 eingeführt; die später besprochene optionale Syntax für primäre zugehörige Typen erfordert Swift 5.7. Die vollständige Datei ist für eine aktuelle Swift-6-Toolchain vorgesehen. Sie wurde anhand der abgerufenen Dokumentation geprüft, hier jedoch nicht kompiliert, da in dieser Umgebung kein Swift-Compiler verfügbar ist. Das gewählte macOS-Deployment-Target ist eine Voraussetzung des Beispiels und keine Behauptung über die älteste Betriebssystemversion, die jede zugrunde liegende Funktion unterstützt. Siehe SE-0244 und 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()
        }
    }
}

Erstelle das macOS-Target und führe es aus. Das erwartete Ergebnis ist ein Fenster mit einer Textkachel und einer Kachel mit einem blauen Kreis neben der Beschriftung. Beide verwenden dieselbe generische Komponente, wobei der Provider-Typ an jeder Aufrufstelle unabhängig abgeleitet wird.

Die expliziten @MainActor-Annotationen sorgen dafür, dass diese UI-erzeugenden Deklarationen im selben Actor-Kontext liegen. Apples aktuelle View-Dokumentation deklariert das Protokoll mit Main-Actor-Isolation. Die Annotationen machen außerdem den vorgesehenen Kontext des benutzerdefinierten Provider-Protokolls explizit.

Warum erhält die generische Komponente den Typ?

Bei der ersten Karte ist Provider gleich TextTile, also ist Provider.Content gleich Text. Bei der zweiten Karte ist Provider gleich CircleTile, und ihr zugehöriger Content-Typ ist das opake Ergebnis von CircleTile.makeContent(). Du kannst diesen Typ über CircleTile.Content benennen, ohne den verschachtelten SwiftUI-Layouttyp auszuschreiben.

Die entscheidende Zuweisung lautet self.content = provider.makeContent(). Das Protokoll garantiert, dass diese Methode genau Provider.Content zurückgibt, also genau den deklarierten Typ der gespeicherten Eigenschaft. Diese Beziehung wird zur Compile-Zeit aus der generischen Einschränkung abgeleitet und beruht nicht auf einem Laufzeit-Typvergleich. Das Kapitel Generics von Swift erklärt den Zugriff auf zugehörige Typen über eingeschränkte generische Parameter.

Die Verwendung von some View in der konformen Implementierung hebt diese Beziehung nicht auf. Ein opaker Rückgabewert verbirgt die konkrete Implementierung, bewahrt aber ihre Typidentität. Daher eignet er sich als Beleg für einen zugehörigen Typ. Swift erklärt den Unterschied unter Opake und verpackte Protokolltypen.

Diese Karten haben unterschiedliche Spezialisierungen: TileCard<TextTile> und TileCard<CircleTile>. Die Wiederverwendung der Quelldeklaration erfordert nicht, dass jede Karte denselben universellen Typ für den gespeicherten Content hat.

Warum lösen unabhängige opake oder existenzielle Eigenschaften das Problem nicht?

Die folgenden Ausschnitte dienen der Diagnose und ersetzen nicht das vollständige Programm:

// 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

Eine opake gespeicherte Eigenschaft benötigt einen Initialisierer in der Deklaration, der ihren zugrunde liegenden Typ festlegt. Mit some View entsteht kein generischer Parameter, dessen Typ vom Aufrufer gewählt und später von einem beliebigen Komponenteninitialisierer festgelegt werden kann. Beispielsweise legt let content: some View = Text("Sample") einen Typ für diese Deklaration fest; es verknüpft die Eigenschaft nicht mit dem zugehörigen Typ einer anderen Eigenschaft. Diese Regel für gespeicherte Eigenschaften ist in SE-0244: Opaque Result Types dokumentiert.

Ein Existenzial wie any TileProvider erlaubt die Speicherung verschiedener konformer Provider-Typen. Es verbirgt den jeweiligen konformen Typ, sodass die Deklaration die Beziehung Provider.Content nicht mehr offenlegt. Ebenso ist any View ein existenzieller Container und nicht derselbe Vertrag wie ein benannter generischer Typ, der View erfüllt. Siehe Swifts Erklärung zu verpackten Protokolltypen.

Swift kann existenzielle Argumente für bestimmte generische Aufrufe implizit öffnen. Dadurch wird nicht jedes Existenzial für jeden generischen Initialisierer verwendbar. Insbesondere schränkt SE-0352 das Öffnen ein, wenn der unbekannte zugrunde liegende Typ in einem generischen Ergebnis wie X<T> entweichen würde. Eine Spezialisierung einer generischen Komponente kann genau an diese Grenze stoßen. Behalte den Provider generisch, bevor du die Karte erstellst, statt anzunehmen, ein Existenzial lasse sich immer wieder in eine dauerhafte Spezialisierung umwandeln. Siehe SE-0352: Implicitly Opened Existentials.

Den Speicher danach wählen, wann der Content erzeugt werden soll

Das Beispiel ruft makeContent() bei jeder Initialisierung von TileCard auf und speichert den zurückgegebenen View-Wert. Es richtet keinen dauerhaften Cache über spätere Komponenteninitialisierungen hinweg ein. Beide Eigenschaften beizubehalten zeigt die gewünschte Typbeziehung; eine Komponente, die nur den resultierenden View benötigt, könnte den gespeicherten Provider weglassen.

Wenn die Komponente den Content immer dann anfordern soll, wenn ihr body ausgewertet wird, behalte nur den Provider. Die folgende Komponentendeklaration ersetzt die bisherige und wird mit dem obigen Protokoll und der App verwendet:

@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 Entwürfe erhalten den zugehörigen Typ. Der Unterschied ergibt sich direkt daraus, an welcher Stelle der Methodenaufruf steht: Initialisierung beim ersten Entwurf, Auswertung des Bodys beim zweiten. Wähle diesen Zeitpunkt bewusst und vermeide bei der Content-Erzeugung Arbeit, die voraussetzt, dass sie genau einmal aufgerufen wird.

Nutze diese Entscheidungshilfe, wenn du das Beispiel anpasst:

Anforderung der Komponente Deklarationsstrategie Typbeziehung
Den Provider und seinen erzeugten View speichern Generischer Provider plus Provider.Content Erhält sowohl die Identität des Providers als auch seinen Content-Typ
Den Content während der Body-Auswertung erzeugen Den generischen Provider speichern Erhält den zugehörigen Typ, ohne einen separaten Content-Wert zu speichern
Provider mit einem bekannten gemeinsamen Content-Typ speichern Ein eingeschränktes Existenzial erwägen Erhält die festgelegte Content-Gleichheit und verbirgt zugleich die Provider-Identität
Unterschiedliche View-Implementierungen bewusst auslöschen AnyView an einer gewählten Grenze verwenden Speichert den Wrapper-Typ statt den ursprünglichen View-Typ offenzulegen

Machen primäre zugehörige Typen Generics überflüssig?

Primäre zugehörige Typen bieten eine kürzere Möglichkeit, ausgewählte Gleichheitsbedingungen für Typen auszudrücken. Wenn dir das Protokoll gehört, macht dieser alternative Header Content zum primären Typ; die übrigen Protokollanforderungen bleiben unverändert:

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

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

Mit dieser Deklaration beschreibt any TileProvider<Text> ein Existenzial, dessen zugehöriger Content-Typ als Text bekannt ist. Verschiedene Provider-Implementierungen können diesen Speichertyp nur dann gemeinsam verwenden, wenn ihre Konformitäten genau diese Content-Gleichheit aufweisen. Die Syntax schränkt einen zugehörigen Typ ein; sie macht das Protokoll nicht zu einer generischen Struktur. Siehe SE-0346 und SE-0353.

Dadurch werden beliebige unbekannte View-Typen nicht zu einem einzigen erhaltenen konkreten Typ zusammengeführt. Insbesondere stellt CircleTile ein opakes Content-Ergebnis bereit, sodass Aufrufer dieses Ergebnis nicht einfach als Text behandeln können. Verwende die ursprüngliche generische Komponente, wenn der zugehörige Content des Providers uneingeschränkt bleiben soll.

Eingeschränkte Existenziale wurden in Swift 5.7 eingeführt, und SE-0353 nennt ausdrücklich Laufzeit- und Abwärtskompatibilitätsgrenzen. Compilerunterstützung allein reicht daher nicht aus, um die Deployment-Kompatibilität dieses optionalen Entwurfs festzustellen. Prüfe vor der Einführung die Verfügbarkeitsdiagnosen der Ziel-Toolchain. Das Hauptbeispiel ist nicht auf die Speicherung eingeschränkter Existenziale angewiesen. Siehe die ABI-Erörterung in SE-0353.

Was solltest du prüfen, wenn die Kompilierung fehlschlägt?

Verfolge den Typ von der Protokollanforderung bis zur gespeicherten Eigenschaft, bevor du das Layout änderst:

  1. Prüfe, ob das Protokoll einen auf View eingeschränkten zugehörigen Typ deklariert.
  2. Prüfe, ob die Komponente einen benannten generischen Provider-Parameter hat, der auf dieses Protokoll eingeschränkt ist.
  3. Deklariere den gespeicherten Content als Provider.Content, passend zum Rückgabetyp der Methode.
  4. Prüfe, ob ein übergeordnetes Element den Provider bereits in any TileProvider umgewandelt hat.
  5. Wenn du vor der Speicherung einen Modifier hinzufügst, prüfe dessen Ergebnistyp: Der modifizierte View ist möglicherweise nicht mehr Provider.Content.
  6. Prüfe Diagnosen zur Actor-Isolation getrennt von Diagnosen zu zugehörigen Typen.

Apple dokumentiert, dass View-Modifier einen View in einen anderen View verpacken. Daher ändert die Zuweisung von provider.makeContent().padding() zu einer als Provider.Content deklarierten Eigenschaft den Vertrag, den die Zuweisung erfüllen muss. Speichere den ursprünglichen Content und wende Präsentationsmodifier in body an, oder gestalte den gespeicherten Typ so um, dass er das modifizierte Ergebnis beschreibt. Siehe View.

Auch bei Buildern gibt es eine Versionsgrenze. Apples aktuelle Dokumentation beschreibt das Verhalten von ViewBuilder für Xcode 26 und frühere Versionen und anschließend ein anderes Verhalten beim Erstellen von Content ab Xcode 27. Das Beispiel verzichtet auf eine benutzerdefinierte Builder-Anforderung: Jeder Provider gibt einen expliziten Ausdruck zurück. Wenn du es um Builder-gesteuerte Provider-APIs erweiterst, richte dich nach der Dokumentation für die Xcode-Version, mit der du tatsächlich erstellst. Siehe ViewBuilder.

Wenn du dich bewusst für AnyView entscheidest, berücksichtige sein dokumentiertes Verhalten: Ändert sich der Typ des umschlossenen Views, wird die alte Hierarchie zerstört und eine neue erstellt. Das ist eine separate Entwurfsentscheidung und hat nichts damit zu tun, einen zugehörigen Typ im Speicher zu erhalten. Apple dokumentiert dies unter AnyView.

Behalte für diese Komponente die Beziehung an ihrer Speichergrenze sichtbar bei: ein generischer Provider, ein Ergebnis vom Typ Provider.Content und eine gespeicherte Eigenschaft desselben Typs. So kannst du das interne Layout eines Providers ändern, während der Compiler seinen zugehörigen View-Typ durch die Komponente trägt.

Quellen