Damit ein eingebettetes NSTextView in einen SwiftUI-Frame passt, stelle ein NSScrollView dar, akzeptiere über sizeThatFits endliche vorgeschlagene Abmessungen und lass das Textdokument darin vertikal wachsen. Konfiguriere den Textcontainer so, dass er der Breite des Editors folgt und lange Zeilen umgebrochen werden.

Dieses Tutorial erstellt einen einfachen Texteditor für deine eigene Mac-App mit einem begrenzten sichtbaren Bereich. Beim Hinzufügen von Text soll vertikales Scrollen möglich sein, ohne dass sich das SwiftUI-Layout vergrößert. Das vollständige Beispiel zielt auf macOS 13 oder neuer ab, wo Apple den Größen-Callback für Representables verfügbar macht. Apples Dokumentation zu sizeThatFits

Warum scheint ein NSTextView einen SwiftUI-Frame zu ignorieren?

Drei Größen müssen aufeinander abgestimmt werden. SwiftUI legt die Größe der dargestellten AppKit-Ansicht fest. Ein NSScrollView stellt den sichtbaren Bereich bereit. Seine Dokumentansicht enthält den Text, während ein NSTextContainer den für das Textlayout verfügbaren Bereich festlegt. Apple beschreibt die Dokumentansicht als die Ansicht, die der Scroll-View innerhalb seiner Inhaltsansicht scrollt. Apples Dokumentation zu documentView

Ein SwiftUI-.frame(width:height:) erzeugt einen umschließenden Layout-Frame. Es konfiguriert das Umbruchverhalten des Textcontainers nicht eigenständig. Apple erklärt außerdem, dass die Bounds eines untergeordneten Elements über einen umschließenden Frame hinausreichen können. Deshalb ist ein Rahmen zur Diagnose hilfreich: Er kann die angeforderten Abmessungen haben, auch wenn sich der dargestellte Inhalt anders verhält. Apples Dokumentation zu Frames

Die zentrale Einschränkung betrifft die Zuständigkeit. Apple sagt ausdrücklich, dass SwiftUI frame und bounds der dargestellten AppKit-Ansicht kontrolliert; wenn du diese Eigenschaften selbst änderst, gerätst du in Konflikt mit SwiftUI. Gib stattdessen deine bevorzugte Größe über den Größen-Callback des Representables zurück. In dieser Implementierung ist die dargestellte Ansicht der Scroll-View, während AppKit das interne Textdokument verwaltet. Apples Dokumentation zu NSViewRepresentable

Lege das gewünschte Verhalten fest, bevor du Layout-Flags änderst:

Gewünschtes Verhalten Richtlinie für die äußere Größe Richtlinie für das Textdokument
Editor mit fester Größe Übernommene Breite und Höhe akzeptieren Horizontal umbrechen; vertikal wachsen und scrollen
Editor füllt einen begrenzten Bereich Endliche vorgeschlagene Abmessungen des Bereichs akzeptieren Horizontal umbrechen; vertikal wachsen und scrollen
Editor erweitert sich, um den gesamten Text anzuzeigen Höhe für die vorgeschlagene Breite berechnen Inhaltsmessung und Layout-Aktualisierungen aufeinander abstimmen

Die ersten beiden Zeilen betreffen die Aufgabe, die das ausführbare Beispiel abdeckt. Für die dritte ist eine eigene Richtlinie für die Inhaltshöhe nötig; die vorgeschlagene Höhe zu akzeptieren, setzt sie nicht um.

Einen begrenzten Editor mit Zeilenumbruch erstellen

Erstelle in Xcode eine macOS-SwiftUI-App und lege das Deployment-Target auf macOS 13 oder neuer fest. Füge die folgende Datei als FramedTextEditor.swift hinzu. Der zweite Codeblock enthält den App-Einstiegspunkt und Beispielinhalte; zusammen bilden die beiden Dateien das vollständige Beispiel.

Die Konfiguration folgt Apples dokumentierter Anordnung für vertikales Scrollen: Die Textansicht wächst vertikal, ihre Breite folgt der Superview und der Textcontainer folgt der Breite der Textansicht. Apples Anleitung zum Zusammensetzen ist archiviert; das Beispiel verwendet daher aktuelle Swift-APIs und wählt über NSTextView(usingTextLayoutManager: true) ausdrücklich TextKit 2 aus. Apple dokumentiert diese Initialisierungsoption in seiner TextKit-Präsentation. Apples Anleitung zu scrollenden Textansichten, Neuerungen bei TextKit und Textansichten

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

Ersetze den generierten App-Einstiegspunkt und die Inhaltsansicht durch diese zweite Datei. Im Target darf es nur eine @main-Deklaration geben. Alle Beschriftungen und Texte sind künstlich erstellt, und das Beispiel läuft vollständig im eigenen App-Fenster.

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

