Per adattare un NSTextView con testo a capo a un frame SwiftUI, rappresenta un NSScrollView, accetta dimensioni proposte finite tramite sizeThatFits e lascia che il documento di testo cresca verticalmente al suo interno. Configura il contenitore di testo in modo che segua la larghezza dell’editor, così le righe lunghe vanno a capo.

Questo tutorial crea un editor di testo semplice per la tua app Mac, con un’area visibile di dimensioni limitate. Aggiungendo testo, si attiva lo scorrimento verticale senza ingrandire il layout SwiftUI. L’esempio completo è destinato a macOS 13 o versioni successive, in cui Apple rende disponibile il callback di dimensionamento del representable. Documentazione Apple di sizeThatFits

Perché un NSTextView può sembrare ignorare un frame SwiftUI?

Ci sono tre dimensioni da coordinare. SwiftUI dimensiona la vista AppKit rappresentata. Un NSScrollView fornisce l’area visibile. La sua vista del documento contiene il testo, mentre un NSTextContainer determina l’area disponibile per il layout del testo. Apple documenta la vista del documento come la vista che la vista di scorrimento fa scorrere all’interno della propria vista contenuto. Documentazione Apple di documentView

Un .frame(width:height:) SwiftUI crea un frame di layout contenitore. Non configura autonomamente il comportamento di ritorno a capo del contenitore di testo. Apple spiega inoltre che i limiti di una vista figlia possono estendersi oltre il frame contenitore. Per questo un bordo è utile nella diagnosi: può avere le dimensioni richieste anche quando il contenuto rappresentato si comporta diversamente. Documentazione Apple sui frame

Il vincolo centrale riguarda la proprietà. Apple afferma esplicitamente che SwiftUI controlla frame e bounds della vista AppKit rappresentata; modificarle autonomamente entra in conflitto con SwiftUI. Restituisci invece la dimensione preferita tramite il callback di dimensionamento del representable. In questa implementazione, la vista rappresentata è la vista di scorrimento, mentre AppKit gestisce il documento di testo al suo interno. Documentazione Apple di NSViewRepresentable

Scegli il comportamento prima di modificare i flag di layout:

Comportamento desiderato Criterio di dimensionamento esterno Criterio del documento di testo
Pannello editor di dimensioni fisse Accetta larghezza e altezza fornite Va a capo orizzontalmente; cresce verticalmente e scorre
Editor che riempie un riquadro delimitato Accetta le dimensioni proposte finite del riquadro Va a capo orizzontalmente; cresce verticalmente e scorre
Editor che si espande per mostrare tutto il testo Calcola l’altezza in base alla larghezza proposta Coordina la misurazione del contenuto e gli aggiornamenti del layout

Le prime due righe descrivono il caso trattato dall’esempio eseguibile. La terza richiede un criterio distinto per l’altezza del contenuto; accettare l’altezza proposta non basta a implementarlo.

Crea un editor con testo a capo e area delimitata

Crea un’app macOS SwiftUI in Xcode e imposta come destinazione di distribuzione macOS 13 o successivo. Aggiungi il file seguente come FramedTextEditor.swift. Il secondo blocco di codice fornisce il punto di ingresso dell’app e un esempio di contenuto; insieme, i due file formano l’esempio completo.

La configurazione segue la disposizione documentata da Apple per lo scorrimento verticale: la vista di testo cresce verticalmente, la sua larghezza segue quella della supervista e il contenitore di testo segue la larghezza della vista di testo. La guida di assemblaggio di Apple è archiviata, quindi l’esempio usa le API Swift attuali e seleziona esplicitamente TextKit 2 tramite NSTextView(usingTextLayoutManager: true). Apple documenta questa scelta dell’inizializzatore nella sua presentazione di TextKit. Guida Apple alle viste di testo con scorrimento, Novità in TextKit e nelle viste di testo

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

Sostituisci il punto di ingresso dell’app generato e la vista contenuto con questo secondo file. Mantieni una sola dichiarazione @main nel target. Tutte le etichette e i testi sono fittizi e l’esempio opera interamente nella propria finestra dell’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)
    }
}

