Pour faire tenir un NSTextView avec retour à la ligne dans un cadre SwiftUI, représentez un NSScrollView, acceptez les dimensions proposées finies via sizeThatFits et laissez le document texte s’agrandir verticalement à l’intérieur. Configurez le conteneur de texte pour qu’il suive la largeur de l’éditeur afin que les longues lignes reviennent à la ligne.

Ce tutoriel construit un éditeur de texte brut pour votre propre app Mac, avec une zone visible de taille limitée. L’ajout de texte doit permettre le défilement vertical sans agrandir la disposition SwiftUI. L’exemple complet cible macOS 13 ou ultérieur, où Apple met à disposition le rappel de dimensionnement du representable. Documentation Apple sur sizeThatFits

Pourquoi un NSTextView peut-il sembler ignorer un cadre SwiftUI ?

Trois dimensions doivent être coordonnées. SwiftUI dimensionne la vue AppKit représentée. Un NSScrollView fournit la zone visible. Sa vue document contient le texte, tandis qu’un NSTextContainer détermine la zone disponible pour la mise en page du texte. Apple décrit la vue document comme la vue que le scroll view fait défiler à l’intérieur de sa vue de contenu. Documentation Apple sur documentView

Un .frame(width:height:) SwiftUI crée un cadre de disposition englobant. Il ne configure pas à lui seul le comportement de retour à la ligne du conteneur de texte. Apple explique aussi que les limites d’un élément enfant peuvent dépasser celles du cadre englobant. Une bordure est donc utile pour le diagnostic : elle peut avoir les dimensions demandées même si le contenu représenté se comporte différemment. Documentation Apple sur les cadres

La contrainte centrale concerne la propriété. Apple indique explicitement que SwiftUI contrôle frame et bounds de la vue AppKit représentée ; modifier ces propriétés vous-même entre en conflit avec SwiftUI. Renvoyez plutôt votre taille préférée via le rappel de dimensionnement du representable. Dans cette implémentation, la vue représentée est le scroll view, tandis qu’AppKit gère son document texte interne. Documentation Apple sur NSViewRepresentable

Choisissez le comportement avant de modifier les indicateurs de disposition :

Comportement souhaité Politique de dimensionnement externe Politique du document texte
Panneau d’édition fixe Accepter la largeur et la hauteur fournies Retour à la ligne horizontal ; agrandissement vertical et défilement
Éditeur remplissant un panneau de taille limitée Accepter les dimensions finies proposées par le panneau Retour à la ligne horizontal ; agrandissement vertical et défilement
Éditeur qui s’agrandit pour afficher tout le texte Calculer la hauteur pour la largeur proposée Coordonner la mesure du contenu et les mises à jour de disposition

Les deux premières lignes correspondent à la tâche couverte par l’exemple exécutable. La troisième nécessite une politique distincte de hauteur fondée sur le contenu ; accepter la hauteur proposée ne suffit pas à l’implémenter.

Construire un éditeur avec retour à la ligne et zone visible limitée

Créez une app SwiftUI pour macOS dans Xcode et définissez sa cible de déploiement sur macOS 13 ou ultérieur. Ajoutez le fichier suivant sous le nom FramedTextEditor.swift. Le second bloc de code fournit le point d’entrée de l’app et un exemple de contenu ; ensemble, les deux fichiers constituent l’exemple complet.

La configuration suit l’agencement documenté par Apple pour le défilement vertical : la vue texte s’agrandit verticalement, sa largeur suit sa supervue et le conteneur de texte suit la largeur de la vue texte. Le guide d’assemblage d’Apple est archivé ; l’exemple utilise donc les API Swift actuelles et sélectionne explicitement TextKit 2 via NSTextView(usingTextLayoutManager: true). Apple explique ce choix d’initialiseur dans sa présentation de TextKit. Guide Apple sur les vues texte défilantes, Nouveautés de TextKit et des vues texte

import AppKit
import SwiftUI

@MainActor
struct FramedTextEditor: NSViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator {
        Coordinator($text)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.borderType = .noBorder
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.autohidesScrollers = true

        let textView = NSTextView(usingTextLayoutManager: true)
        // This is the internal document, not the represented root view.
        textView.setFrameSize(scrollView.contentSize)
        textView.minSize = .zero
        textView.maxSize = NSSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        textView.isVerticallyResizable = true
        textView.isHorizontallyResizable = false
        textView.autoresizingMask = [.width]
        textView.isRichText = false
        textView.importsGraphics = false
        textView.font = .systemFont(ofSize: 14)
        textView.textContainerInset = NSSize(width: 8, height: 8)

        guard let container = textView.textContainer else {
            preconditionFailure("Expected a text container")
        }
        container.size = NSSize(
            width: max(1, scrollView.contentSize.width),
            height: CGFloat.greatestFiniteMagnitude
        )
        container.widthTracksTextView = true
        container.heightTracksTextView = false

