Как автоматически увеличить высоту NSTextView в SwiftUI?

Как мне правильно реализовать ограничения NSView для NSTextView ниже, чтобы он взаимодействовал с SwiftUI .frame()?

Цель

NSTextView, который при появлении новых строк расширяет свой фрейм по вертикали, чтобы заставить родительское представление SwiftUI снова отобразить (т. Е. Развернуть фоновую панель под текстом + подтолкнуть вниз другой контент в VStack). Родительское представление уже заключено в ScrollView. Поскольку SwiftUI TextEditor уродлив и недостаточно функционален, я предполагаю, что несколько других новичков в MacOS зададутся вопросом, как сделать то же самое.

Обновить

@Asperi указал на образец для UIKit, похороненный в другом потоке. Я попытался адаптировать это для AppKit, но в функции async recalculateHeight есть некоторый цикл. Я посмотрю на это завтра с кофе. Спасибо, Аспери. (Кем бы вы ни были, вы папа SwiftUI SO.)

Проблема

Реализация NSTextView, приведенная ниже, легко редактируется, но не соответствует вертикальной рамке SwiftUI. По горизонтали все соблюдается, но текст просто продолжается вниз за предел высоты по вертикали. За исключением того, что при переключении фокуса редактор обрезает этот лишний текст... пока редактирование не начнется снова.

Что я пробовал

Ооочень много постов в качестве моделей. Ниже приведены несколько. Мой недостаток, я думаю, заключается в неправильном понимании того, как устанавливать ограничения, как использовать объекты NSTextView, и, возможно, слишком много думать о вещах.

  • Я попытался реализовать стек NSTextContainer, NSLayoutManager и NSTextStorage вместе в приведенном ниже коде, но без прогресса.
  • Я играл с входами GeometryReader, без кубиков.
  • Я напечатал переменные LayoutManager и TextContainer в textdidChange(), но не вижу, чтобы размеры менялись при появлении новых строк. Также пробовал прослушивать.boundsDidChangeNotification /.frameDidChangeNotification.
  1. GitHub: безымянный MacEditorTextView.swift <- Удален его ScrollView, но не удалось получить текстовые ограничения сразу после этого
  2. SO: Многострочное редактируемое текстовое поле в SwiftUI <- Помогло мне понять, как обернуть, удалил ScrollView
  3. SO: Использование вычисления layoutManager <- Моя реализация не сработала
  4. Reddit: оберните NSTextView в SwiftUI <- советы кажутся правильными, но им не хватает знаний о AppKit, чтобы следовать
  5. SO: Autogrow height с intrinsicContentSize <- Моя реализация не сработала
  6. ТАК: изменение ScrollView <- не удалось понять, как экстраполировать
  7. SO: Учебник по какао по настройке NSTextView
  8. Класс Apple NSTextContainer
  9. Apple отслеживает размер текстового представления

ContentView.swift

import SwiftUI
import Combine

struct ContentView: View {
    @State var text = NSAttributedString(string: "Testing.... testing...")
    let nsFont: NSFont = .systemFont(ofSize: 20)

    var body: some View {
// ScrollView would go here
        VStack(alignment: .center) {
            GeometryReader { geometry in
                NSTextEditor(text: $text.didSet { text in react(to: text) },
                             nsFont: nsFont,
                             geometry: geometry)
                    .frame(width: 500, // Wraps to width
                           height: 300) // Disregards this during editing
                    .background(background)
            }
           Text("Editing text above should push this down.")
        }
    }

    var background: some View {
        ...
    }

    // Seeing how updates come back; I prefer setting them on textDidEndEditing to work with a database
    func react(to text: NSAttributedString) {
        print(#file, #line, #function, text)
    }

}

// Listening device into @State
extension Binding {

    func didSet(_ then: @escaping (Value) ->Void) -> Binding {
        return Binding(
            get: {
                return self.wrappedValue
            },
            set: {
                then($0)
                self.wrappedValue = $0
            }
        )
    }
}

NSTextEditor.swift


import SwiftUI

struct NSTextEditor: View, NSViewRepresentable {
    typealias Coordinator = NSTextEditorCoordinator
    typealias NSViewType = NSTextView

    @Binding var text: NSAttributedString
    let nsFont: NSFont
    var geometry: GeometryProxy

    func makeNSView(context: NSViewRepresentableContext<NSTextEditor>) -> NSTextEditor.NSViewType {
        return context.coordinator.textView
    }

    func updateNSView(_ nsView: NSTextView, context: NSViewRepresentableContext<NSTextEditor>) { }

