Aby dopasować opakowany NSTextView do ramki SwiftUI, reprezentuj NSScrollView, przyjmuj skończone proponowane wymiary przez sizeThatFits i pozwól dokumentowi tekstowemu rosnąć w pionie. Skonfiguruj kontener tekstu tak, aby śledził szerokość edytora, dzięki czemu długie wiersze będą zawijane.

Ten samouczek pokazuje, jak zbudować dla własnej aplikacji na Maca edytor zwykłego tekstu z ograniczonym widocznym obszarem. Dodawanie tekstu powinno powodować przewijanie w pionie bez powiększania układu SwiftUI. Kompletny przykład wymaga systemu macOS 13 lub nowszego, w którym Apple udostępnia metodę zwrotną reprezentowalnego widoku do ustalania rozmiaru. Dokumentacja Apple dotycząca sizeThatFits

Dlaczego NSTextView może pozornie ignorować ramkę SwiftUI?

Trzeba skoordynować trzy rozmiary. SwiftUI określa rozmiar reprezentowanego widoku AppKit. NSScrollView zapewnia widoczny obszar. Jego widok dokumentu zawiera tekst, a NSTextContainer określa obszar dostępny na układ tekstu. Apple opisuje widok dokumentu jako widok przewijany wewnątrz widoku zawartości widoku przewijania. Dokumentacja Apple dotycząca documentView

SwiftUI .frame(width:height:) tworzy zewnętrzną ramkę układu. Samodzielnie nie konfiguruje sposobu zawijania tekstu w kontenerze tekstu. Apple wyjaśnia też, że granice podrzędnego widoku mogą wykraczać poza ramkę otaczającą. Dlatego obramowanie jest przydatne w diagnostyce: może mieć żądane wymiary, nawet jeśli reprezentowana zawartość zachowuje się inaczej. Dokumentacja Apple dotycząca ramek

Kluczowa jest kwestia własności. Apple wyraźnie zaznacza, że SwiftUI kontroluje właściwości reprezentowanego widoku AppKit: frame i bounds; samodzielna zmiana tych właściwości koliduje z działaniem SwiftUI. Zwróć preferowany rozmiar przez metodę zwrotną reprezentowalnego widoku służącą do ustalania rozmiaru. W tej implementacji reprezentowanym widokiem jest widok przewijania, a AppKit zarządza wewnętrznym dokumentem tekstowym. Dokumentacja Apple dotycząca NSViewRepresentable

Zanim zmienisz flagi układu, wybierz oczekiwane zachowanie:

Oczekiwane zachowanie Zasady ustalania rozmiaru zewnętrznego widoku Zasady dotyczące dokumentu tekstowego
Panel edytora o stałym rozmiarze Przyjmij podaną szerokość i wysokość Zawijaj w poziomie; zwiększaj wysokość i przewijaj
Edytor wypełniający ograniczony panel Przyjmij skończone proponowane wymiary panelu Zawijaj w poziomie; zwiększaj wysokość i przewijaj
Edytor rozszerzający się, aby pokazać cały tekst Oblicz wysokość dla proponowanej szerokości Skoordynuj pomiar zawartości i aktualizacje układu

Przykład w kodzie dotyczy dwóch pierwszych wierszy. Trzeci wymaga odrębnych zasad określania wysokości na podstawie zawartości; przyjęcie proponowanej wysokości tego nie realizuje.

Zbuduj zawijany edytor o ograniczonym rozmiarze

Utwórz w Xcode aplikację SwiftUI na macOS i ustaw minimalną wersję systemu na macOS 13 lub nowszą. Dodaj poniższy plik jako FramedTextEditor.swift. Drugi blok kodu zawiera punkt wejścia aplikacji i przykładową zawartość; razem oba pliki tworzą kompletny przykład.

Konfiguracja odpowiada udokumentowanemu przez Apple układowi przewijania w pionie: widok tekstu rośnie w pionie, jego szerokość dopasowuje się do widoku nadrzędnego, a kontener tekstu — do szerokości widoku tekstu. Przewodnik Apple dotyczący składania tych elementów jest zarchiwizowany, dlatego przykład korzysta z aktualnych interfejsów Swift i jawnie wybiera TextKit 2 przez NSTextView(usingTextLayoutManager: true). Apple opisuje ten wybór inicjalizatora w prezentacji TextKit. Przewodnik Apple dotyczący widoku tekstu w widoku przewijania, Co nowego w TextKit i widokach tekstu

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

Zastąp wygenerowany punkt wejścia aplikacji i widok zawartości tym drugim plikiem. W projekcie pozostaw tylko jedną deklarację @main. Wszystkie etykiety i tekst są przykładowe, a przykład działa w całości w oknie własnej aplikacji.

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