        textView.string = text
        textView.delegate = context.coordinator
        scrollView.documentView = textView
        return scrollView
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        context.coordinator.text = $text
        guard let textView = nsView.documentView as? NSTextView else {
            preconditionFailure("Expected an NSTextView document")
        }
        if textView.string != text {
            textView.string = text
        }
    }

    func sizeThatFits(
        _ proposal: ProposedViewSize,
        nsView: NSScrollView,
        context: Context
    ) -> CGSize? {
        let ideal = CGSize(width: 320, height: 180)
        let proposed = proposal.replacingUnspecifiedDimensions(by: ideal)
        return CGSize(
            width: proposed.width.isFinite
                ? max(0, proposed.width) : ideal.width,
            height: proposed.height.isFinite
                ? max(0, proposed.height) : ideal.height
        )
    }

    static func dismantleNSView(
        _ nsView: NSScrollView,
        coordinator: Coordinator
    ) {
        (nsView.documentView as? NSTextView)?.delegate = nil
    }

    @MainActor
    final class Coordinator: NSObject, NSTextViewDelegate {
        var text: Binding<String>

        init(_ text: Binding<String>) {
            self.text = text
            super.init()
        }

        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else {
                return
            }
            if text.wrappedValue != textView.string {
                text.wrappedValue = textView.string
            }
        }
    }
}

Remplacez le point d’entrée et la vue de contenu générés par Xcode par ce second fichier. Ne conservez qu’une seule déclaration @main dans la cible. Tous les libellés et textes sont fictifs, et l’exemple s’exécute entièrement dans sa propre fenêtre d’app.

import SwiftUI

@main
@MainActor
struct FrameSizingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            EditorDemo()
        }
    }
}

@MainActor
struct EditorDemo: View {
    @State private var compact = false
    @State private var text = Array(
        repeating: "Sample note: arrange the blue tiles beside the green tiles.",
        count: 16
    ).joined(separator: "\n\n")

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Toggle("Compact editor", isOn: $compact)

            FramedTextEditor(text: $text)
                .frame(width: compact ? 280 : 420, height: 180)
                .border(Color.secondary)

            Text("Character count: \(text.count)")
                .font(.caption)
        }
        .padding(20)
        .frame(minWidth: 460, minHeight: 280, alignment: .topLeading)
    }
}

Ces fichiers ont été examinés, mais n’ont pas été compilés ni exécutés ici : l’environnement disponible est Linux et ne dispose ni de l’exécutable Swift ni des frameworks macOS. Compilez et vérifiez l’app dans Xcode en suivant la procédure ci-dessous.

Comment fonctionnent le dimensionnement et les paramètres de retour à la ligne ?

Accepter les propositions finies sans mesurer le document

Pour cet éditeur, une largeur et une hauteur proposées finies deviennent les dimensions préférées de la zone d’affichage. Le repli 320 × 180 est un choix de conception donné à titre d’exemple pour les dimensions non spécifiées ou non finies. Il ne s’agit pas d’une taille de texte mesurée. Une valeur zéro reste zéro afin qu’une vérification de taille minimale ne devienne pas une taille minimale positive arbitraire.

SwiftUI peut demander différentes tailles au cours d’une même passe de disposition. Ses propositions standard incluent les requêtes de taille minimale, maximale et idéale. Cet exemple fournit volontairement une valeur de repli finie pour une proposition non bornée ; il est destiné à un panneau d’édition de taille limitée, et non à une politique de dimensionnement universelle pour tous les conteneurs. Renvoyer nil sélectionnerait plutôt l’algorithme de dimensionnement par défaut de SwiftUI. Documentation Apple sur ProposedViewSize, Rappel de dimensionnement d’Apple pour les representables

Le rappel ne modifie pas le cadre du scroll view, ne remplace pas le texte et n’écrit pas dans l’état SwiftUI. Son rôle est de répondre à la demande de dimensionnement. Dans cette implémentation, la longueur du texte ne détermine donc pas la hauteur externe de l’éditeur.

Faire suivre à la largeur celle de la zone d’affichage et à la hauteur celle du contenu

Les deux paramètres de largeur ont des rôles différents. Le masque d’agrandissement automatique .width de la vue texte interne fait suivre sa largeur aux changements de sa supervue. Le widthTracksTextView = true du conteneur de texte fait suivre sa largeur de mise en page aux changements de la vue texte. Ces deux relations sont importantes lorsque l’éditeur devient plus étroit. Documentation Apple sur autoresizingMask, Documentation Apple sur widthTracksTextView

Verticalement, isVerticallyResizable = true permet à la vue document de modifier sa hauteur pour s’adapter au texte. La grande hauteur du conteneur de texte laisse de la place à la mise en page verticale, tandis que heightTracksTextView = false évite de lier cette limite de mise en page au redimensionnement de chaque vue document. Le scroll view reste la zone visible de taille limitée. Documentation Apple sur le redimensionnement vertical, Documentation Apple sur heightTracksTextView

Prévoyez un peu d’espace entre la bordure et le texte. L’exemple ajoute explicitement un retrait de huit points au conteneur de texte. Le texte peut aussi avoir un retrait supplémentaire dû au remplissage des fragments de ligne du conteneur ; la largeur de la zone d’affichage ou de la vue texte ne correspond donc pas nécessairement à la largeur exacte occupée par les caractères. Documentation Apple sur textContainerInset