    func makeCoordinator() -> NSTextEditorCoordinator {
        let coordinator =  NSTextEditorCoordinator(binding: $text,
                                                   nsFont: nsFont,
                                                   proxy: geometry)
        return coordinator
    }
}

class  NSTextEditorCoordinator : NSObject, NSTextViewDelegate {
    let textView: NSTextView
    var font: NSFont
    var geometry: GeometryProxy

    @Binding var text: NSAttributedString

    init(binding: Binding<NSAttributedString>,
         nsFont: NSFont,
         proxy: GeometryProxy) {
        _text = binding
        font = nsFont
        geometry = proxy

        textView = NSTextView(frame: .zero)
        textView.autoresizingMask = [.height, .width]
        textView.textColor = NSColor.textColor
        textView.drawsBackground = false
        textView.allowsUndo = true


        textView.isAutomaticLinkDetectionEnabled = true
        textView.displaysLinkToolTips = true
        textView.isAutomaticDataDetectionEnabled = true
        textView.isAutomaticTextReplacementEnabled = true
        textView.isAutomaticDashSubstitutionEnabled = true
        textView.isAutomaticSpellingCorrectionEnabled = true
        textView.isAutomaticQuoteSubstitutionEnabled = true
        textView.isAutomaticTextCompletionEnabled = true
        textView.isContinuousSpellCheckingEnabled = true
        textView.usesAdaptiveColorMappingForDarkAppearance = true

//        textView.importsGraphics = true // 100% size, layoutManger scale didn't fix
//        textView.allowsImageEditing = true // NSFileWrapper error
//        textView.isIncrementalSearchingEnabled = true
//        textView.usesFindBar = true
//        textView.isSelectable = true
//        textView.usesInspectorBar = true
        // Context Menu show styles crashes


        super.init()
        textView.textStorage?.setAttributedString($text.wrappedValue)
        textView.delegate = self
    }

//     Calls on every character stroke
    func textDidChange(_ notification: Notification) {
        switch notification.name {
        case NSText.boundsDidChangeNotification:
            print("bounds did change")
        case NSText.frameDidChangeNotification:
            print("frame did change")
        case NSTextView.frameDidChangeNotification:
            print("FRAME DID CHANGE")
        case NSTextView.boundsDidChangeNotification:
            print("BOUNDS DID CHANGE")
        default:
            return
        }
//        guard notification.name == NSText.didChangeNotification,
//              let update = (notification.object as? NSTextView)?.textStorage else { return }
//        text = update
    }

    // Calls only after focus change
    func textDidEndEditing(_ notification: Notification) {
        guard notification.name == NSText.didEndEditingNotification,
              let update = (notification.object as? NSTextView)?.textStorage else { return }
        text = update
    }
}

Быстрый ответ Аспери из потока UIKit

Сбой

*** Assertion failure in -[NSCGSWindow setSize:], NSCGSWindow.m:1458
[General] Invalid parameter not satisfying: 
   size.width >= 0.0 
&& size.width < (CGFloat)INT_MAX - (CGFloat)INT_MIN 
&& size.height >= 0.0 
&& size.height < (CGFloat)INT_MAX - (CGFloat)INT_MIN

import SwiftUI

struct AsperiMultiLineTextField: View {

    private var placeholder: String
    private var onCommit: (() -> Void)?

    @Binding private var text: NSAttributedString
    private var internalText: Binding<NSAttributedString> {
        Binding<NSAttributedString>(get: { self.text } ) {
            self.text = $0
            self.showingPlaceholder = $0.string.isEmpty
        }
    }

    @State private var dynamicHeight: CGFloat = 100
    @State private var showingPlaceholder = false

    init (_ placeholder: String = "", text: Binding<NSAttributedString>, onCommit: (() -> Void)? = nil) {
        self.placeholder = placeholder
        self.onCommit = onCommit
        self._text = text
        self._showingPlaceholder = State<Bool>(initialValue: self.text.string.isEmpty)
    }

    var body: some View {
        NSTextViewWrapper(text: self.internalText, calculatedHeight: $dynamicHeight, onDone: onCommit)
            .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
            .background(placeholderView, alignment: .topLeading)
    }

    @ViewBuilder
    var placeholderView: some View {
            if showingPlaceholder {
                Text(placeholder).foregroundColor(.gray)
                    .padding(.leading, 4)
                    .padding(.top, 8)
            }
    }
}

fileprivate struct NSTextViewWrapper: NSViewRepresentable {
    typealias NSViewType = NSTextView

    @Binding var text: NSAttributedString
    @Binding var calculatedHeight: CGFloat
    var onDone: (() -> Void)?

    func makeNSView(context: NSViewRepresentableContext<NSTextViewWrapper>) -> NSTextView {
        let textField = NSTextView()
        textField.delegate = context.coordinator

        textField.isEditable = true
        textField.font = NSFont.preferredFont(forTextStyle: .body)
        textField.isSelectable = true
        textField.drawsBackground = false
        textField.allowsUndo = true
        /// Disabled these lines as not available/neeed/appropriate for AppKit
//        textField.isUserInteractionEnabled = true
//        textField.isScrollEnabled = false
//        if nil != onDone {
//            textField.returnKeyType = .done
//        }
        textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        return textField
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text, height: $calculatedHeight, onDone: onDone)
    }

