Pour stocker différentes formes SwiftUI dans un même ensemble tout en imposant une interface commune, utilisez un wrapper concret conforme à Hashable avec un initialiseur contraint par un protocole. Ce tutoriel crée une petite palette de formes pour Mac, avec des règles explicites pour les doublons et des opérations de forme appelables.

La tâche est précise : regrouper des composants synthétiques en forme de disque et de tuile, rejeter les valeurs sans rapport à la compilation, éliminer les doublons et afficher les composants restants dans une fenêtre SwiftUI. La collection accepte plusieurs types de composants grâce à un protocole commun qui affine Shape de SwiftUI.

Pourquoi un ensemble de valeurs de protocole ne satisfait-il pas ce contrat ?

Le type des éléments d’un Set doit être conforme à Hashable. Déclarer que chaque valeur contenue dans un existentiel est conforme à Hashable ne confère pas à ce type existentiel sa propre conformité. Par conséquent, Set<any PaletteShape> ne devient pas valide simplement parce que PaletteShape hérite de Hashable. Apple documente la contrainte imposée aux éléments de la collection, tandis que Swift Evolution explique la différence entre un conteneur existentiel et la valeur conforme qu’il contient. Set, Implicitly Opened Existentials.

Un existentiel tel que any PaletteShape peut contenir différents types conformes. La difficulté concerne l’égalité : l’exigence Equatable héritée compare deux valeurs de même type Self, alors que deux conteneurs existentiels peuvent contenir des types concrets différents. La prise en charge étendue des valeurs existentielles par Swift ne fournit pas d’opération d’égalité universelle pour ces conteneurs. Unlock existentials for all protocols.

Ces solutions répondent à des aspects différents du problème :

Représentation Types concrets mélangés ? Interface commune imposée ? Convient comme élément d’un ensemble ?
[any PaletteShape] Oui Oui Un tableau ne garantit pas l’unicité des éléments
Set<S> avec S: PaletteShape Un seul S choisi Oui Oui, pour ce type concret unique
Set<AnyHashable> Oui Aucune contrainte de forme Oui
Set<AnyPaletteShape> ci-dessous Oui, dans les wrappers Oui, à la construction Oui

AnyHashable fournit l’égalité et le hachage avec effacement de type, mais son initialiseur accepte de manière générale les valeurs hachables. Un ensemble qui ne contient que ce type pourrait donc aussi accepter une chaîne ou un entier. Le wrapper personnalisé ci-dessous limite les entrées au protocole de formes de l’application et conserve l’interface de forme ainsi que la valeur Hashable avec effacement de type. AnyHashable.

Qu’est-ce qui doit compter comme une forme en double ?

Décidez de l’égalité avant d’implémenter le wrapper. Dans cette palette, deux composants sont des doublons s’ils ont le même type concret et des valeurs stockées égales. Un disque étiqueté Sample et une tuile étiquetée Sample restent deux entrées distinctes. Deux disques portant la même étiquette sont réduits à une seule entrée.

L’étiquette est l’unique propriété stockée dans chaque composant d’exemple ; l’égalité et le hachage synthétisés en tiennent donc compte. Il s’agit d’un choix de modélisation illustratif : une simple ressemblance visuelle ne définit pas l’égalité. Apple documente la synthèse automatique de Hashable pour les structures dont les propriétés stockées sont hachables et exige que les valeurs égales transmettent les mêmes composants à leurs calculateurs de hachage. Hashable.

Le wrapper inclut également un identifiant de type dans l’égalité et le hachage. La règle « même type » est ainsi explicite, au lieu de reposer sur des suppositions concernant les comparaisons avec effacement de type. AnyHashable peut comparer certaines valeurs de types sous-jacents différents au moyen de représentations canoniques ; Apple ne le définit pas comme un discriminateur inconditionnel de type concret. ObjectIdentifier prend en charge les métatypes, ce qui permet à cet exemple d’identifier le type générique d’entrée. AnyHashable, ObjectIdentifier.

Créer l’exemple complet de palette Mac

Utilisez dans Xcode un projet d’application SwiftUI pour macOS, avec un compilateur Swift 5.7 ou ultérieur. Pour disposer d’une base reproductible, sélectionnez le mode de langage Swift 5 et une cible de déploiement macOS 13.0 ou ultérieure. Cette cible de déploiement est un choix prudent pour ce tutoriel, et non l’affirmation que toutes les API ci-dessous nécessitent macOS 13.

