How to auto-expand height of NSTextView in SwiftUI?
Asked Answered
P

2

4

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

}

Petal answered 28/7, 2020 at 4:36 Comment(5)
You can adapt to NS* the solution from How do I create a multiline TextField in SwiftUI?, it does the same.Bak
Thanks, hadn't noticed your answer on that Q. My adaptation crashes immediately after the window appears during a few loops through the async portion of recalculateHeight(). The only change in that function is, since UIView.sizeThatFits isn't available in AppKit, attempts to use NSView.fittingSize, .intrinsicContentSize, and .frame. All had the same result. I'll work on this tomorrow and post the code as an edit above. Thanks for pointing your answer out!Petal
2020-07-27 23:59:11.769655-0700 Tester[15798:897781] *** Assertion failure in -[NSCGSWindow setSize:], NSCGSWindow.m:1458 2020-07-27 23:59:11.769897-0700 Tester[15798:897781] [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_MINPetal
Even with a guard statement gating any sub-zero values, still crashes. Will do the coffee tomorrow.Petal
Solved. Will post shortly.Petal
P
6

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

  • NSView also lacks the view.sizeThatFits() for a proposed bounds change, so I found that the view's .visibleRect would work instead.

Bugs:

  • There is a bobble on first render (from smaller vertically to the proper size). I thought it was caused by the recalculateHeight(), which would print out some smaller values initially. A gating statement there stopped those values, but the bobble is still there.
  • Currently I set the placeholder text's inset by a magic number, which should be done based on the NSTextView's attributes, but I didn't find anything usable yet. If it has the same font I guess I could just add a space or two in front of the placeholder text and be done with it.

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


Petal answered 28/7, 2020 at 23:32 Comment(1)
Did you ever manage to solve the 'bobble'? I think I am seeing the same issue with a different technique: https://mcmap.net/q/130710/-can-i-stop-nsviewrepresentable-layout-lag-for-nstextview/978300Athalla
C
0

I found nice gist code created by unnamedd.

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

Sample Usage:

MacEditorTextView(
   text: $text,
   isEditable: true,
   font: .monospacedSystemFont(ofSize: 12, weight: .regular)
)
.frame(minWidth: 300,
       maxWidth: .infinity,
       minHeight: 100,
       maxHeight: .infinity)
.padding(12)
.cornerRadius(8)
Cartesian answered 8/5, 2021 at 13:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.