    func updateNSView(_ NSView: NSTextView, context: NSViewRepresentableContext<NSTextViewWrapper>) {
        NSTextViewWrapper.recalculateHeight(view: NSView, result: $calculatedHeight)
    }

    fileprivate static func recalculateHeight(view: NSView, result: Binding<CGFloat>) {
/// UIView.sizeThatFits is not available in AppKit. Tried substituting below, but there's a loop that crashes.
//        let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude))

// tried reportedSize = view.frame, view.intrinsicContentSize
        let reportedSize = view.fittingSize
        let newSize = CGSize(width: reportedSize.width, height: CGFloat.greatestFiniteMagnitude)
        if result.wrappedValue != newSize.height {
            DispatchQueue.main.async {
                result.wrappedValue = newSize.height // !! must be called asynchronously
            }
        }
    }


    final class Coordinator: NSObject, NSTextViewDelegate {
        var text: Binding<NSAttributedString>
        var calculatedHeight: Binding<CGFloat>
        var onDone: (() -> Void)?

        init(text: Binding<NSAttributedString>, height: Binding<CGFloat>, onDone: (() -> Void)? = nil) {
            self.text = text
            self.calculatedHeight = height
            self.onDone = onDone
        }

        func textDidChange(_ notification: Notification) {
            guard notification.name == NSText.didChangeNotification,
                  let textView = (notification.object as? NSTextView),
                  let latestText = textView.textStorage else { return }
            text.wrappedValue = latestText
            NSTextViewWrapper.recalculateHeight(view: textView, result: calculatedHeight)
        }

        func textView(_ textView: NSTextView, shouldChangeTextIn: NSRange, replacementString: String?) -> Bool {
            if let onDone = self.onDone, replacementString == "\n" {
                textView.resignFirstResponder()
                onDone()
                return false
            }
            return true
        }
    }

}

2 ответа

Решение

Решение благодаря совету @Asperi преобразовать его код UIKit в этом посте.Пришлось изменить несколько вещей:

  • NSView также не имеет view.sizeThatFits() для предлагаемого изменения границ, поэтому я обнаружил, что вместо этого будет работать.visibleRect представления.

Ошибки:

  • При первом рендеринге появляется боб (от меньшего по вертикали до нужного размера). Я думал, что это было вызвано recalculateHeight(), который сначала распечатал несколько меньших значений. Оператор стробирования остановил эти значения, но помеха все еще существует.
  • В настоящее время я устанавливаю вставку текста-заполнителя с помощью магического числа, что должно быть сделано на основе атрибутов NSTextView, но я еще не нашел ничего пригодного для использования. Если у него такой же шрифт, я думаю, я мог бы просто добавить пробел или два перед текстом заполнителя и покончить с этим.

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

import SwiftUI

// Wraps the NSTextView in a frame that can interact with SwiftUI
struct MultilineTextField: View {

    private var placeholder: NSAttributedString
    @Binding private var text: NSAttributedString
    @State private var dynamicHeight: CGFloat // MARK TODO: - Find better way to stop initial view bobble (gets bigger)
    @State private var textIsEmpty: Bool
    @State private var textViewInset: CGFloat = 9 // MARK TODO: - Calculate insetad of magic number
    var nsFont: NSFont

    init (_ placeholder: NSAttributedString = NSAttributedString(string: ""),
          text: Binding<NSAttributedString>,
          nsFont: NSFont) {
        self.placeholder = placeholder
        self._text = text
        _textIsEmpty = State(wrappedValue: text.wrappedValue.string.isEmpty)
        self.nsFont = nsFont
        _dynamicHeight = State(initialValue: nsFont.pointSize)
    }

    var body: some View {
        ZStack {
            NSTextViewWrapper(text: $text,
                              dynamicHeight: $dynamicHeight,
                              textIsEmpty: $textIsEmpty,
                              textViewInset: $textViewInset,
                              nsFont: nsFont)
                .background(placeholderView, alignment: .topLeading)
                // Adaptive frame applied to this NSViewRepresentable
                .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
        }
    }

