Pour construire un composant SwiftUI générique, exigez son initialiseur dans un protocole ou injectez une closure qui le renvoie. Ce tutoriel utilise les deux approches pour afficher le même élément synthétique dans une fenêtre macOS, avec des limites explicites concernant le compilateur et le SDK.

Que doit promettre un composant générique ?

Supposons qu’une app macOS affiche une petite fiche d’élément. Vous voulez qu’un panneau réutilisable construise la fiche à partir d’un élément, tout en laissant les appelants choisir la présentation. La question centrale est de savoir qui prend en charge la construction : l’interface déclarée du composant ou l’appelant qui fournit le composant.

Une contrainte générique donne à l’implémentation accès aux capacités énoncées par cette contrainte. Écrire Component: View établit que le composant est une vue SwiftUI ; cela n’établit pas que le composant possède un initialiseur nommé init(item:). Le chapitre sur les génériques de Swift explique les contraintes de protocole, tandis que la documentation de View d’Apple décrit la propriété body requise par une vue.

Par conséquent, ce fragment est volontairement invalide :

// Invalid fragment: View does not require init(item:).
@MainActor
func construct<Component: View>(item: Item) -> Component {
    Component(item: item)
}

Ajouter cet initialiseur à un composant concret ne le rend pas disponible pour tous les paramètres génériques contraints uniquement par View. L’API réutilisable a besoin d’un contrat de construction explicite. Consultez ce tableau de décision avant de modifier le composant :

Exigence de construction Interface appropriée Motif
Tous les composants concernés acceptent le même élément Protocole exigeant init(item:) La construction relève de l’interface de la famille de composants.
Les appelants choisissent une configuration ou des modificateurs supplémentaires Closure fabrique qui renvoie le composant générique La construction reste au point d’appel.
L’appelant dispose déjà d’un composant terminé Stocker la valeur du composant générique La construction a déjà eu lieu.
La présentation alterne entre un nombre fini de mises en page Composer les différentes possibilités dans une vue SwiftUI L’API environnante peut conserver une structure de vue concrète.

Ce tableau est une aide à la conception dérivée des règles du langage documentées. Les deux premières options sont mises en œuvre ensemble ci-dessous afin de comparer directement leur comportement.

Créer un exemple macOS complet

Utilisez une cible d’app macOS Xcode avec SwiftUI et une cible de déploiement macOS 11 ou ultérieure. L’exemple est écrit pour un compilateur Swift 6 avec le SDK Apple correspondant. Ses annotations explicites @MainActor exigent Swift 5.5 ou ultérieur ; ce code n’est donc pas destiné aux compilateurs antérieurs. Apple indique que WindowGroup est disponible à partir de macOS 11 et qu’il sert de conteneur à la hiérarchie des vues d’une app. Consultez WindowGroup.

Remplacez le fichier du point d’entrée de l’app générée par le programme complet suivant. Veillez à ce que la cible ne contienne qu’une seule déclaration d’app @main. Le fichier ContentView généré peut rester inutilisé. Aucun package, ressource externe ou jeu de données téléchargé n’est nécessaire.

import SwiftUI

struct Item {
    let title: String
    let detail: String
}

@MainActor
protocol ItemComponent: View {
    init(item: Item)
}

@MainActor
struct ItemCard: ItemComponent {
    let item: Item

    init(item: Item) {
        self.item = item
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(item.title)
                .font(.headline)
            Text(item.detail)
        }
        .padding()
        .background(Color.gray.opacity(0.12))
        .cornerRadius(8)
    }
}

@MainActor
struct ContractPanel<Component: ItemComponent>: View {
    let item: Item

    init(item: Item, component: Component.Type) {
        self.item = item
    }

    var body: some View {
        Component(item: item)
    }
}

@MainActor
struct FactoryPanel<Component: View>: View {
    private let makeComponent: @MainActor () -> Component

    init(make: @escaping @MainActor () -> Component) {
        self.makeComponent = make
    }

    var body: some View {
        makeComponent()
    }
}

@MainActor
struct DemoView: View {
    private let item = Item(
        title: "Sample tile",
        detail: "Blue circle on a neutral background"
    )

    var body: some View {
        VStack(alignment: .leading, spacing: 18) {
            Text("Initializer contract")
            ContractPanel(item: item, component: ItemCard.self)

            Text("Injected factory")
            FactoryPanel {
                ItemCard(item: item)
                    .padding(.leading, 12)
            }

            Text("Alternative factory output")
            FactoryPanel {
                Text(item.title + ": " + item.detail)
            }
        }
        .padding(24)
        .frame(width: 420)
    }
}