Questi file sono stati esaminati, ma qui non sono stati compilati né eseguiti: l’ambiente disponibile è Linux e non dispone di un eseguibile Swift o dei framework macOS. Compila e verifica l’app in Xcode seguendo la sequenza di verifica riportata sotto.

Come funzionano le impostazioni di dimensionamento e ritorno a capo?

Accetta le proposte finite senza misurare il documento

Per questo editor, una larghezza e un’altezza proposte finite diventano le dimensioni preferite dell’area visibile. Il valore predefinito di 320 × 180 è una scelta progettuale esemplificativa per dimensioni non specificate o non finite. Non rappresenta una misura del testo. Lo zero resta zero, così una verifica della dimensione minima non diventa una dimensione minima positiva arbitraria.

SwiftUI può richiedere dimensioni diverse durante uno stesso passaggio di layout. Le sue proposte standard includono richieste di dimensioni minime, massime e ideali. Questo esempio fornisce intenzionalmente un valore finito predefinito per una proposta non limitata; è pensato per un pannello editor delimitato, non come criterio di dimensionamento generico per ogni contenitore. Restituire nil selezionerebbe invece l’algoritmo di dimensionamento predefinito di SwiftUI. Documentazione Apple di ProposedViewSize, Callback Apple per il dimensionamento dei representable

Il callback non modifica il frame della vista di scorrimento, non sostituisce il testo e non scrive nello stato SwiftUI. Il suo compito è rispondere alla richiesta di dimensionamento. In questa implementazione, quindi, la lunghezza del testo non determina l’altezza esterna dell’editor.

Fai seguire alla larghezza l’area visibile e all’altezza il contenuto

Le due impostazioni di larghezza hanno scopi diversi. La maschera di ridimensionamento automatico .width della vista di testo interna fa sì che la sua larghezza si adatti ai cambiamenti della supervista. widthTracksTextView = true fa sì che la larghezza di layout del contenitore di testo si adatti ai cambiamenti della vista di testo. Entrambe le relazioni sono importanti quando l’editor diventa più stretto. Documentazione Apple di autoresizingMask, Documentazione Apple di widthTracksTextView

In verticale, isVerticallyResizable = true consente alla vista del documento di cambiare altezza per adattarsi al testo. L’altezza ampia del contenitore di testo lascia spazio al layout verticale, mentre heightTracksTextView = false evita di vincolarne il limite di layout a ogni ridimensionamento della vista del documento. La vista di scorrimento resta l’area visibile delimitata. Documentazione Apple sul ridimensionamento verticale, Documentazione Apple di heightTracksTextView

Aspettati un po’ di spazio tra il bordo e il testo. L’esempio aggiunge esplicitamente un rientro di otto punti al contenitore di testo. Anche il padding dei frammenti di riga del contenitore può aggiungere altro spazio, quindi la larghezza dell’area visibile o della vista di testo non corrisponde necessariamente alla larghezza esatta occupata dai caratteri. Documentazione Apple di textContainerInset

Tieni separati gli aggiornamenti del testo e la negoziazione delle dimensioni

Il coordinatore inoltra le modifiche apportate durante la digitazione tramite textDidChange, e il binding le collega allo stato del genitore. updateNSView gestisce invece le modifiche che arrivano da SwiftUI. Il controllo di uguaglianza evita di sostituire la stringa dell’editor quando lo stesso valore torna dopo una normale modifica. Documentazione Apple sul delegato per le modifiche al testo, Documentazione Apple di Binding

Una stringa in ingresso diversa viene trattata come una sostituzione completa. Se durante la modifica l’app sostituisce il testo anche a livello di codice, prima di estendere questo esempio definisci come preservare la selezione, la cronologia di annullamento e la composizione in corso. La dimostrazione modifica la stringa solo tramite l’editor.

L’esempio contrassegna i tipi dell’interfaccia utente e il coordinatore con @MainActor. La guida alla concorrenza di Swift descrive l’isolamento dell’attore principale come il meccanismo che protegge lo stato dell’interfaccia utente e richiede che il codice annotato venga eseguito su quell’attore. Il linguaggio di programmazione Swift: concorrenza

