Évitez une boucle géométrique dans SwiftUI en supprimant le chemin qui renvoie une taille mesurée vers le cadre qui l’a produite. Pour une fenêtre Mac contenant des étiquettes de même largeur, calculez leurs tailles dans un conteneur Layout au lieu de stocker les mesures dans l’état.

Ce guide crée un petit panneau d’étiquettes dans la fenêtre de votre propre app. Les étiquettes utilisent du texte local synthétique, ont la même largeur et passent à la ligne lorsque la fenêtre rétrécit. L’exemple complet cible macOS 13 ou ultérieur ; les sections suivantes expliquent comment appliquer la même rigueur de dimensionnement lorsque votre interface inclut des vues texte AppKit.

Comment une mesure géométrique devient-elle une boucle de mise en page ?

La mise en page SwiftUI repose sur des propositions et des réponses : un parent propose une taille et ses enfants indiquent les tailles qu’ils choisissent. Un conteneur personnalisé participe à cette négociation par l’intermédiaire de ProposedViewSize. Il est donc normal de mesurer une vue à plusieurs reprises ; ces mesures répétées ne suffisent pas à conclure qu’il y a un problème. Consultez la documentation d’Apple sur ProposedViewSize.

La dépendance problématique apparaît lorsqu’une mesure modifie une entrée de la mise en page mesurée. Par exemple, une étiquette indique sa largeur avec marges intérieures, une action enregistre cette largeur et un cadre englobant utilise la valeur enregistrée à laquelle il ajoute d’autres marges. Le cadre obtenu modifie la mesure suivante. Apple décrit explicitement cette boucle de rétroaction entre un lecteur géométrique et un cadre et recommande d’utiliser le système de mise en page pour ce type d’arrangement. Consultez Composer des mises en page personnalisées avec SwiftUI.

Un calcul simple montre pourquoi une vérification d’égalité peut ne pas suffire. Supposons qu’une implémentation illustrative mesure la largeur précédente du cadre à laquelle elle ajoute 16 points de marge intérieure, puis attribue le résultat à la largeur du cadre suivant. En partant de 120 points, on obtient 136, 152 et 168 points. Chaque résultat diffère du précédent : une garde qui empêche d’attribuer une valeur inchangée ne met donc jamais fin à cette croissance. Ces nombres illustrent la dépendance ; ils ne proviennent pas de mesures effectuées dans une app en cours d’exécution.

Suivez la dépendance dans votre propre code avant de choisir une solution :

  1. Repérez le lecteur géométrique, le gestionnaire de préférences ou l’action liée aux changements géométriques.
  2. Identifiez chaque valeur d’état qui reçoit ses mesures.
  3. Trouvez les cadres, les marges intérieures, les choix de police ou les vues conditionnelles qui dépendent de ces valeurs.
  4. Vérifiez si ces choix ont un effet sur la vue mesurée ou sur ses ancêtres.
  5. Remplacez temporairement la valeur mesurée par une constante. Si les changements répétés cessent, examinez d’abord ce chemin de dépendance.

Modifier la façon dont une mesure est transmise ne supprime pas nécessairement le cycle. Une préférence peut renvoyer la même taille au même ancêtre. Différer l’affectation à l’état peut déplacer la rétroaction vers une mise à jour ultérieure. La question utile est de savoir si la mesure suivante dépend encore du résultat précédent.

Quel mécanisme de dimensionnement choisir ?

Choisissez le mécanisme en fonction de la décision à prendre. Commencez par les conteneurs de mise en page intégrés ; Apple présente les implémentations personnalisées de Layout comme la solution à privilégier lorsqu’un comportement plus contrôlé est nécessaire. Consultez la documentation de Layout.

Résultat souhaité Approche prise en charge Condition clé
Disposer des étiquettes ordinaires à la verticale Une pile intégrée Laissez les enfants participer au dimensionnement normal.
Choisir une présentation large ou compacte ViewThatFits Fournissez les alternatives par ordre de préférence.
Donner à plusieurs étiquettes une largeur commune fondée sur leur contenu Un Layout personnalisé Mesurez et positionnez les enfants dans les méthodes de mise en page.
Réagir à la géométrie d’une vue existante onGeometryChange Observez uniquement la valeur nécessaire à l’action.
Dimensionner une vue AppKit intégrée NSViewRepresentable.sizeThatFits Renvoyez une taille préférée pour la proposition.

ViewThatFits sélectionne le premier enfant dont la taille idéale convient sur les axes contraints. C’est utile lorsque votre besoin réel consiste à choisir entre plusieurs présentations, par exemple une disposition horizontale et une solution de repli verticale. Cela peut éliminer une variable d’état distincte pour la largeur mesurée. Consultez la documentation d’Apple sur ViewThatFits.