Séparer les mises à jour du texte de la négociation des dimensions

Le coordinateur transmet les modifications de l’édition via textDidChange, et la liaison les relie à l’état du parent. updateNSView gère les changements renvoyés par SwiftUI. Sa vérification d’égalité évite de remplacer la chaîne de l’éditeur lorsque la même valeur revient après une modification ordinaire. Documentation Apple sur le délégué de changement de texte, Documentation Apple sur Binding

Une chaîne entrante différente est traitée comme un remplacement complet. Si votre app remplace aussi le texte par programmation pendant l’édition, définissez comment préserver la sélection, l’historique d’annulation et la composition en cours avant d’étendre cet exemple. La démonstration elle-même ne modifie la chaîne qu’au moyen de l’éditeur.

L’exemple marque ses types d’interface utilisateur et son coordinateur avec @MainActor. Le guide de concurrence de Swift décrit l’isolation de l’acteur principal comme le mécanisme qui protège l’état de l’interface utilisateur et impose l’exécution du code annoté sur cet acteur. Le langage de programmation Swift : concurrence

Vérifier le résultat et repérer les problèmes de disposition restants

Effectuez ces vérifications dans l’app exemple. Elles distinguent le dimensionnement de la zone d’affichage, le retour à la ligne, le défilement et la synchronisation de l’état, au lieu d’attribuer chaque symptôme à un problème de cadre SwiftUI.

  1. Observez la bordure de l’éditeur. La zone initiale demandée est de 420 × 180 points.
  2. Activez Compact editor. La largeur doit passer à 280 points, tandis que la hauteur reste de 180 points. Les phrases doivent revenir à la ligne sur davantage de lignes.
  3. Faites défiler les paragraphes fournis. Le document doit défiler à l’intérieur de la même bordure.
  4. Saisissez des lignes supplémentaires. Le nombre de caractères doit changer sans que la hauteur demandée de l’éditeur augmente.
  5. Supprimez le texte. La zone d’affichage doit conserver les dimensions demandées même si elle contient peu de texte.
Symptôme Première vérification
La bordure a la bonne taille, mais les longues lignes ne reviennent pas à la ligne Vérifiez le masque d’agrandissement automatique de la largeur du document ainsi que widthTracksTextView
L’ajout de texte agrandit la disposition environnante Vérifiez que le representable renvoie le scroll view et ne renvoie pas la hauteur du contenu via sizeThatFits
Impossible d’atteindre les lignes suivantes Vérifiez le défilement vertical, le redimensionnement vertical du document et la limite de hauteur du conteneur de texte
L’éditeur reste étroit dans une autre disposition parente Examinez la largeur proposée par ce parent ; le rappel accepte les propositions finies sans inventer d’espace supplémentaire
La position du texte change lors de mises à jour d’état sans rapport Recherchez les affectations inconditionnelles à textView.string

Ces vérifications constituent une séquence de diagnostic déduite des relations de dimensionnement ci-dessus, et non des résultats de tests d’exécution rapportés.

Que faut-il changer pour une hauteur déterminée par le contenu ou pour les anciennes versions de macOS ?

Un éditeur qui s’agrandit pour afficher toutes les lignes répond à un besoin différent. Il nécessite un calcul de la hauteur du contenu pour la largeur proposée, ainsi qu’une politique définissant une éventuelle hauteur maximale. Activer simplement le redimensionnement vertical du document interne ne communique pas une nouvelle hauteur préférée à SwiftUI. De même, accepter la hauteur proposée, comme le fait cet exemple, ne permet pas de calculer une hauteur dictée par le texte.

Conservez le moteur de texte choisi lors de l’ajout de la mesure. Apple explique que l’accès à layoutManager d’une instance de NSTextView peut déclencher le mode de compatibilité TextKit 1. L’équivalent documenté dans TextKit 2 pour obtenir les limites d’utilisation du conteneur de texte est NSTextLayoutManager.usageBoundsForTextContainer. Cet éditeur de taille limitée n’a besoin d’aucune de ces API de mesure. Conseils d’Apple pour la migration de TextKit

La limite de version compte également. NSViewRepresentable est disponible depuis macOS 10.15, mais son rappel sizeThatFits nécessite macOS 13. Pour les cibles de déploiement antérieures, conservez l’agencement avec document défilant et utilisez le comportement de dimensionnement par défaut de l’ancien representable, avec les priorités de dimensionnement d’AppKit ou un conteneur conçu pour cette cible. Ne vous attendez pas à ce que ce rappel contrôle la disposition sur ces versions ; vérifiez cette implémentation séparément. Documentation Apple sur les representables, Disponibilité du rappel de dimensionnement d’Apple

Pour un éditeur Mac de taille limitée, définissez clairement les responsabilités : SwiftUI alloue le rectangle visible, le scroll view contient le document qui s’agrandit, et le conteneur de texte suit la largeur disponible. Cette organisation permet de diagnostiquer concrètement le dimensionnement du cadre sans intégrer la mesure du texte à un éditeur de hauteur fixe.

Sources