Pliki zostały sprawdzone, ale nie zostały tutaj skompilowane ani uruchomione: dostępne środowisko to Linux, bez kompilatora Swift i frameworków macOS. Zbuduj aplikację i sprawdź ją w Xcode, korzystając z poniższej procedury.

Jak działają ustawienia rozmiaru i zawijania?

Przyjmuj skończone propozycje bez mierzenia dokumentu

W tym edytorze skończona proponowana szerokość i wysokość stają się preferowanymi wymiarami widocznego obszaru. Zapasowy rozmiar 320 × 180 to przykładowy wybór projektowy dla nieokreślonych lub nieskończonych wymiarów. Nie jest to zmierzony rozmiar tekstu. Wartość zero pozostaje zerem, aby sprawdzenie minimalnego rozmiaru nie skutkowało arbitralnie ustalonym dodatnim minimum.

SwiftUI może pytać o różne rozmiary w ramach jednego przebiegu układu. Standardowe propozycje obejmują zapytania o rozmiar minimalny, maksymalny i idealny. Ten przykład celowo używa skończonej wartości zapasowej dla nieograniczonej propozycji; jest przeznaczony do panelu edytora o ograniczonym rozmiarze, a nie jako ogólna zasada ustalania rozmiaru dla każdego kontenera. Zwrócenie nil oznaczałoby wybór domyślnego algorytmu ustalania rozmiaru SwiftUI. Dokumentacja Apple dotycząca ProposedViewSize, Metoda zwrotna Apple do ustalania rozmiaru reprezentowalnego widoku

Metoda zwrotna nie zmienia ramki widoku przewijania, nie zastępuje tekstu ani nie zapisuje stanu SwiftUI. Jej zadaniem jest odpowiedzieć na pytanie o rozmiar. Dlatego w tej implementacji długość tekstu nie wpływa na zewnętrzną wysokość edytora.

Dopasuj szerokość do widocznego obszaru, a wysokość do zawartości

Dwa ustawienia szerokości pełnią różne funkcje. Maska automatycznej zmiany rozmiaru .width wewnętrznego widoku tekstu sprawia, że jego szerokość reaguje na zmiany widoku nadrzędnego. widthTracksTextView = true kontenera tekstu sprawia, że jego szerokość układu reaguje na zmiany szerokości widoku tekstu. Obie zależności mają znaczenie, gdy edytor staje się węższy. Dokumentacja Apple dotycząca autoresizingMask, Dokumentacja Apple dotycząca widthTracksTextView

W pionie isVerticallyResizable = true pozwala widokowi dokumentu zmieniać wysokość, aby dopasować ją do tekstu. Duża wysokość kontenera tekstu zapewnia miejsce na układ pionowy, a heightTracksTextView = false zapobiega uzależnieniu limitu tego układu od rozmiaru widoku dokumentu przy każdej zmianie. Widok przewijania pozostaje ograniczonym widocznym obszarem. Dokumentacja Apple dotycząca zmiany rozmiaru w pionie, Dokumentacja Apple dotycząca heightTracksTextView

Między obramowaniem a tekstem pozostanie trochę miejsca. Przykład jawnie ustawia wcięcie kontenera tekstu na osiem punktów. Tekst może mieć też dodatkowe wcięcie wynikające z odstępu fragmentu wiersza w kontenerze, dlatego szerokość widocznego obszaru ani widoku tekstu nie musi dokładnie odpowiadać szerokości zajmowanej przez znaki. Dokumentacja Apple dotycząca textContainerInset

Oddziel aktualizacje tekstu od negocjacji rozmiaru

Koordynator przekazuje zmiany edycji przez textDidChange, a powiązanie łączy je ze stanem widoku nadrzędnego. updateNSView obsługuje zmiany przekazywane w drugą stronę, z SwiftUI. Kontrola równości pozwala uniknąć zastępowania tekstu edytora, gdy po zwykłej edycji wraca ta sama wartość. Dokumentacja Apple dotycząca delegata zmian tekstu, Dokumentacja Apple dotycząca Binding

Nierówny ciąg wejściowy jest traktowany jako pełne zastąpienie tekstu. Jeśli aplikacja zastępuje tekst programowo również podczas edycji, przed rozszerzeniem tego przykładu określ, jak zachować zaznaczenie, historię cofania i trwające wprowadzanie tekstu. W samym przykładzie ciąg zmienia się wyłącznie za pośrednictwem edytora.

Przykład oznacza typy interfejsu i koordynator atrybutem @MainActor. Przewodnik współbieżności języka Swift opisuje izolację głównego aktora jako mechanizm ochrony stanu interfejsu, który wymaga, aby oznaczony kod wykonywał się na tym aktorze. Język Swift: współbieżność

