Pour que le texte SwiftUI passe à la ligne dans une cellule NSTableView, donnez une largeur définie à la vue hébergée, reliez sa hauteur à la cellule avec Auto Layout et activez le calcul automatique de la hauteur des lignes. Ce guide crée une petite liste d’étiquettes Mac selon cette configuration.

La tâche est précise : afficher dans votre propre app des étiquettes fictives de différentes longueurs, dans des lignes suffisamment hautes pour montrer leur contenu quand la largeur disponible change. L’exemple cible macOS 13 ou une version ultérieure et utilise un tableau basé sur des vues.

Comment SwiftUI et NSTableView partagent-ils le calcul des dimensions ?

Trois couches interviennent. Le tableau alloue le rectangle de la cellule. Auto Layout relie la cellule à la NSHostingView qu’elle contient. SwiftUI détermine comment l’étiquette s’adapte à l’espace proposé. Apple décrit NSHostingView comme le pont qui communique à AppKit les préférences de taille du contenu SwiftUI pendant la mise en page. NSHostingView

Pour le retour à la ligne du texte, la largeur doit être établie avant que la hauteur ait un sens. Une étiquette peut tenir sur une ligne dans une colonne large et en occuper plusieurs dans une colonne étroite. Définir le cadre initial d’une vue hébergée ne fixe pas durablement la mise en page d’une cellule de tableau : Apple précise que le tableau définit le cadre de la vue renvoyée par son délégué de cellule. tableView(_:viewFor:row:)

L’implémentation ci-dessous suit donc une seule configuration de dimensionnement : le tableau fournit l’espace horizontal, la vue hébergée est contrainte aux bords de la cellule avec des marges intérieures horizontales, et l’étiquette SwiftUI conserve sa taille verticale idéale tout en acceptant la largeur proposée. Le tableau utilise Auto Layout pour calculer la hauteur des lignes lorsque usesAutomaticRowHeights est activé. usesAutomaticRowHeights

Ne considérez pas intrinsicContentSize et fittingSize comme des mesures interchangeables. Apple définit fittingSize comme la plus petite taille qui satisfait les contraintes de la vue et celles de ses sous-vues. Par conséquent, l’interroger sans les contraintes de largeur prévues ne permet pas de savoir quelle doit être la hauteur de la cellule pour la largeur actuelle de la colonne. fittingSize

Créer un tableau d’étiquettes complet pour macOS 13 et les versions ultérieures

Dans Xcode, créez un projet d’app macOS avec l’interface SwiftUI et une cible de déploiement macOS 13 ou ultérieure. Remplacez la déclaration d’app générée par le premier fichier ci-dessous, puis ajoutez les deux autres fichiers Swift à la même cible d’app. Ces trois fichiers forment ensemble un programme complet ; conservez une seule déclaration @main.

La version macOS 13 est requise en raison de l’utilisation explicite de NSHostingView.sizingOptions. La vue hébergée elle-même est disponible depuis macOS 10.15, et le calcul automatique de la hauteur des lignes du tableau depuis macOS 10.13. Une cible de déploiement plus ancienne nécessite une vérification de disponibilité autour de sizingOptions ou une version de l’implémentation qui omet cette personnalisation. NSHostingView, sizingOptions, usesAutomaticRowHeights

Créer l’app et les exemples de données fictives

Le premier fichier, LabelSizingApp.swift, fournit trois étiquettes et un bouton qui modifie la dernière. Vous pouvez ainsi vérifier la mise en page initiale et le comportement lors d’une mise à jour du contenu.

import SwiftUI

@main
struct LabelSizingApp: App {
    @State private var expanded = false

    private var labels: [String] {
        [
            "Amber tile",
            "Blue tile with a longer descriptive label that can wrap.",
            expanded
                ? "Green tile with several descriptive words. This longer label makes it easier to inspect wrapping when the window becomes narrower."
                : "Green tile"
        ]
    }

    var body: some Scene {
        WindowGroup {
            VStack(alignment: .leading, spacing: 12) {
                Button("Toggle longer label") {
                    expanded.toggle()
                }
                LabelTable(labels: labels)
            }
            .padding()
            .frame(minWidth: 300, minHeight: 240)
        }
    }
}

Relier la vue hébergée à la cellule

Dans HostedLabelCell.swift, le contenu SwiftUI accepte la largeur disponible et demande sa hauteur idéale. Le modificateur fixedSize appliqué à l’axe concerné est important : fixer l’axe horizontal demanderait au texte de conserver sa largeur idéale, ce qui peut le faire dépasser les limites de son parent. fixedSize(horizontal:vertical:)

import AppKit
import SwiftUI

struct RowLabel: View {
    let text: String

    var body: some View {
        Text(verbatim: text)
            .font(.system(size: 14))
            .fixedSize(horizontal: false, vertical: true)
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.vertical, 8)
    }
}

