Чтобы NSTextView с переносом строк вписывался во фрейм SwiftUI, представьте NSScrollView, принимайте конечные предлагаемые размеры через sizeThatFits и позвольте текстовому документу расти по вертикали внутри него. Настройте текстовый контейнер так, чтобы его ширина соответствовала ширине редактора и длинные строки переносились.

В этом руководстве создаётся текстовый редактор без форматирования для вашего приложения Mac с ограниченной видимой областью. При добавлении текста должна появляться вертикальная прокрутка, а размер макета SwiftUI не должен увеличиваться. Полный пример рассчитан на macOS 13 или новее, где Apple добавила обратный вызов размера для представляемого элемента. Документация Apple по sizeThatFits

Почему кажется, что NSTextView игнорирует фрейм SwiftUI?

Нужно согласовать три размера. SwiftUI задаёт размер представляемого представления AppKit. NSScrollView задаёт видимую область. Его представление документа содержит текст, а NSTextContainer определяет область, доступную для размещения текста. Apple описывает представление документа как представление, прокручиваемое внутри области содержимого прокручиваемого представления. Документация Apple по documentView

.frame(width:height:) SwiftUI создаёт внешнюю рамку макета. Сам по себе он не настраивает поведение переноса текста в контейнере. Apple также поясняет, что границы дочернего представления могут выходить за пределы внешней рамки. Поэтому рамка полезна для диагностики: она может иметь заданные размеры, даже если содержимое представляемого элемента ведёт себя иначе. Документация Apple о фреймах

Главное ограничение связано с владением. Apple прямо указывает, что SwiftUI управляет frame и bounds представляемого представления AppKit; самостоятельное изменение этих свойств конфликтует с управлением SwiftUI. Вместо этого верните предпочтительный размер через обратный вызов определения размера представляемого элемента. В этой реализации представляемым элементом служит прокручиваемое представление, а AppKit управляет внутренним текстовым документом. Документация Apple по NSViewRepresentable

Прежде чем менять флаги макета, выберите нужное поведение:

Желаемое поведение Политика внешнего размера Политика текстового документа
Редактор с фиксированным размером Принять заданные ширину и высоту Переносить строки по горизонтали; расти по вертикали и прокручиваться
Редактор в панели с ограниченным размером Принять конечные предлагаемые размеры панели Переносить строки по горизонтали; расти по вертикали и прокручиваться
Редактор, расширяющийся для показа всего текста Рассчитать высоту для предложенной ширины Согласовать измерение содержимого и обновление макета

Первые два случая охватывает исполняемый пример. Для третьего нужна отдельная политика высоты содержимого; принятие предложенной высоты её не реализует.

Создайте редактор с переносом строк и ограниченным размером

Создайте приложение SwiftUI для macOS в Xcode и задайте целевую версию macOS 13 или новее. Добавьте файл FramedTextEditor.swift с таким содержимым. Во втором блоке кода приведены точка входа приложения и пример содержимого; вместе эти два файла составляют полный пример.

Конфигурация соответствует документированной Apple схеме вертикальной прокрутки: текстовое представление растёт по вертикали, его ширина следует за шириной суперпредставления, а текстовый контейнер — за шириной текстового представления. Руководство Apple по сборке заархивировано, поэтому в примере используются актуальные API Swift и явно выбирается TextKit 2 через NSTextView(usingTextLayoutManager: true). Этот выбор инициализатора описан Apple в презентации TextKit. Руководство Apple по текстовому представлению с прокруткой, Что нового в TextKit и текстовых представлениях

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

Замените сгенерированные точку входа приложения и представление содержимого этим вторым файлом. В цели должна остаться только одна декларация @main. Все подписи и текст в примере вымышлены, а сам пример работает только в окне собственного приложения.

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

Эти файлы проверены, но здесь не компилировались и не запускались: доступна среда Linux, в которой нет исполняемого файла Swift и фреймворков macOS. Соберите и проверьте приложение в Xcode по приведённой ниже последовательности.

Как работают настройки размера и переноса строк?

Принимайте конечные предлагаемые размеры, не измеряя документ

Для этого редактора конечные предложенные ширина и высота становятся предпочтительными размерами области просмотра. Значения по умолчанию 320 × 180 — пример дизайнерского решения для неопределённых или не конечных размеров. Это не результат измерения текста. Нулевое значение остаётся нулевым, чтобы проверка минимального размера не превратилась в произвольный положительный минимум.

В течение одного прохода макета SwiftUI может запрашивать разные размеры. Стандартные предлагаемые размеры включают минимальный, максимальный и идеальный варианты. В этом примере для неограниченного предложения намеренно задаётся конечный размер по умолчанию; решение подходит для панели редактора с ограниченным размером, но не является универсальной политикой определения размеров для любых контейнеров. Возврат nil вместо этого задействовал бы алгоритм определения размера SwiftUI по умолчанию. Документация Apple по ProposedViewSize, Обратный вызов определения размера для представляемого элемента в Apple

Обратный вызов не меняет фрейм прокручиваемого представления, не заменяет текст и не записывает состояние SwiftUI. Его задача — ответить на запрос размера. Поэтому в этой реализации длина текста не определяет высоту внешнего редактора.

Пусть ширина следует за областью просмотра, а высота — за содержимым

Две настройки ширины выполняют разные задачи. Маска автоматического изменения размера .width внутреннего текстового представления позволяет менять его ширину при изменении суперпредставления. Свойство widthTracksTextView = true текстового контейнера позволяет менять ширину макета при изменении ширины текстового представления. Обе связи важны, когда редактор становится уже. Документация Apple по autoresizingMask, Документация Apple по widthTracksTextView