@main
@MainActor
struct ComponentDemoApp: App {
    var body: some Scene {
        WindowGroup {
            DemoView()
        }
    }
}

Exécutez la cible de l’app macOS. Le résultat attendu est une fenêtre contenant deux présentations sous forme de fiche et une présentation en texte simple. La deuxième fiche a un espacement intérieur supplémentaire au début, fourni par son appelant. Ce sont des exemples synthétiques du comportement de l’API, et non des résultats mesurés. Aucun compilateur Swift n’est installé dans l’environnement de recherche ; ce code a donc été examiné, mais n’a pas été compilé ni exécuté ici.

Les annotations de l’acteur principal rendent explicite la limite de construction de l’interface utilisateur. La déclaration actuelle de View par Apple est annotée avec @MainActor et @preconcurrency ; sa documentation explique aussi comment l’isolation est héritée lorsque la conformité apparaît dans la déclaration d’origine d’un type. Consultez View.

Comment fonctionne le contrat d’initialisation ?

ItemComponent affine View et ajoute exactement une exigence de construction : init(item:). Swift permet de déclarer directement des exigences d’initialisation dans les protocoles. Un type conforme fournit l’implémentation ; le protocole indique la signature sans corps d’initialiseur. Consultez Protocoles : exigences d’initialisation.

Cette exigence rend Component(item: item) valide dans ContractPanel. L’argument component: Component.Type fournit un type concret pour l’inférence générique : en passant ItemCard.self, le type de composant de ce panneau est déterminé comme étant ItemCard. Le panneau n’a pas besoin de stocker le métatype, car son body construit le composant à l’aide du paramètre de type générique.

Ce contrat est utile lorsque chaque composant doit pouvoir être construit à partir de la même entrée. Par exemple, une famille de présentations d’éléments compactes et développées appartenant à l’app pourrait partager cette exigence. L’appelant peut alors changer le type de composant sélectionné sans réécrire l’expression de construction du panneau.

Le compromis apparaît lorsqu’une implémentation a besoin d’une configuration supplémentaire. Exiger init(item:) signifie que chaque composant conforme doit proposer ce mode de construction, même si son initialiseur préféré accepte aussi une légende ou un paramètre de mise en page. Si ces informations supplémentaires relèvent de l’appelant, l’approche par fabrique ci-dessous exprime généralement l’exigence plus directement.

Pour les implémentations basées sur des classes, un initialiseur satisfaisant une exigence de protocole doit être required, sauf si la classe est final. Une sous-classe qui redéfinit aussi un initialiseur désigné correspondant de la superclasse peut devoir utiliser à la fois required et override. Les structures telles que ItemCard n’utilisent pas required. Ces distinctions sont documentées dans Protocoles.

Quand faut-il déplacer la construction dans une fabrique ?

FactoryPanel accepte une fonction de type @MainActor () -> Component. Sa seule promesse est que l’appel de cette fonction produit le type de composant sélectionné. La closure peut appeler un initialiseur, capturer une configuration ou renvoyer une vue avec des modificateurs appliqués.

Le programme illustre la différence concrète : la fabrique peut renvoyer un ItemCard avec un espacement intérieur, ou renvoyer Text, sans obliger l’un ou l’autre des types obtenus à adopter ItemComponent. Apple explique que les modificateurs de vue peuvent envelopper leur entrée dans une autre vue, ce qui explique l’intérêt de laisser le compilateur inférer ici le type obtenu par la fabrique. Consultez View.

L’initialiseur marque la closure @escaping parce que le panneau la stocke pour l’appeler plus tard. Le chapitre sur les closures de Swift documente les closures échappantes et leur capacité à capturer des valeurs du contexte environnant. Dans cet exemple, la closure capture l’élément synthétique utilisé par la vue environnante.

Choisissez délibérément les entrées de la fabrique. Une fabrique sans argument signifie que l’appelant capture tout ce qui est nécessaire à la construction. Si le panneau choisit lui-même un élément, une fabrique paramétrée peut exprimer cette responsabilité plus clairement. Voici une définition de composant de remplacement à utiliser avec la définition existante de Item ; ce n’est pas une seconde app complète :

