Om een ingepakte NSTextView in een SwiftUI-frame te laten passen, representeer je een NSScrollView, accepteer je eindige voorgestelde afmetingen via sizeThatFits en laat je het tekstdocument daarin verticaal groeien. Configureer de tekstcontainer zodat deze de breedte van de editor volgt en lange regels worden afgebroken.

Deze tutorial bouwt een teksteditor zonder opmaak voor je eigen Mac-app, met een begrensd zichtbaar gebied. Toegevoegde tekst moet verticaal scrollen zonder de SwiftUI-layout groter te maken. Het volledige voorbeeld is bedoeld voor macOS 13 of nieuwer, waar Apple de sizing-callback voor representables beschikbaar stelt. Apple-documentatie over sizeThatFits

Waarom lijkt een NSTextView een SwiftUI-frame te negeren?

Er zijn drie afmetingen op elkaar af te stemmen. SwiftUI bepaalt de grootte van de gerepresenteerde AppKit-weergave. Een NSScrollView biedt het zichtbare gebied. De documentweergave ervan bevat de tekst, terwijl een NSTextContainer bepaalt welk gebied beschikbaar is voor tekstopmaak. Apple beschrijft de documentweergave als de weergave waarbinnen de scrollweergave scrollt in haar contentweergave. Apple-documentatie over documentView

Een SwiftUI-.frame(width:height:) maakt een omvattend layoutframe. Het configureert niet zelfstandig het afbreekgedrag van de tekstcontainer. Apple legt ook uit dat de grenzen van een onderliggende weergave buiten een omvattend frame kunnen vallen. Daarom is een rand nuttig voor diagnose: de rand kan de gevraagde afmetingen hebben, ook als de inhoud van de representable zich anders gedraagt. Apple-documentatie over frames

De belangrijkste beperking is eigenaarschap. Apple zegt expliciet dat SwiftUI de frame en bounds van de gerepresenteerde AppKit-weergave beheert; als je die eigenschappen zelf wijzigt, ontstaat er een conflict met SwiftUI. Geef je gewenste grootte in plaats daarvan door via de sizing-callback van de representable. In deze implementatie is de gerepresenteerde weergave de scrollweergave, terwijl AppKit het interne tekstdocument beheert. Apple-documentatie over NSViewRepresentable

Kies het gewenste gedrag voordat je layoutvlaggen wijzigt:

Gewenst gedrag Beleid voor buitenafmetingen Beleid voor tekstdocument
Vast editorpaneel Accepteer de opgegeven breedte en hoogte Breek regels horizontaal af; laat de inhoud verticaal groeien en scrollen
Editor die een begrensd paneel vult Accepteer de eindige voorgestelde afmetingen van het paneel Breek regels horizontaal af; laat de inhoud verticaal groeien en scrollen
Editor die uitbreidt om alle tekst te tonen Bereken de hoogte voor de voorgestelde breedte Stem inhoudsmeting en layoutupdates op elkaar af

De eerste twee rijen zijn het onderwerp van het uitvoerbare voorbeeld. De derde vereist een afzonderlijk beleid voor de inhoudshoogte; de voorgestelde hoogte accepteren implementeert dat niet.

Bouw een begrensde editor met regelafbreking

Maak in Xcode een macOS SwiftUI-app en stel het implementatiedoel in op macOS 13 of nieuwer. Voeg het volgende bestand toe als FramedTextEditor.swift. Het tweede codeblok bevat het app-startpunt en voorbeeldinhoud; samen vormen de twee bestanden het volledige voorbeeld.

De configuratie volgt Apples gedocumenteerde opstelling voor verticaal scrollen: de tekstweergave groeit verticaal, de breedte ervan volgt de superview en de tekstcontainer volgt de breedte van de tekstweergave. De montagehandleiding van Apple is gearchiveerd, dus het voorbeeld gebruikt actuele Swift-API's en selecteert TextKit 2 expliciet via NSTextView(usingTextLayoutManager: true). Apple documenteert die keuze van initializer in zijn presentatie over TextKit. Apples gids voor scrollende tekstweergaven, Wat is nieuw in TextKit en tekstweergaven

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

Vervang het gegenereerde app-startpunt en de contentweergave door dit tweede bestand. Behoud slechts één @main-declaratie in het target. Alle labels en teksten zijn synthetisch en het voorbeeld werkt volledig binnen het eigen appvenster.

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