La version minimale du compilateur est importante, car l’exemple stocke any PaletteShape, dont les protocoles hérités incluent des exigences de type associé et de Self. SE-0309 indique que cette prise en charge étendue des existentiels a été implémentée dans Swift 5.7. L’exemple ne nécessite pas de fonctionnalité plus récente de conformité généralisée des existentiels. Unlock existentials for all protocols.

Remplacez la déclaration d’application et la vue de contenu générées par le fichier source complet suivant. Ne conservez qu’une seule déclaration @main dans la cible de l’application. L’exemple comprend les définitions des composants, le wrapper, les valeurs d’exemple, les vérifications, la vue et le point d’entrée de l’application. Swift n’est pas disponible dans l’environnement de recherche ; ce code a donc été examiné, mais n’a pas été compilé ni exécuté ici.

import SwiftUI

protocol PaletteShape: Shape, Hashable {
    var label: String { get }
}

struct Disk: PaletteShape {
    let label: String

    func path(in rect: CGRect) -> Path {
        Path(ellipseIn: rect)
    }
}

struct Tile: PaletteShape {
    let label: String

    func path(in rect: CGRect) -> Path {
        var result = Path()
        result.addRect(rect)
        return result
    }
}

struct AnyPaletteShape: Hashable {
    private let base: any PaletteShape
    private let typeID: ObjectIdentifier
    private let key: AnyHashable

    init<S: PaletteShape>(_ value: S) {
        base = value
        typeID = ObjectIdentifier(S.self)
        key = AnyHashable(value)
    }

    var label: String { base.label }

    func path(in rect: CGRect) -> Path {
        base.path(in: rect)
    }

    static func == (
        lhs: AnyPaletteShape,
        rhs: AnyPaletteShape
    ) -> Bool {
        lhs.typeID == rhs.typeID && lhs.key == rhs.key
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(typeID)
        hasher.combine(key)
    }
}

func makePalette() -> Set<AnyPaletteShape> {
    var result: Set<AnyPaletteShape> = [
        AnyPaletteShape(Disk(label: "Disk")),
        AnyPaletteShape(Tile(label: "Tile"))
    ]

    let duplicate = result.insert(
        AnyPaletteShape(Disk(label: "Disk"))
    )
    precondition(!duplicate.inserted)
    precondition(result.count == 2)
    precondition(result.contains(
        AnyPaletteShape(Tile(label: "Tile"))
    ))

    let sameLabel: Set<AnyPaletteShape> = [
        AnyPaletteShape(Disk(label: "Sample")),
        AnyPaletteShape(Tile(label: "Sample"))
    ]
    precondition(sameLabel.count == 2)

    return result
}

struct ContentView: View {
    private let shapes = makePalette()

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Shape Palette")
                .font(.title)

            ForEach(
                shapes.sorted { $0.label < $1.label },
                id: \.self
            ) { item in
                HStack(spacing: 16) {
                    GeometryReader { geometry in
                        item.path(in: CGRect(
                            origin: .zero,
                            size: geometry.size
                        ))
                        .fill(Color.blue)
                    }
                    .frame(width: 56, height: 56)

                    Text(item.label)
                }
            }
        }
        .padding(24)
        .frame(minWidth: 300, minHeight: 220)
    }
}

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

PaletteShape hérite du véritable protocole Shape de SwiftUI. Le disque et la tuile fournissent path(in:), l’exigence de dessin documentée par Apple. Leurs implémentations utilisent les opérations Path documentées pour créer une ellipse et un rectangle. Le cadre carré de l’affichage donne à l’ellipse d’exemple l’apparence d’un cercle. Shape, Path.

L’initialiseur contraint définit la règle d’entrée : init<S: PaletteShape> n’accepte que les types conformes au protocole commun. Les propriétés stockées sont privées ; les appelants ne peuvent donc pas remplacer séparément la forme conservée ou sa clé d’égalité. AnyPaletteShape("Disk") devrait provoquer une erreur de conformité à la compilation, car String n’est pas conforme à PaletteShape ; laissez ce rejet délibéré hors du fichier exécutable.

Bien que l’ensemble ait un seul type concret d’élément, chaque wrapper peut conserver une forme concrète différente. Ses membres label et path(in:) transmettent les opérations nécessaires à cette palette. Le wrapper lui-même déclare être conforme à Hashable ; il ne déclare pas être conforme à Shape.

Comment vérifier l’insertion, l’appartenance et l’ordre d’affichage ?