Pour le panneau d’étiquettes de même largeur présenté ci-dessous, la règle de dimensionnement est explicite : utilisez la largeur idéale de l’étiquette la plus large, plafonnée à la largeur finie proposée. Mesurez ensuite la hauteur de chaque étiquette avec cette largeur. Ainsi, une fenêtre plus étroite peut augmenter la hauteur du panneau par le retour à la ligne, sans que les mesures géométriques passent par l’état de l’application.

Créer un panneau d’étiquettes de même largeur sans état géométrique

Dans Xcode, créez un projet d’app macOS utilisant SwiftUI, définissez sa cible de déploiement sur macOS 13 ou ultérieur, puis remplacez les déclarations générées de l’app et de la vue de contenu par cet exemple complet dans un seul fichier. Ne gardez qu’une seule déclaration d’app @main dans la cible. Le programme a été vérifié au regard des API documentées ; il n’a pas été compilé ni exécuté ici, car l’environnement disponible est Linux.

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

Les deux méthodes de mise en page requises se partagent le travail : sizeThatFits renvoie la taille du conteneur, tandis que placeSubviews positionne ses enfants. Les proxys enfants permettent de mesurer et de positionner les vues sans accéder directement aux vues sous-jacentes. Les deux méthodes respectent ces rôles documentés. Consultez la documentation d’Apple sur Layout.

Les détails importants de l’implémentation sont la proposition de largeur et sa réutilisation. Chaque enfant est mesuré avec une largeur définie et une hauteur non spécifiée, ce qui permet au texte de déterminer la hauteur nécessaire. Le positionnement utilise la largeur réelle des limites du conteneur ainsi que la même règle de hauteur. Le recours à bounds.minX et bounds.minY évite aussi de supposer que le positionnement commence à l’origine des coordonnées.

Le conteneur gère une collection vide en renvoyant zéro. Si la largeur n’est pas spécifiée, il choisit sa largeur idéale ; si elle est finie, il plafonne cette largeur idéale. Une proposition de largeur infinie produit également la largeur idéale finie pour ces étiquettes. Ce sont des règles délibérées pour ce panneau dimensionné selon son contenu. Apple indique que les parents peuvent sonder les mises en page avec des propositions nulles, infinies, non spécifiées ou autres pendant une même passe de mise en page. Consultez sizeThatFits(proposal:subviews:cache:).

L’exemple ne comporte aucun état contenant une taille mesurée, aucun rappel géométrique et aucune affectation qui renvoie une largeur rendue vers un cadre. Sa vue de défilement verticale permet au contenu qui s’étend avec les retours à la ligne de rester visible. La mise en page suppose que les enfants, comme les étiquettes fournies, peuvent réagir correctement à une largeur finie. Un enfant à largeur fixe qui dépasse l’espace disponible nécessite un choix de présentation distinct, comme un défilement horizontal ou une autre vue.

Quand est-il toujours pertinent d’observer la géométrie ?

L’observation géométrique reste utile lorsque la mesure déclenche une réaction limitée. L’API onGeometryChange d’Apple sépare une transformation d’une action : la transformation extrait une valeur équatable, et l’action s’exécute lorsque cette valeur transformée change. Apple recommande d’éviter les mises à jour générales de l’application, car la géométrie peut changer fréquemment. Consultez onGeometryChange(for:of:action:).

Par exemple, une superposition pourrait indiquer si une région englobante stable dépasse une largeur choisie. Observez cette région englobante et renvoyez un booléen depuis la transformation. Veillez à ce que la superposition ne contribue pas aux dimensions de la région. Cette conception suit le modèle documenté de transformation et d’action tout en supprimant la dépendance circulaire de dimensionnement.

Réduire un rectangle à un booléen limite les notifications, mais ne garantit pas à lui seul la stabilité. Si le passage à la présentation compacte modifie suffisamment la région mesurée pour revenir à la présentation précédente, le booléen peut alterner indéfiniment. Mesurez une région dont la largeur dépend de la mise en page de la fenêtre englobante, ou utilisez ViewThatFits pour prendre la décision de présentation dans la mise en page.

La disponibilité dépend aussi de la surcharge utilisée. L’action qui ne reçoit que la nouvelle valeur est prise en charge sur macOS 13 ou ultérieur, avec un SDK qui l’inclut ; la surcharge qui reçoit l’ancienne et la nouvelle valeur nécessite macOS 15 ou ultérieur. L’exemple complet évite cette distinction en utilisant uniquement Layout. Consultez la documentation d’Apple sur les changements géométriques et la surcharge qui reçoit l’ancienne et la nouvelle valeur avant de copier une signature de rappel dans une cible de déploiement plus ancienne.

Comment intégrer les mesures AppKit et TextKit à la mise en page SwiftUI ?