Verifica il risultato e individua gli eventuali problemi di layout

Esegui questi controlli nell’app di esempio. Permettono di distinguere il dimensionamento dell’area visibile, il ritorno a capo, lo scorrimento e la sincronizzazione dello stato, senza attribuire ogni sintomo a un problema del frame SwiftUI.

  1. Osserva il bordo dell’editor. L’area iniziale richiesta per l’editor è di 420 × 180 punti.
  2. Attiva Compact editor. La larghezza dovrebbe diventare 280 punti, mentre l’altezza resta 180 punti. Le frasi dovrebbero andare a capo formando più righe.
  3. Scorri i paragrafi forniti. Il documento dovrebbe spostarsi all’interno dello stesso bordo.
  4. Digita altre righe. Il conteggio dei caratteri dovrebbe cambiare senza aumentare l’altezza richiesta per l’editor.
  5. Elimina il testo. L’area visibile dovrebbe mantenere le dimensioni richieste anche se il documento contiene poco testo.
Sintomo Primo controllo
Il bordo ha le dimensioni giuste, ma le righe lunghe non vanno a capo Controlla sia la maschera di ridimensionamento automatico della larghezza del documento sia widthTracksTextView
Aggiungendo testo si ingrandisce il layout circostante Verifica che il representable restituisca la vista di scorrimento e non l’altezza del contenuto tramite sizeThatFits
Non è possibile raggiungere le righe successive Controlla lo scorrimento verticale, il ridimensionamento verticale del documento e il limite di altezza del contenitore di testo
L’editor resta stretto con un layout genitore diverso Esamina la larghezza proposta dal genitore: il callback accetta proposte finite, ma non inventa spazio aggiuntivo
La posizione del testo cambia durante aggiornamenti di stato non correlati Controlla che non ci siano assegnazioni incondizionate a textView.string

Questi controlli sono una sequenza diagnostica derivata dalle relazioni di dimensionamento descritte sopra, non risultati di test eseguiti.

Cosa cambia con un’altezza determinata dal contenuto o con versioni precedenti di macOS?

Un editor che si espande per mostrare ogni riga ha uno scopo diverso. Richiede di calcolare l’altezza del contenuto alla larghezza proposta e di stabilire un criterio per l’eventuale altezza massima. Attivare il ridimensionamento verticale del documento interno non comunica di per sé a SwiftUI una nuova altezza preferita. Allo stesso modo, accettare l’altezza proposta, come fa questo esempio, non consente di calcolare un’altezza basata sul testo.

Quando aggiungi la misurazione, mantieni il motore di testo scelto. Apple spiega che accedere a layoutManager di un’istanza NSTextView può attivare la modalità di compatibilità con TextKit 1. L’equivalente documentato in TextKit 2 per i limiti d’uso del contenitore di testo è NSTextLayoutManager.usageBoundsForTextContainer. Questo editor delimitato non necessita di nessuna delle due API di misurazione. Indicazioni Apple per la migrazione a TextKit

Anche la versione minima supportata è importante. NSViewRepresentable è disponibile da macOS 10.15, ma il callback sizeThatFits richiede macOS 13. Per le versioni di destinazione precedenti, mantieni la disposizione con documento scorrevole e usa il comportamento di dimensionamento predefinito del representable meno recente, con le priorità di dimensionamento AppKit o un contenitore progettato per quella versione. Non aspettarti che il callback controlli il layout in quel contesto; verifica separatamente quell’implementazione. Documentazione Apple sui representable, Disponibilità del callback Apple per il dimensionamento

Per un editor Mac delimitato, mantieni esplicite le responsabilità: SwiftUI alloca il rettangolo visibile, la vista di scorrimento contiene il documento in crescita e il contenitore di testo segue la larghezza disponibile. Questa disposizione offre un modo concreto per diagnosticare il dimensionamento del frame, senza rendere la misurazione del testo parte di un editor ad altezza fissa.

Fonti