Exécutez l’application dans une version de débogage. Les vérifications incluses expriment trois résultats attendus : l’insertion d’un second disque égal renvoie inserted à false, l’appartenance trouve une tuile égale et des types de composants différents restent distincts même lorsque leurs étiquettes sont identiques. Apple indique que insert(_:) renvoie un indicateur d’insertion ainsi que le membre obtenu. Set.

La fenêtre attendue contient deux lignes, Disk puis Tile, avec des graphiques bleus. Le tri est une opération d’affichage distincte, car un ensemble n’a pas d’ordre d’éléments défini. Le tableau obtenu satisfait aussi à l’exigence de collection à accès aléatoire de ForEach ; id: \.self utilise la valeur hachable du wrapper comme identifiant de ligne. Set, ForEach.

Cet ordre convient aux valeurs fournies, car leurs étiquettes diffèrent. Si de futurs composants partagent des étiquettes, ajoutez un critère secondaire déterministe au tri. Si les lignes deviennent modifiables et doivent conserver leur identité lorsque les étiquettes changent, introduisez un identifiant stable et décidez séparément si cet identifiant participe à l’égalité des éléments de l’ensemble.

Ne vous attendez pas à ce qu’une insertion dans un ensemble remplace la valeur d’un composant existant qui lui est égal. Lorsqu’un remplacement est souhaité, Apple fournit update(with:). Cette distinction compte si un modèle ultérieur exclut de l’égalité les propriétés de présentation : l’insertion d’une valeur égale peut conserver le membre existant au lieu d’actualiser son apparence. Set.

Quelles sont les limites du wrapper ?

Le stockage Hashable ne préserve pas toutes les capacités de SwiftUI

Le wrapper transmet la génération du chemin pour une palette statique. Il ne transmet ni les données animables de la forme sous-jacente, ni les opérations de réduction, ni tous les membres hérités de View. Le dessin du chemin renvoyé ne constitue donc pas un effaceur de forme préservant les animations. Shape de SwiftUI hérite aussi de Animatable et de View ; transmettre une seule méthode de dessin ne recrée pas l’ensemble de ce contrat. Shape.

Si une autre API a besoin d’une forme concrète avec effacement de type, SwiftUI fournit AnyShape, disponible sur macOS 13 et les versions ultérieures. Son rôle documenté est d’effacer le type concret d’une forme, et ses conformités répertoriées n’incluent pas Hashable. Il peut répondre à un besoin de rendu, mais ne fournit ni la politique d’égalité ni l’élément d’ensemble contraint par un protocole construits ici. AnyShape.

La prise en charge des nouveaux compilateurs ne supprime pas la contrainte sur les éléments d’un ensemble

SE-0352, implémenté dans Swift 5.7, permet d’ouvrir le type sous-jacent d’un existentiel pour certains appels génériques. Son texte actuel décrit aussi le comportement en mode de langage Swift 6. Ouvrir une valeur pour un appel ne transforme pas any PaletteShape en un type Hashable adapté au paramètre de type des éléments de l’ensemble. Conservez le wrapper concret si vous appliquez cette conception à ces différentes générations de compilateurs. Implicitly Opened Existentials.

L’extension du modèle exige une décision sur l’égalité

L’exemple utilise des structures immuables. Lors de l’ajout de dimensions ou d’autres paramètres stockés, décidez si leur modification crée un membre distinct de l’ensemble. Le hachage synthétisé inclura les propriétés stockées hachables supplémentaires. Pour une égalité personnalisée, hachez précisément les mêmes composants essentiels, dans le même ordre ; des valeurs de hachage identiques ne suffisent jamais à établir l’égalité. Hashable.

Si l’application a un ensemble de variantes de composants fixé de façon permanente, une énumération associant des valeurs hachables constitue une autre solution raisonnable : elle centralise les cas pris en charge. Le wrapper convient lorsque d’autres types de composants conformes doivent pouvoir être ajoutés via le même initialiseur, sans étendre une liste centrale de cas. Ce choix dépend des besoins d’extension de l’application.

Pour cette palette Mac, l’implémentation associe chaque exigence à un élément concret : le protocole définit les formes acceptées, l’initialiseur contrôle l’entrée, l’égalité définit les doublons et la transmission du chemin permet le rendu. Ajoutez de nouveaux composants immuables conformes en les enveloppant lors de leur insertion, puis complétez les vérifications chaque fois que la définition d’un doublon change.

Sources