Diese Dateien wurden geprüft, aber hier weder kompiliert noch ausgeführt: Die verfügbare Umgebung ist Linux und enthält weder ein Swift-Executable noch macOS-Frameworks. Erstelle und prüfe die App in Xcode anhand der folgenden Prüfschritte.

Wie funktionieren die Einstellungen für Größe und Zeilenumbruch?

Endliche Vorschläge akzeptieren, ohne das Dokument zu vermessen

Für diesen Editor werden eine endliche vorgeschlagene Breite und Höhe zu den bevorzugten Abmessungen des Viewports. Der Fallback von 320 × 180 ist eine beispielhafte Designentscheidung für nicht angegebene oder nicht endliche Abmessungen. Er entspricht keiner gemessenen Textgröße. Null bleibt null, damit eine Abfrage der Mindestgröße nicht zu einer willkürlichen positiven Mindestgröße wird.

SwiftUI kann während eines Layoutdurchlaufs nach verschiedenen Größen fragen. Zu den üblichen Vorschlägen gehören Abfragen für Mindest-, Höchst- und Idealgröße. Dieses Beispiel verwendet bewusst einen endlichen Fallback für einen unbeschränkten Vorschlag. Es ist für einen begrenzten Editorbereich gedacht und nicht als allgemeine Größenrichtlinie für jeden Container. nil zurückzugeben würde stattdessen den Standardalgorithmus zur Größenbestimmung von SwiftUI auswählen. Apples Dokumentation zu ProposedViewSize, Apples Größen-Callback für Representables

Der Callback ändert weder den Frame der Scroll-View noch ersetzt er Text oder schreibt SwiftUI-Status. Seine Aufgabe ist es, die Größenanfrage zu beantworten. Die Textlänge bestimmt daher in dieser Implementierung nicht die äußere Höhe des Editors.

Die Breite soll dem Viewport folgen, die Höhe dem Inhalt

Die beiden Einstellungen für die Breite haben unterschiedliche Aufgaben. Die .width-Maske für automatisches Ändern der Größe der internen Textansicht sorgt dafür, dass sich deren Breite an Änderungen der Superview anpasst. widthTracksTextView = true des Textcontainers sorgt dafür, dass sich seine Layoutbreite an Änderungen der Textansicht anpasst. Beide Beziehungen sind wichtig, wenn der Editor schmaler wird. Apples Dokumentation zu autoresizingMask, Apples Dokumentation zu widthTracksTextView

Vertikal erlaubt isVerticallyResizable = true, dass sich die Höhe der Dokumentansicht an den Text anpasst. Die große Höhe des Textcontainers bietet Raum für das vertikale Layout, während heightTracksTextView = false verhindert, dass diese Layoutgrenze an jede Größenänderung der Dokumentansicht gekoppelt ist. Die Scroll-View bleibt der begrenzte sichtbare Bereich. Apples Dokumentation zur vertikalen Größenanpassung, Apples Dokumentation zu heightTracksTextView

Zwischen dem Rahmen und dem Text ist etwas Abstand zu erwarten. Das Beispiel fügt ausdrücklich einen Innenabstand von acht Punkten im Textcontainer hinzu. Der Text kann außerdem durch den Zeilenfragment-Innenabstand des Containers weiter eingerückt sein. Daher entspricht weder die Breite des Viewports noch die der Textansicht zwangsläufig genau der von Zeichen belegten Breite. Apples Dokumentation zu textContainerInset

Textaktualisierungen und Größenbestimmung getrennt halten

Der Coordinator leitet Änderungen beim Bearbeiten über textDidChange weiter, und die Binding verbindet sie mit dem Status des übergeordneten Elements. updateNSView verarbeitet Änderungen, die von SwiftUI zurückfließen. Die Gleichheitsprüfung verhindert, dass der String des Editors ersetzt wird, wenn nach einer gewöhnlichen Bearbeitung derselbe Wert zurückkommt. Apples Dokumentation zum Delegate für Textänderungen, Apples Dokumentation zu Binding

Ein abweichender eingehender String gilt als vollständiger Ersatz. Wenn deine App während der Bearbeitung auch Text programmgesteuert ersetzt, lege fest, wie Auswahl, Rückgängig-Verlauf und laufende Texteingabe erhalten bleiben sollen, bevor du dieses Beispiel erweiterst. Die Demonstration selbst ändert den String nur über den Editor.

Das Beispiel kennzeichnet seine UI-Typen und den Coordinator mit @MainActor. Apples Leitfaden zu Nebenläufigkeit beschreibt die Isolation durch den Main Actor als Mechanismus zum Schutz des UI-Status und dafür, dass annotierter Code auf diesem Actor ausgeführt werden muss. The Swift Programming Language: Nebenläufigkeit