Lorsque le contenu est une vue AppKit, utilisez la limite de dimensionnement du type representable. NSViewRepresentable.sizeThatFits(_:nsView:context:) est disponible sur macOS 13 ou ultérieur, renvoie une taille préférée pour une proposition et peut être appelé plusieurs fois avec différentes propositions au cours d’une même passe. Renvoyer nil demande l’algorithme de dimensionnement par défaut. Traitez cet appel comme une requête de dimensionnement, et non comme un moyen de publier des dimensions mesurées dans l’état SwiftUI. Consultez la méthode de dimensionnement du representable.

SwiftUI contrôle frame et bounds de la vue AppKit représentée. Apple déconseille explicitement de définir vous-même ces propriétés de mise en page, car cela entre en conflit avec SwiftUI et entraîne un comportement indéfini. Configurez le contenu dans la méthode de mise à jour du representable et indiquez les dimensions au moyen du mécanisme de dimensionnement pris en charge. Consultez NSViewRepresentable.

Pour le texte, déterminez d’abord quel moteur de mise en page utilise la vue. TextKit 2 fournit NSTextLayoutManager.usageBoundsForTextContainer, disponible sur macOS 12 ou ultérieur, comme mesure de l’espace utilisé dans le conteneur de texte. Apple indique qu’on peut observer cette propriété au moyen de KVO. Cette observation peut permettre de demander un redimensionnement, mais ne dispense pas de maintenir la cohérence des dépendances entre largeur et hauteur. Consultez usageBoundsForTextContainer.

Dans une implémentation TextKit 1 existante, NSLayoutManager.usedRect(for:) renvoie la zone occupée par les glyphes déjà mis en page ; il ne génère pas de glyphes et n’effectue pas la mise en page. Si la mesure exige que la mise en page soit terminée, appelez ensureLayout(for:) pour le conteneur concerné avant de lire le rectangle occupé. Les deux API sont disponibles depuis macOS 10.7. Consultez usedRect(for:) et ensureLayout(for:).

N’accédez pas à la propriété héritée layoutManager d’une vue texte TextKit 2 simplement pour obtenir une mesure. Apple explique que l’accès au gestionnaire de mise en page hérité peut faire basculer une vue texte en mode de compatibilité TextKit 1. Vérifiez d’abord textLayoutManager et limitez le code de mesure hérité à la branche de repli appropriée. Consultez Nouveautés de TextKit et des vues texte.

Pour une vue texte avec retour à la ligne, faites de la largeur proposée une entrée de la mise en page du texte et déduisez la hauteur préférée de cette configuration. Tenez explicitement compte des marges intérieures de la vue texte et du conteneur ; un rectangle exprimé dans les coordonnées du conteneur n’est pas automatiquement la taille de la vue extérieure. Effectuez le travail sur l’interface AppKit dans le contexte d’isolation de l’acteur approprié. Le chapitre sur la concurrence de Swift explique l’exécution sur l’acteur principal ; déplacer le travail dans une tâche ne modifie pas la dépendance circulaire d’une formule de dimensionnement.

Comment vérifier que la mise en page se stabilise ?

Exécutez l’exemple dans Xcode et redimensionnez lentement la fenêtre dans les deux sens. Le comportement attendu est une largeur commune pour les étiquettes, un retour à la ligne aux largeurs plus étroites et un défilement vertical lorsque le contenu dépasse la hauteur visible. Répétez ensuite l’opération avec votre structure de vues réelle, après avoir supprimé la dépendance à l’état contenant les mesures.

Ces vérifications permettent de distinguer le travail normal de mise en page d’une rétroaction persistante :

  • Laissez la fenêtre immobile après l’avoir redimensionnée. Vérifiez si les mesures et les affectations d’état continuent de changer sans modification du contenu.
  • Testez avec une étiquette synthétique plus longue et une police plus grande. Vérifiez que le panneau peut s’agrandir en hauteur sans tronquer le texte.
  • Testez avec une collection d’étiquettes vide. La mise en page doit renvoyer zéro avant l’application des marges extérieures.
  • Examinez les propositions utilisées pour la mesure et le positionnement. Un enfant avec retour à la ligne doit recevoir des hypothèses de largeur cohérentes.
  • Si vous ajoutez une mise en cache par la suite, incluez les entrées de dimensionnement dans les règles de validité ; ne réutilisez pas des hauteurs calculées pour une autre largeur.

Plusieurs appels de dimensionnement au cours d’une même passe constituent un comportement documenté. En pratique, il faut obtenir un résultat stable lorsque le contenu et les contraintes ne changent pas. Gardez les mesures dans le processus de négociation de la mise en page lorsqu’elles déterminent l’agencement, et réservez l’observation géométrique aux réactions qui ne recréent pas les dimensions observées.

Sources