How do I properly implement NSView constraints on the NSTextView below so it interacts with SwiftUI .frame()?
Goal
An NSTextView that, upon new lines, expands its frame vertically to force a SwiftUI parent view to render again (i.e., expand a background panel that's under the text + push down other content in VStack). The parent view is already wrapped in a ScrollView. Since the SwiftUI TextEditor is ugly and under-featured, I'm guessing several others new to MacOS will wonder how to do the same.
Update
@Asperi pointed out a sample for UIKit buried in another thread. I tried adapting that for AppKit, but there's some loop in the async recalculateHeight function. I'll look more at it with coffee tomorrow. Thanks Asperi. (Whoever you are, you are the SwiftUI SO daddy.)
Problem
The NSTextView implementation below edits merrily, but disobeys SwiftUI's vertical frame. Horizontally all is obeyed, but texts just continues down past the vertical height limit. Except, when switching focus away, the editor crops that extra text... until editing begins again.
What I've Tried
Sooo many posts as models. Below are a few. My shortfall I think is misunderstanding how to set constraints, how to use NSTextView objects, and perhaps overthinking things.
- I've tried implementing an NSTextContainer, NSLayoutManager, and NSTextStorage stack together in the code below, but no progress.
- I've played with GeometryReader inputs, no dice.
- I've printed LayoutManager and TextContainer variables on textdidChange(), but am not seeing dimensions change upon new lines. Also tried listening for .boundsDidChangeNotification / .frameDidChangeNotification.
- GitHub: unnamedd MacEditorTextView.swift <- Removed its ScrollView, but couldn't get text constraints right after doing so
- SO: Multiline editable text field in SwiftUI <- Helped me understand how to wrap, removed the ScrollView
- SO: Using a calculation by layoutManager <- My implementation didn't work
- Reddit: Wrap NSTextView in SwiftUI <- Tips seem spot on, but lack AppKit knowledge to follow
- SO: Autogrow height with intrinsicContentSize <- My implementation didn't work
- SO: Changing a ScrollView <- Couldn't figure out how to extrapolate
- SO: Cocoa tutorial on setting up an NSTextView
- Apple NSTextContainer Class
- Apple Tracking the Size of a Text View
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
    }
}
Quick Asperi's answer from a UIKit thread
Crash
*** 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
        }
    }
}
 
    