    // Background placeholder text matched to default font provided to the NSViewRepresentable
    var placeholderView: some View {
        Text(placeholder.string)
            // Convert NSFont
            .font(.system(size: nsFont.pointSize))
            .opacity(textIsEmpty ? 0.3 : 0)
            .padding(.leading, textViewInset)
            .animation(.easeInOut(duration: 0.15))
    }
}

// Creates the NSTextView
fileprivate struct NSTextViewWrapper: NSViewRepresentable {

    @Binding var text: NSAttributedString
    @Binding var dynamicHeight: CGFloat
    @Binding var textIsEmpty: Bool
    // Hoping to get this from NSTextView,
    // but haven't found the right parameter yet
    @Binding var textViewInset: CGFloat
    var nsFont: NSFont

    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text,
                           height: $dynamicHeight,
                           textIsEmpty: $textIsEmpty,
                           nsFont: nsFont)
    }

    func makeNSView(context: NSViewRepresentableContext<NSTextViewWrapper>) -> NSTextView {
        return context.coordinator.textView
    }

    func updateNSView(_ textView: NSTextView, context: NSViewRepresentableContext<NSTextViewWrapper>) {
        NSTextViewWrapper.recalculateHeight(view: textView, result: $dynamicHeight, nsFont: nsFont)
    }

    fileprivate static func recalculateHeight(view: NSView, result: Binding<CGFloat>, nsFont: NSFont) {
        // Uses visibleRect as view.sizeThatFits(CGSize())
        // is not exposed in AppKit, except on NSControls.
        let latestSize = view.visibleRect
        if result.wrappedValue != latestSize.height &&
            // MARK TODO: - The view initially renders slightly smaller than needed, then resizes.
            // I thought the statement below would prevent the @State dynamicHeight, which
            // sets itself AFTER this view renders, from causing it. Unfortunately that's not
            // the right cause of that redawing bug.
            latestSize.height > (nsFont.pointSize + 1) {
            DispatchQueue.main.async {
                result.wrappedValue = latestSize.height
                print(#function, latestSize.height)
            }
        }
    }
}

// Maintains the NSTextView's persistence despite redraws
fileprivate final class Coordinator: NSObject, NSTextViewDelegate, NSControlTextEditingDelegate {
    var textView: NSTextView
    @Binding var text: NSAttributedString
    @Binding var dynamicHeight: CGFloat
    @Binding var textIsEmpty: Bool
    var nsFont: NSFont

    init(text: Binding<NSAttributedString>,
         height: Binding<CGFloat>,
         textIsEmpty: Binding<Bool>,
         nsFont: NSFont) {

        _text = text
       _dynamicHeight = height
        _textIsEmpty = textIsEmpty
        self.nsFont = nsFont

        textView = NSTextView(frame: .zero)
        textView.isEditable = true
        textView.isSelectable = true

        // Appearance
        textView.usesAdaptiveColorMappingForDarkAppearance = true
        textView.font = nsFont
        textView.textColor = NSColor.textColor
        textView.drawsBackground = false
        textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

        // Functionality (more available)
        textView.allowsUndo = true
        textView.isAutomaticLinkDetectionEnabled = true
        textView.displaysLinkToolTips = true
        textView.isAutomaticDataDetectionEnabled = true
        textView.isAutomaticTextReplacementEnabled = true
        textView.isAutomaticDashSubstitutionEnabled = true
        textView.isAutomaticSpellingCorrectionEnabled = true
        textView.isAutomaticQuoteSubstitutionEnabled = true
        textView.isAutomaticTextCompletionEnabled = true
        textView.isContinuousSpellCheckingEnabled = true

        super.init()
        // Load data from binding and set font
        textView.textStorage?.setAttributedString(text.wrappedValue)
        textView.textStorage?.font = nsFont
        textView.delegate = self
    }

    func textDidChange(_ notification: Notification) {
        // Recalculate height after every input event
        NSTextViewWrapper.recalculateHeight(view: textView, result: $dynamicHeight, nsFont: nsFont)
        // If ever empty, trigger placeholder text visibility
        if let update = (notification.object as? NSTextView)?.string {
            textIsEmpty = update.isEmpty
        }
    }

    func textDidEndEditing(_ notification: Notification) {
        // Update binding only after editing ends; useful to gate NSManagedObjects
        $text.wrappedValue = textView.attributedString()
    }
}


Я нашел хороший код сути, созданный безымянным.

https://gist.github.com/unnamedd/6e8c3fbc806b8deb60fa65d6b9affab0

Пример использования:

      MacEditorTextView(
   text: $text,
   isEditable: true,
   font: .monospacedSystemFont(ofSize: 12, weight: .regular)
)
.frame(minWidth: 300,
       maxWidth: .infinity,
       minHeight: 100,
       maxHeight: .infinity)
.padding(12)
.cornerRadius(8)
Другие вопросы по тегам