@MainActor
final class HostedLabelCell: NSTableCellView {
    private let host = NSHostingView(
        rootView: RowLabel(text: "")
    )

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)

        host.translatesAutoresizingMaskIntoConstraints = false
        host.sizingOptions = [.intrinsicContentSize]
        addSubview(host)

        NSLayoutConstraint.activate([
            host.leadingAnchor.constraint(
                equalTo: leadingAnchor, constant: 12
            ),
            host.trailingAnchor.constraint(
                equalTo: trailingAnchor, constant: -12
            ),
            host.topAnchor.constraint(equalTo: topAnchor),
            host.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }

    required init?(coder: NSCoder) {
        fatalError("This sample creates cells programmatically.")
    }

    func configure(text: String) {
        host.rootView = RowLabel(text: text)
    }
}

Les quatre contraintes de bord relient la vue hébergée à la cellule. Désactiver la traduction du masque de redimensionnement automatique sur la vue hébergée imbriquée permet à ces contraintes explicites de la régir, comme le recommande Apple pour les vues dont les contraintes nécessitent un ajustement dynamique. Le tableau reste responsable du positionnement de la cellule externe. translatesAutoresizingMaskIntoConstraints

Ici, sizingOptions conserve le dimensionnement du contenu intrinsèque sans demander les contraintes de taille minimale et maximale de la vue hébergée. Apple indique que ces comportements peuvent être sélectionnés séparément ; définir la propriété sur un ensemble d’options vide ne crée aucune de ces contraintes dérivées du contenu. Conserver le dimensionnement du contenu intrinsèque est un choix d’implémentation pour cette cellule dont le contenu détermine la taille, et non un réglage universel pour toute vue hébergée. sizingOptions

Activer le calcul automatique de la hauteur des lignes et configurer les cellules réutilisées

Ajoutez LabelTable.swift pour créer la vue de défilement, le tableau et son délégué. Le programme utilise volontairement le dimensionnement automatique sans implémenter de délégué de hauteur de ligne manuel.

import AppKit
import SwiftUI

@MainActor
struct LabelTable: NSViewRepresentable {
    let labels: [String]

    func makeCoordinator() -> Coordinator {
        Coordinator(labels: labels)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let table = NSTableView()
        let column = NSTableColumn(
            identifier: Coordinator.cellID
        )
        column.title = "Label"
        column.width = 360
        column.minWidth = 120
        column.resizingMask = .autoresizingMask
        table.addTableColumn(column)

        table.headerView = nil
        table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle
        table.intercellSpacing = NSSize(width: 0, height: 0)
        table.usesAutomaticRowHeights = true
        table.dataSource = context.coordinator
        table.delegate = context.coordinator

        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        scrollView.documentView = table
        return scrollView
    }

    func updateNSView(
        _ scrollView: NSScrollView,
        context: Context
    ) {
        guard let table = scrollView.documentView as? NSTableView,
              context.coordinator.labels != labels else {
            return
        }

        context.coordinator.labels = labels
        table.reloadData()
    }

    @MainActor
    final class Coordinator: NSObject,
        NSTableViewDataSource, NSTableViewDelegate {
        static let cellID = NSUserInterfaceItemIdentifier("LabelCell")
        var labels: [String]

        init(labels: [String]) {
            self.labels = labels
            super.init()
        }

        func numberOfRows(in tableView: NSTableView) -> Int {
            labels.count
        }

        func tableView(
            _ tableView: NSTableView,
            viewFor tableColumn: NSTableColumn?,
            row: Int
        ) -> NSView? {
            let cell = (
                tableView.makeView(
                    withIdentifier: Self.cellID,
                    owner: self
                ) as? HostedLabelCell
            ) ?? HostedLabelCell(frame: .zero)

            cell.identifier = Self.cellID
            cell.configure(text: labels[row])
            return cell
        }
    }
}

Le délégué tente de réutiliser une vue avant d’en créer une, puis lui attribue l’étiquette actuelle à chaque fois. Cette séquence suit la méthode recommandée par Apple pour construire les vues de cellule. makeView(withIdentifier:owner:) peut renvoyer une vue réutilisée ou renvoyer nil si aucune vue correspondante n’est disponible. tableView(_:viewFor:row:), makeView(withIdentifier:owner:)

L’exemple ne recharge le tableau que lorsque son tableau d’entrée change. Un rechargement complet est facile à suivre pour trois lignes, mais Apple précise qu’il supprime puis récupère les vues de ligne et de cellule visibles. Dans une application plus grande, choisissez une stratégie de mise à jour plus ciblée si nécessaire. Construisez et mettez à jour les vues AppKit sur l’acteur principal ; Swift indique que MainActor utilise un exécuteur équivalent à la file de distribution principale. reloadData(), MainActor

Comment diagnostiquer un contenu tronqué ou des lignes trop hautes ?

Commencez par la largeur, examinez ensuite la propagation de la hauteur, puis les mises à jour. Cet ordre suit les dépendances de la mise en page et aide à éviter de modifier plusieurs réglages sans rapport entre eux à la fois.