@MainActor
struct ItemFactoryPanel<Component: View>: View {
    let item: Item
    private let makeComponent: @MainActor (Item) -> Component

    init(
        item: Item,
        make: @escaping @MainActor (Item) -> Component
    ) {
        self.item = item
        self.makeComponent = make
    }

    var body: some View {
        makeComponent(item)
    }
}

Avec cette définition, le panneau fournit l’élément et l’appelant fournit la présentation. Limitez le travail de la fabrique à la production de la valeur de vue : le contrat de construction de l’exemple ne promet ni exécution unique, ni stockage persistant, ni mise en cache.

Les fabriques ont-elles besoin d’un générateur de résultats ?

Les fabriques du programme complet sont des closures ordinaires. Chacune renvoie une expression, donc aucune annotation personnalisée de générateur n’est nécessaire. Si plusieurs vues enfants sont requises, renvoyer un seul VStack suffit pour cette API ; la pile contient les vues enfants.

Un générateur de résultats devient utile lorsque le paramètre de la fabrique doit lui-même accepter une séquence déclarative d’expressions ou du contenu conditionnel. Swift Evolution indique que la fonctionnalité normalisée des générateurs de résultats a été implémentée dans Swift 5.4. Consultez SE-0289 : générateurs de résultats.

Respectez les limites du SDK lorsque vous adoptez la syntaxe des générateurs. La documentation actuelle d’Apple décrit @ViewBuilder pour les paramètres qui produisent des vues dans Xcode 26 et les versions antérieures. Pour Xcode 27 et les versions ultérieures, elle décrit une construction indépendante du type et recommande l’alias ContentBuilder pour signaler ce comportement. Consultez ViewBuilder et ContentBuilder. La closure ordinaire utilisée ici évite d’exiger cet alias plus récent.

Pourquoi les types opaques ou existentiels ne remplacent-ils pas le contrat ?

some View masque un type de résultat sous-jacent spécifique tout en préservant son identité. Cette syntaxe convient bien aux propriétés body de ce programme. Elle n’ajoute pas init(item:) à l’interface de View. Une valeur encapsulée écrite any View masque son type sous-jacent d’une autre manière et ne préserve pas la même identité de type statique. Consultez Types de protocoles opaques et encapsulés.

N’en tirez pas une affirmation générale selon laquelle les valeurs existentielles ne peuvent jamais être transmises à des fonctions génériques. SE-0352 a introduit les existentiels ouverts implicitement dans Swift 5.7, permettant à des appels admissibles de lier un paramètre générique au type sous-jacent d’un existentiel. La proposition documente également des restrictions, notamment pour les résultats qui intègrent le type ouvert dans un wrapper générique lorsque le résultat ne peut pas être représenté après effacement. Consultez Existentiels ouverts implicitement.

Pour ce panneau, conserver le type concret du composant au moyen d’un paramètre générique suffit. Il n’est pas nécessaire d’introduire un stockage existentiel simplement pour laisser les appelants choisir comment construire une vue.

Comment diagnostiquer une erreur de construction ?

Suivez l’expression qui échoue et examinez le contrat disponible à cet endroit :

  1. Si Component(item:) échoue, vérifiez que la contrainte générique inclut un protocole exigeant exactement cette signature d’initialiseur.
  2. Si le compilateur ne parvient pas à inférer Component, fournissez un métatype concret tel que ItemCard.self, ou une fabrique avec une expression de retour concrète.
  3. Si un composant a besoin d’une configuration propre à l’appelant, déplacez la construction dans la fabrique au lieu d’ajouter des exigences sans rapport au protocole.
  4. Si le stockage d’une closure déclenche un diagnostic de closure échappante, vérifiez l’annotation @escaping de l’initialiseur.
  5. Si la construction déclenche un diagnostic d’isolation d’acteur, vérifiez que le point d’appel et le type de fonction de la fabrique expriment tous deux la limite souhaitée de l’acteur principal.
  6. Si un existentiel provoque une erreur liée au résultat générique, examinez les restrictions de type de résultat de SE-0352 avant de supposer qu’une mise à niveau du compilateur supprimera cette limitation.

Pour une famille de composants macOS partagée, exigez l’initialiseur lorsque la construction uniforme fait partie de l’interface. Utilisez une fabrique injectée lorsque l’appelant doit assembler et configurer la présentation. Ces deux approches rendent explicite la promesse de construction manquante tout en préservant le type concret de composant dont le panneau a besoin.

Sources