По вертикали isVerticallyResizable = true позволяет представлению документа менять высоту в соответствии с текстом. Большая высота текстового контейнера оставляет место для вертикального размещения текста, а heightTracksTextView = false не даёт ограничению макета зависеть от каждого изменения размера представления документа. Прокручиваемое представление остаётся ограниченной видимой областью. Документация Apple об изменении размера по вертикали, Документация Apple по heightTracksTextView

Между рамкой и текстом должно оставаться некоторое пространство. В примере явно задан внутренний отступ текстового контейнера в восемь пунктов. У текста также может быть дополнительный отступ из-за заполнения фрагментов строк контейнера, поэтому ширина области просмотра или текстового представления не обязательно совпадает с шириной, фактически занимаемой символами. Документация Apple по textContainerInset

Отделите обновление текста от согласования размеров

Координатор передаёт изменения текста через textDidChange, а привязка связывает их с состоянием родительского представления. updateNSView обрабатывает изменения, поступающие обратно из SwiftUI. Проверка на равенство не позволяет заменять строку редактора, когда после обычного редактирования возвращается то же значение. Документация Apple по делегату изменения текста, Документация Apple по Binding

Любая отличающаяся входящая строка считается полной заменой текста. Если приложение также заменяет текст программно во время редактирования, перед расширением этого примера определите, как сохранять выделение, историю отмены и текущую композицию ввода. В самой демонстрации строка меняется только через редактор.

В примере типы пользовательского интерфейса и координатор помечены @MainActor. В руководстве Swift по параллелизму изоляция главного актора описана как механизм защиты состояния пользовательского интерфейса, который требует выполнения аннотированного кода на этом акторе. Язык программирования Swift: параллелизм

Проверьте результат и найдите оставшиеся проблемы макета

Выполните эти проверки в примере приложения. Они позволяют отдельно оценить размер области просмотра, перенос строк, прокрутку и синхронизацию состояния, а не считать каждый симптом ошибкой фрейма SwiftUI.

  1. Посмотрите на рамку редактора. Изначально заданная область редактора имеет размер 420 × 180 пунктов.
  2. Включите Compact editor. Ширина должна стать равной 280 пунктам, а высота остаться равной 180 пунктам. Предложения должны переноситься на большее число строк.
  3. Прокрутите текст из примера. Документ должен перемещаться внутри той же рамки.
  4. Введите дополнительные строки. Количество символов должно измениться, а заданная высота редактора — остаться прежней.
  5. Удалите текст. Область просмотра должна сохранить заданные размеры, даже если в документе почти нет содержимого.
Симптом Что проверить в первую очередь
Рамка имеет правильный размер, но длинные строки не переносятся Проверьте маску автоматического изменения ширины документа и widthTracksTextView
При добавлении текста увеличивается окружающий макет Убедитесь, что представляемый элемент возвращает прокручиваемое представление и не возвращает высоту содержимого из sizeThatFits
До последующих строк нельзя прокрутить Проверьте вертикальную прокрутку, изменение размера документа по вертикали и ограничение высоты текстового контейнера
В другом макете родительского представления редактор остаётся узким Проверьте ширину, предлагаемую родительским представлением; обратный вызов принимает конечные предложения и не создаёт дополнительное пространство
При посторонних обновлениях состояния меняется положение текста Проверьте наличие безусловных присваиваний textView.string

Это последовательность диагностических проверок, выведенная из описанных выше связей размеров, а не заявленные результаты выполнения тестов.

Что меняется для высоты, зависящей от содержимого, или старых версий macOS?

У редактора, который расширяется, чтобы показать все строки, другая задача. Нужно рассчитать высоту содержимого при предложенной ширине и определить политику максимальной высоты. Простое разрешение вертикального изменения размера внутреннего документа не сообщает SwiftUI новую предпочтительную высоту. И наоборот, принятие предложенной высоты, как в этом примере, не позволяет рассчитать высоту по тексту.

При добавлении измерения сохраняйте выбранный текстовый движок. Apple поясняет, что обращение к экземпляру NSTextView через layoutManager может включить режим совместимости с TextKit 1. Документированный аналог для границ использования текстового контейнера в TextKit 2 — NSTextLayoutManager.usageBoundsForTextContainer. Для этого редактора с ограниченным размером не нужен ни один из этих API измерения. Рекомендации Apple по переходу на TextKit

Важна и граница по версиям. NSViewRepresentable доступен начиная с macOS 10.15, но его обратный вызов sizeThatFits требует macOS 13. Для более ранних целевых версий сохраняйте схему с прокручиваемым документом и используйте поведение определения размера по умолчанию в старом варианте представляемого элемента, задав приоритеты размеров AppKit или выбрав контейнер, рассчитанный на эту целевую версию. В этих версиях не рассчитывайте управлять макетом с помощью этого обратного вызова; отдельно проверьте соответствующую реализацию. Документация Apple по представляемым элементам, Доступность обратного вызова определения размера в Apple

Для редактора Mac с ограниченным размером чётко распределите обязанности: SwiftUI выделяет видимую область, прокручиваемое представление содержит растущий документ, а текстовый контейнер следует за доступной шириной. Такая схема позволяет предметно диагностировать размеры фрейма и не требует измерять текст для редактора с фиксированной высотой.

Источники