Deze bestanden zijn geïnspecteerd, maar hier niet gecompileerd of uitgevoerd: de beschikbare omgeving is Linux en bevat geen Swift-uitvoerbaar bestand of macOS-frameworks. Bouw en controleer de app in Xcode met de onderstaande verificatiestappen.

Hoe werken de instellingen voor grootte en regelafbreking?

Accepteer eindige voorstellen zonder het document op te meten

Voor deze editor worden een eindige voorgestelde breedte en hoogte de gewenste afmetingen van de viewport. De fallback van 320 × 180 is een illustratieve ontwerpkeuze voor niet-opgegeven of niet-eindige afmetingen. Het is geen gemeten tekstgrootte. Nul blijft nul, zodat een probe voor de minimumgrootte geen willekeurige positieve minimumgrootte wordt.

SwiftUI kan tijdens één layoutpass verschillende groottes opvragen. De standaardvoorstellen omvatten vragen naar de minimum-, maximum- en ideale grootte. Dit voorbeeld geeft bewust een eindige fallback voor een onbegrensd voorstel; het is bedoeld voor een begrensd editorpaneel en niet als algemeen sizingbeleid voor elke container. Als je nil teruggeeft, wordt in plaats daarvan het standaard sizingalgoritme van SwiftUI gebruikt. Apple-documentatie over ProposedViewSize, Apple-documentatie over de sizing-callback voor representables

De callback wijzigt het frame van de scrollweergave niet, vervangt geen tekst en schrijft geen SwiftUI-status weg. De taak ervan is de sizingvraag te beantwoorden. De tekstlengte bepaalt in deze implementatie dus niet de buitenhoogte van de editor.

Laat de breedte de viewport volgen en de hoogte de inhoud

De twee breedte-instellingen dienen verschillende doelen. Het autoresizingmasker .width van de interne tekstweergave zorgt ervoor dat de breedte reageert op wijzigingen in de superview. De widthTracksTextView = true van de tekstcontainer zorgt ervoor dat de lay-outbreedte reageert op wijzigingen in de tekstweergave. Beide relaties zijn van belang wanneer de editor smaller wordt. Apple-documentatie over autoresizingMask, Apple-documentatie over widthTracksTextView

Verticaal maakt isVerticallyResizable = true het mogelijk dat de documentweergave van hoogte verandert om in de tekst te passen. De grote hoogte van de tekstcontainer biedt ruimte voor de verticale layout, terwijl heightTracksTextView = false voorkomt dat die layoutlimiet wordt gekoppeld aan elke wijziging van de documentweergave. De scrollweergave blijft het begrensde zichtbare gebied. Apple-documentatie over verticaal vergroten/verkleinen, Apple-documentatie over heightTracksTextView

Verwacht wat ruimte tussen de rand en de tekst. Het voorbeeld voegt expliciet een inset van acht punten toe aan de tekstcontainer. Tekst kan ook een extra inset hebben door de regelafstand binnen de container; daarom is noch de breedte van de viewport, noch die van de tekstweergave noodzakelijkerwijs gelijk aan de exacte breedte die de tekens innemen. Apple-documentatie over textContainerInset

Houd tekstupdates gescheiden van grootteonderhandeling

De coördinator geeft wijzigingen tijdens het bewerken door via textDidChange, en de binding koppelt die aan de status van de bovenliggende weergave. updateNSView verwerkt wijzigingen die vanuit SwiftUI terugkomen. De gelijkheidscontrole voorkomt dat de tekst van de editor wordt vervangen wanneer na een gewone bewerking dezelfde waarde terugkomt. Apple-documentatie over de delegate voor tekstwijzigingen, Apple-documentatie over Binding

Een ongelijke binnenkomende tekenreeks wordt behandeld als een volledige vervanging. Als je app tekst tijdens het bewerken ook programmatisch vervangt, bepaal dan hoe selectie, ongedaan maken en opnieuw uitvoeren, en een lopende tekstopbouwsessie behouden moeten blijven voordat je dit voorbeeld uitbreidt. In de demonstratie zelf wordt de tekenreeks alleen via de editor gewijzigd.