Symptôme Première vérification Correction utile
Le texte long dépasse horizontalement de la cellule Le dimensionnement idéal horizontal est-il activé ? Utilisez fixedSize(horizontal: false, vertical: true) pour cette étiquette avec retour à la ligne.
Le texte passe à la ligne, mais les dernières lignes sont tronquées La vue hébergée est-elle reliée aux deux bords verticaux de la cellule ? Rétablissez les contraintes supérieure et inférieure et supprimez les hauteurs fixes conflictuelles.
Toutes les lignes ont la même hauteur Le dimensionnement automatique est-il activé, et existe-t-il une implémentation manuelle de la hauteur ? Vérifiez la configuration du tableau et choisissez une stratégie de dimensionnement cohérente.
Le texte court apparaît dans une cellule inutilement haute Le contenu comporte-t-il une hauteur fixe ou un espace vertical supplémentaire ? Examinez la hiérarchie SwiftUI et ses marges intérieures avant de modifier le tableau.
Une étiquette modifiée affiche l’ancien contenu La cellule est-elle reconfigurée après la modification du modèle ? Mettez à jour la vue racine affichée ou rechargez le contenu concerné.

Établissez un budget de largeur pour rendre l’inspection concrète. Si la cellule fait, à titre d’illustration, 320 points de large, les deux marges intérieures horizontales de 12 points de l’exemple laissent 296 points à la vue hébergée. Les marges intérieures verticales ajoutent 16 points au total. La hauteur requise restante dépend de la disposition réelle du texte ; ces nombres ne permettent pas de prévoir combien de lignes occupera une étiquette donnée.

Le cadre SwiftUI extensible établit l’alignement à gauche dans l’espace horizontal disponible. Il n’attribue pas une largeur physique illimitée : la documentation d’Apple sur les cadres explique que le cadre participe aux propositions de son parent et applique les contraintes de taille spécifiées. frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)

Exécutez l’exemple avec l’étiquette longue, avec une fenêtre large puis étroite, puis repassez à l’étiquette courte. Vérifiez que tout le texte reste visible et que la ligne peut à nouveau rétrécir. Effectuez ces vérifications sur les versions de macOS prises en charge. Le code a été examiné à la lumière de la documentation d’API récupérée ; il n’a pas été compilé ni exécuté ici, car cet environnement ne dispose ni d’un compilateur Swift ni d’un environnement d’exécution macOS.

Que change le calcul manuel de la hauteur des lignes ?

Le dimensionnement manuel constitue une approche d’implémentation distincte. Si vous en avez besoin, calculez la hauteur du contenu à la largeur prévue en dehors du délégué de hauteur, puis demandez au délégué de renvoyer le résultat préparé. Une clé de cache utile comprend l’élément, la largeur disponible et tous les paramètres d’affichage qui modifient la mise en page, comme la police ou les marges intérieures.

Apple exige qu’une hauteur de ligne renvoyée manuellement soit supérieure à zéro et exclue l’espacement entre les cellules. Sa documentation interdit également d’obtenir les vues de cellule ou de ligne dans tableView(_:heightOfRow:) et met en garde contre les requêtes géométriques susceptibles de déclencher une mise en page récursive du tableau. Mesurer une cellule visible depuis ce rappel constitue donc une base peu fiable pour une implémentation manuelle. tableView(_:heightOfRow:)

Lorsque les hauteurs préparées changent, informez le tableau avec noteHeightOfRows(withIndexesChanged:). Apple précise que cette méthode redispose immédiatement le tableau selon les hauteurs du délégué si cette méthode du délégué est implémentée. Le redimensionnement de la colonne doit également invalider les mesures qui dépendent de la largeur ; columnDidResizeNotification est la notification documentée pour cet événement. Ce sont des points à prendre en compte si vous remplacez l’approche automatique par un cache manuel. noteHeightOfRows(withIndexesChanged:), columnDidResizeNotification

TextKit doit-il mesurer une étiquette SwiftUI ?

TextKit est utile lorsque votre app met réellement en page du texte au moyen d’un conteneur TextKit. La mesure documentée a une portée plus limitée que celle de l’ensemble d’une cellule SwiftUI : NSLayoutManager.usedRect(for:) renvoie les limites des glyphes déjà mis en page dans le conteneur spécifié et ne génère pas les glyphes ni n’effectue la mise en page. Appelez ensureLayout(for:) avant de lire cette mesure si la mise en page n’a pas encore été effectuée. usedRect(for:), ensureLayout(for:)

Tenez également compte de la configuration du conteneur. Apple indique que la valeur par défaut de lineFragmentPadding est de 5 points à chaque extrémité d’un fragment de ligne, ce qui influe sur la largeur disponible pour la mise en page. Apple précise également que cette propriété n’est pas destinée à définir les marges du texte. lineFragmentPadding

On peut en déduire qu’une mesure TextKit configurée séparément ne suffit pas, à elle seule, à établir la hauteur de cette hiérarchie SwiftUI. La cellule comprend également le comportement de mise en page SwiftUI et des marges intérieures. Pour ce tableau d’étiquettes, gardez le calcul des dimensions dans la chaîne qui passe par la vue hébergée et Auto Layout, puis vérifiez le retour à la ligne, les modifications du contenu et la réduction de la hauteur aux largeurs prises en charge par votre app.

Sources