macosswiftuinstextviewnsviewrepresentable

How to auto-expand height of NSTextView in SwiftUI?


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.

  1. GitHub: unnamedd MacEditorTextView.swift <- Removed its ScrollView, but couldn't get text constraints right after doing so
  2. SO: Multiline editable text field in SwiftUI <- Helped me understand how to wrap, removed the ScrollView
  3. SO: Using a calculation by layoutManager <- My implementation didn't work
  4. Reddit: Wrap NSTextView in SwiftUI <- Tips seem spot on, but lack AppKit knowledge to follow
  5. SO: Autogrow height with intrinsicContentSize <- My implementation didn't work
  6. SO: Changing a ScrollView <- Couldn't figure out how to extrapolate
  7. SO: Cocoa tutorial on setting up an NSTextView
  8. Apple NSTextContainer Class
  9. 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
        }
    }

}


Solution

  • Solution thanks to @Asperi's tip to convert his UIKit code in this post. A few things had to change:

    Bugs:

    Hope this saves some others making SwiftUI Mac apps some time.

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