Ergebnis prüfen und verbleibende Layoutprobleme finden

Führe diese Prüfungen in der Beispiel-App aus. Sie betrachten Viewport-Größe, Zeilenumbruch, Scrollen und Statussynchronisierung getrennt, statt jedes Symptom als Fehler eines SwiftUI-Frames zu behandeln.

  1. Beobachte den Rahmen des Editors. Der anfänglich angeforderte Editorbereich ist 420 × 180 Punkte groß.
  2. Aktiviere Compact editor. Die Breite sollte auf 280 Punkte sinken, während die Höhe bei 180 Punkten bleibt. Sätze sollten sich auf mehr Zeilen verteilen.
  3. Scrolle durch die bereitgestellten Absätze. Das Dokument sollte sich innerhalb desselben Rahmens bewegen.
  4. Gib weitere Zeilen ein. Die Zeichenanzahl sollte sich ändern, ohne dass die angeforderte Höhe des Editors zunimmt.
  5. Lösche den Text. Der Viewport sollte seine angeforderten Abmessungen beibehalten, auch wenn das Dokument nur wenig Inhalt hat.
Symptom Erste Prüfung
Der Rahmen hat die richtige Größe, aber lange Zeilen werden nicht umgebrochen Prüfe sowohl die Maske für automatisches Ändern der Größe der Dokumentbreite als auch widthTracksTextView
Durch das Hinzufügen von Text vergrößert sich das umgebende Layout Stelle sicher, dass das Representable die Scroll-View zurückgibt und aus sizeThatFits nicht die Inhaltshöhe zurückgibt
Spätere Zeilen sind nicht erreichbar Prüfe vertikales Scrollen, vertikale Größenanpassung des Dokuments und die Höhenbegrenzung des Textcontainers
Der Editor bleibt in einem anderen übergeordneten Layout schmal Prüfe die von diesem übergeordneten Element vorgeschlagene Breite; der Callback akzeptiert endliche Vorschläge, statt zusätzlichen Platz zu erfinden
Die Textposition ändert sich bei unabhängigen Statusaktualisierungen Prüfe, ob textView.string bedingungslos zugewiesen wird

Diese Prüfungen sind eine aus den obigen Größenbeziehungen abgeleitete Diagnosesequenz und keine berichteten Laufzeittestergebnisse.

Was ändert sich bei inhaltsabhängiger Höhe oder älteren macOS-Versionen?

Ein Editor, der sich erweitert, um jede Zeile anzuzeigen, hat eine andere Aufgabe. Er benötigt eine Berechnung der Inhaltshöhe für die vorgeschlagene Breite sowie eine Richtlinie für eine maximale Höhe. Die vertikale Größenanpassung des internen Dokuments zu aktivieren, übermittelt SwiftUI nicht von selbst eine neue bevorzugte Höhe. Ebenso kann die vorgeschlagene Höhe – wie in diesem Beispiel – zwar akzeptiert werden, aber keine textabhängige Höhe berechnen.

Behalte beim Hinzufügen einer Messung die gewählte Text-Engine bei. Apple erklärt, dass der Zugriff auf eine Instanz von NSTextView über layoutManager den Kompatibilitätsmodus von TextKit 1 auslösen kann. Das dokumentierte Gegenstück in TextKit 2 für die Nutzungsgrenzen des Textcontainers ist NSTextLayoutManager.usageBoundsForTextContainer. Dieser begrenzte Editor benötigt keine der beiden Messungs-APIs. Apples Leitfaden zur Migration zu TextKit

Auch die Versionsgrenze ist wichtig. NSViewRepresentable ist ab macOS 10.15 verfügbar, sein Callback sizeThatFits erfordert jedoch macOS 13. Für frühere Deployment-Targets solltest du die Anordnung mit scrollbarem Dokument beibehalten und das Standardverhalten zur Größenbestimmung des älteren Representables zusammen mit Größenprioritäten von AppKit oder einem für dieses Target entwickelten Container verwenden. Erwarte nicht, dass dieser Callback dort das Layout steuert; prüfe diese Implementierung separat. Apples Dokumentation zu Representables, Verfügbarkeit von Apples Größen-Callback

Bei einem begrenzten Mac-Editor solltest du die Zuständigkeiten klar festlegen: SwiftUI weist das sichtbare Rechteck zu, die Scroll-View enthält das wachsende Dokument und der Textcontainer folgt der verfügbaren Breite. So kannst du die Frame-Größe gezielt untersuchen, ohne die Textvermessung zum Bestandteil eines Editors mit fester Höhe zu machen.

Quellen