Sprawdź wynik i zlokalizuj pozostałe problemy z układem

Wykonaj poniższe czynności w przykładowej aplikacji. Pozwalają one osobno sprawdzić rozmiar widocznego obszaru, zawijanie wierszy, przewijanie i synchronizację stanu, zamiast przypisywać każdy objaw błędowi ramki SwiftUI.

  1. Sprawdź obramowanie edytora. Początkowy żądany obszar edytora ma wymiary 420 × 180 punktów.
  2. Włącz opcję Compact editor. Szerokość powinna zmienić się na 280 punktów, a wysokość pozostać równa 180 punktów. Zdania powinny zawijać się do większej liczby wierszy.
  3. Przewiń przykładowe akapity. Dokument powinien przesuwać się wewnątrz tego samego obramowania.
  4. Wpisz dodatkowe wiersze. Liczba znaków powinna się zmienić, ale żądana wysokość edytora nie powinna wzrosnąć.
  5. Usuń tekst. Widoczny obszar powinien zachować żądane wymiary, mimo że dokument zawiera niewiele treści.
Objaw Co sprawdzić najpierw
Obramowanie ma właściwy rozmiar, ale długie wiersze się nie zawijają Sprawdź zarówno maskę automatycznej zmiany rozmiaru szerokości dokumentu, jak i widthTracksTextView
Dodawanie tekstu powiększa otaczający układ Upewnij się, że reprezentowalny widok zwraca widok przewijania i nie zwraca wysokości zawartości z sizeThatFits
Nie można dotrzeć do dalszych wierszy Sprawdź przewijanie w pionie, zmianę rozmiaru dokumentu w pionie i limit wysokości kontenera tekstu
Edytor pozostaje wąski przy innym układzie nadrzędnym Sprawdź szerokość proponowaną przez widok nadrzędny; metoda zwrotna przyjmuje skończone propozycje zamiast tworzyć dodatkowe miejsce
Po niepowiązanych aktualizacjach stanu zmienia się położenie tekstu Sprawdź, czy nie ma bezwarunkowych przypisań do textView.string

To procedura diagnostyczna wyprowadzona z opisanych zależności rozmiarów, a nie zgłoszone wyniki testów uruchomieniowych.

Co zmienić, aby wysokość zależała od zawartości lub obsłużyć starsze wersje macOS?

Edytor rozszerzający się tak, aby pokazać każdy wiersz, wymaga innego podejścia. Potrzebne jest obliczenie wysokości zawartości dla proponowanej szerokości oraz zasady określające maksymalną wysokość. Samo włączenie zmiany rozmiaru wewnętrznego dokumentu w pionie nie przekazuje SwiftUI nowej preferowanej wysokości. Podobnie przyjęcie proponowanej wysokości — tak jak robi to ten przykład — nie pozwala obliczyć wysokości zależnej od tekstu.

Podczas dodawania pomiaru zachowaj wybrany mechanizm tekstowy. Apple wyjaśnia, że dostęp do właściwości instancji NSTextView layoutManager może uruchomić tryb zgodności z TextKit 1. Udokumentowanym odpowiednikiem TextKit 2 dla zakresu użycia kontenera tekstu jest NSTextLayoutManager.usageBoundsForTextContainer. Ten edytor o ograniczonym rozmiarze nie wymaga żadnego z tych interfejsów do pomiaru. Wskazówki Apple dotyczące migracji do TextKit

Istotna jest też granica wersji. NSViewRepresentable jest dostępny od macOS 10.15, ale jego metoda zwrotna sizeThatFits wymaga macOS 13. W przypadku starszych docelowych wersji systemu zachowaj układ z przewijanym dokumentem i użyj domyślnego zachowania ustalania rozmiaru starszego reprezentowalnego widoku, korzystając z priorytetów ustalania rozmiaru AppKit lub kontenera przeznaczonego dla tej wersji systemu. Nie oczekuj, że ta metoda zwrotna będzie tam kontrolować układ; osobno sprawdź tę implementację. Dokumentacja Apple dotycząca reprezentowalnego widoku, Dostępność metody zwrotnej Apple do ustalania rozmiaru

W edytorze na Maca o ograniczonym rozmiarze jasno określ zakres odpowiedzialności: SwiftUI przydziela widoczny prostokąt, widok przewijania zawiera rosnący dokument, a kontener tekstu dopasowuje się do dostępnej szerokości. Ten układ pozwala konkretnie diagnozować rozmiar ramki bez uwzględniania pomiaru tekstu w edytorze o stałej wysokości.

Źródła