Het voorbeeld markeert de UI-typen en de coördinator met @MainActor. De concurrencygids van Swift beschrijft isolatie op de hoofdactor als het mechanisme om UI-status te beschermen en ervoor te zorgen dat geannoteerde code op die actor wordt uitgevoerd. The Swift Programming Language: Concurrency

Controleer het resultaat en spoor resterende layoutproblemen op

Voer deze controles uit in de voorbeeldapp. Ze maken onderscheid tussen viewportgrootte, regelafbreking, scrollen en statussynchronisatie, in plaats van elk symptoom te behandelen als een probleem met het SwiftUI-frame.

  1. Bekijk de rand van de editor. Het aanvankelijk gevraagde editorgebied is 420 × 180 punten.
  2. Schakel Compact editor in. De breedte moet 280 punten worden, terwijl de hoogte 180 punten blijft. Zinnen moeten over meer regels worden afgebroken.
  3. Scroll door de meegeleverde alinea's. Het document moet binnen dezelfde rand bewegen.
  4. Typ extra regels. Het aantal tekens moet veranderen zonder dat de gevraagde hoogte van de editor toeneemt.
  5. Verwijder de tekst. De viewport moet de gevraagde afmetingen behouden, ook al bevat het document weinig inhoud.
Symptoom Eerste controle
De rand heeft de juiste afmetingen, maar lange regels worden niet afgebroken Controleer zowel het autoresizingmasker voor de breedte van het document als widthTracksTextView
Toegevoegde tekst vergroot de omringende layout Controleer of de representable de scrollweergave teruggeeft en geen inhoudshoogte teruggeeft vanuit sizeThatFits
Latere regels zijn niet bereikbaar Controleer verticaal scrollen, verticaal vergroten/verkleinen van het document en de hoogtelimiet van de tekstcontainer
De editor blijft smal in een andere bovenliggende layout Controleer de breedte die door die bovenliggende weergave wordt voorgesteld; de callback accepteert eindige voorstellen en verzint geen extra ruimte
De tekstpositie verandert tijdens niet-gerelateerde statusupdates Controleer op onvoorwaardelijke toewijzingen aan textView.string

Deze controles vormen een diagnostische volgorde die is afgeleid van de bovenstaande sizingrelaties; het zijn geen gerapporteerde runtime-testresultaten.

Wat verandert er bij een inhoudsgestuurde hoogte of oudere macOS-versies?

Een editor die uitbreidt om elke regel te tonen, heeft een andere taak. Daarvoor zijn een berekening van de inhoudshoogte bij de voorgestelde breedte en een beleid voor een eventuele maximumhoogte nodig. Door verticaal vergroten van het interne document in te schakelen, wordt niet automatisch een nieuwe gewenste hoogte aan SwiftUI doorgegeven. Evenzo kan het accepteren van de voorgestelde hoogte — zoals dit voorbeeld doet — geen tekstgestuurde hoogte berekenen.

Behoud de gekozen tekstengine wanneer je metingen toevoegt. Apple legt uit dat toegang tot layoutManager van een NSTextView-instantie de compatibiliteitsmodus van TextKit 1 kan activeren. De gedocumenteerde tegenhanger in TextKit 2 voor de gebruiksgrenzen van de tekstcontainer is NSTextLayoutManager.usageBoundsForTextContainer. Deze begrensde editor heeft geen van beide meet-API's nodig. Apples migratiehandleiding voor TextKit

Ook de versiegrens is van belang. NSViewRepresentable is beschikbaar vanaf macOS 10.15, maar de callback sizeThatFits vereist macOS 13. Gebruik voor oudere implementatiedoelen de opstelling met scrollweergave en document, en vertrouw op het standaard sizinggedrag van de oudere representable, met AppKit-sizingprioriteiten of een container die voor dat doel is ontworpen. Verwacht niet dat deze callback daar de layout bepaalt; valideer die implementatie afzonderlijk. Apple-documentatie over representables, Apple-beschikbaarheid van de sizing-callback

Houd voor een begrensde Mac-editor de verantwoordelijkheden duidelijk: SwiftUI kent de zichtbare rechthoek toe, de scrollweergave bevat het groeiende document en de tekstcontainer volgt de beschikbare breedte. Zo kun je de grootte van het frame concreet diagnosticeren zonder tekstmetingen onderdeel te maken van een editor met vaste hoogte.

Bronnen