NSTextField - autoresize related to content size - up to 5 lines to display

316 views Asked by At

My target at the moment is:

  • TextField displayed in the bottom of the window
  • TextField covers window content (displayed above content)
  • TextField wrapped into ScrollView to have ability of scroll text
  • ScrollView must to have Height of TextField's content up to 5 lines of text. If TextField's content size is equal 5 lines or more than 5 lines - ScrollView must to have height 5 lines an user must to have ability to scroll text up and down

So I'm trying to do something like the following:

When no text in text field OR there is 1 line of text:

When >= 5 lines of text in text field:

But at the moment it's have a static height

SwiftUI ContentView:

import Combine
import SwiftUI

@available(macOS 12.0, *)
struct ContentView: View {
    @State var text: String = textSample
    
    var body: some View {
        ZStack {
            VStack{
                Spacer()
                
                Text("Hello")
                
                Spacer()
            }
            
            VStack {
                Spacer()
                
                DescriptionTextField(text: $text)
                    .padding(EdgeInsets(top: 3, leading: 3, bottom: 6, trailing: 3) )
                    .background(Color.green)
            }
        }
        .frame(minWidth: 450, minHeight: 300)
    }
}

let textSample =
"""
hello 1
hello 2
hello 3
hello 4
hello 5
hello 6
hello 7
hello 8
"""
import Foundation
import SwiftUI
import AppKit

struct DescriptionTextField: NSViewRepresentable {
    @Binding var text: String
    var isEditable: Bool = true
    var font: NSFont?    = .systemFont(ofSize: 17, weight: .regular)
    
    var onEditingChanged: () -> Void       = { }
    var onCommit        : () -> Void       = { }
    var onTextChange    : (String) -> Void = { _ in }
    
    func makeCoordinator() -> Coordinator { Coordinator(self) }
    
    func makeNSView(context: Context) -> CustomTextView {
        let textView = CustomTextView(text: text, isEditable: isEditable, font: font)
        
        textView.delegate = context.coordinator
        
        return textView
    }
    
    func updateNSView(_ view: CustomTextView, context: Context) {
        view.text = text
        view.selectedRanges = context.coordinator.selectedRanges
    }
}

extension DescriptionTextField {
    class Coordinator: NSObject, NSTextViewDelegate {
        var parent: DescriptionTextField
        var selectedRanges: [NSValue] = []
        
        init(_ parent: DescriptionTextField) {
            self.parent = parent
        }
        
        func textDidBeginEditing(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else {
                return
            }
            
            self.parent.text = textView.string
            self.parent.onEditingChanged()
        }
        
        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else {
                return
            }
            
            self.parent.text = textView.string
            self.selectedRanges = textView.selectedRanges
            
            if let txtView = textView.superview?.superview?.superview as? CustomTextView {
                txtView.refreshScrollViewConstrains()
                
            }
        }
        
        func textDidEndEditing(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            
            self.parent.text = textView.string
            self.parent.onCommit()
        }
    }
}

// MARK: - CustomTextView
final class CustomTextView: NSView {
    private var isEditable: Bool
    private var font: NSFont?
    
    weak var delegate: NSTextViewDelegate?
    
    var text: String { didSet { textView.string = text } }
    
    var selectedRanges: [NSValue] = [] {
        didSet {
            guard selectedRanges.count > 0 else { return }
            
            textView.selectedRanges = selectedRanges
        }
    }
    
    private lazy var scrollView: NSScrollView = {
        let scrollView = NSScrollView()
        
        scrollView.drawsBackground = false
        scrollView.borderType = .noBorder
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalRuler = false
        scrollView.autoresizingMask = [.width, .height]
//        scrollView.translatesAutoresizingMaskIntoConstraints = false
        
        return scrollView
    }()
    
    private lazy var textView: NSTextView = {
        let contentSize = scrollView.contentSize
        
        let textStorage = NSTextStorage()
        
        let layoutManager = NSLayoutManager()
        textStorage.addLayoutManager(layoutManager)
        
        let textContainer = NSTextContainer()
        textContainer.widthTracksTextView = true
        textContainer.containerSize = NSSize( width: contentSize.width, height: CGFloat.greatestFiniteMagnitude )
        
        layoutManager.addTextContainer(textContainer)
        
        let textView                     = NSTextView(frame: .zero, textContainer: textContainer)
        textView.autoresizingMask        = [.width, .height]
        textView.backgroundColor         = NSColor.clear
        textView.delegate                = self.delegate
        textView.drawsBackground         = true
        textView.font                    = self.font
        textView.isHorizontallyResizable = false
        textView.isVerticallyResizable   = true
        textView.minSize                 = NSSize( width: 150, height: min(contentSize.height, 13) )
        textView.maxSize                 = NSSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        textView.textColor               = NSColor.labelColor
        textView.allowsUndo              = true
        textView.isRichText              = true
        
        return textView
    } ()
    
    // MARK: - Init
    init(text: String, isEditable: Bool, font: NSFont?) {
        self.font       = font
        self.isEditable = isEditable
        self.text       = text
        
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    
    // MARK: - Life cycle
    override func viewWillDraw() {
        super.viewWillDraw()
        
        setupScrollViewConstraints()
        
        scrollView.documentView = textView
    }
    
    private func setupScrollViewConstraints() {
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        
        addSubview(scrollView)
        
        refreshScrollViewConstrains()
    }
    
    func refreshScrollViewConstrains() {
        print("Constrains updated!")
        
        let finalHeight = min(textView.contentSize.height, font!.pointSize * 6)
        
        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(lessThanOrEqualTo: topAnchor),
            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
            scrollView.heightAnchor.constraint(lessThanOrEqualToConstant: finalHeight)
        ])
        
        scrollView.needsUpdateConstraints = true
    }
}

extension NSTextView {
    var contentSize: CGSize {
        get {
            guard let layoutManager = layoutManager, let textContainer = textContainer else {
                print("textView no layoutManager or textContainer")
                return .zero
            }
            
            layoutManager.ensureLayout(for: textContainer)
            return layoutManager.usedRect(for: textContainer).size
        }
    }
}
3

There are 3 answers

0
Andrew_STOP_RU_WAR_IN_UA On BEST ANSWER

So reason of my issues was:

  • No call of refreshScrollViewConstrains() in textDidChange() (thanks to @VonC )

  • I didn't removed an old constraints before assign/activate a new one set of constraints :)

Code of the solution is the following:

import Foundation
import SwiftUI
import AppKit

struct DescriptionTextField: NSViewRepresentable {
    @Binding var text: String
    var isEditable: Bool = true
    var font: NSFont?    = .systemFont(ofSize: 17, weight: .regular)
    
    var onEditingChanged: () -> Void       = { }
    var onCommit        : () -> Void       = { }
    var onTextChange    : (String) -> Void = { _ in }
    
    func makeCoordinator() -> Coordinator { Coordinator(self) }
    
    func makeNSView(context: Context) -> CustomTextView {
        let textView = CustomTextView(text: text, isEditable: isEditable, font: font)
        
        textView.delegate = context.coordinator
        
        return textView
    }
    
    func updateNSView(_ view: CustomTextView, context: Context) {
        view.text = text
        view.selectedRanges = context.coordinator.selectedRanges
    }
}

extension DescriptionTextField {
    class Coordinator: NSObject, NSTextViewDelegate {
        var parent: DescriptionTextField
        var selectedRanges: [NSValue] = []
        
        init(_ parent: DescriptionTextField) {
            self.parent = parent
        }
        
        func textDidBeginEditing(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else {
                return
            }
            
            self.parent.text = textView.string
            self.parent.onEditingChanged()
        }
        
        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else {
                return
            }
            
            self.parent.text = textView.string
            self.selectedRanges = textView.selectedRanges
            
            if let txtView = textView.superview?.superview?.superview as? CustomTextView {
                txtView.refreshScrollViewConstrains()
            }
        }
        
        func textDidEndEditing(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            
            self.parent.text = textView.string
            self.parent.onCommit()
        }
    }
}

// MARK: - CustomTextView
final class CustomTextView: NSView {
    private var isEditable: Bool
    private var font: NSFont?
    
    weak var delegate: NSTextViewDelegate?
    
    var text: String { didSet { textView.string = text } }
    
    var selectedRanges: [NSValue] = [] {
        didSet {
            guard selectedRanges.count > 0 else { return }
            
            textView.selectedRanges = selectedRanges
        }
    }
    
    private lazy var scrollView: NSScrollView = {
        let scrollView = NSScrollView()
        
        scrollView.drawsBackground = false
        scrollView.borderType = .noBorder
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalRuler = false
        scrollView.autoresizingMask = [.width, .height]
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        
        return scrollView
    }()
    
    private lazy var textView: NSTextView = {
        let contentSize = scrollView.contentSize
        let textStorage = NSTextStorage()
        
        let layoutManager = NSLayoutManager()
        textStorage.addLayoutManager(layoutManager)
        
        let textContainer = NSTextContainer(containerSize: scrollView.frame.size)
        textContainer.widthTracksTextView = true
        textContainer.containerSize = NSSize(
            width: contentSize.width,
            height: CGFloat.greatestFiniteMagnitude
        )
        
        layoutManager.addTextContainer(textContainer)
        
        let textView                     = NSTextView(frame: .zero, textContainer: textContainer)
        textView.autoresizingMask        = .width
        textView.backgroundColor         = NSColor.clear
        textView.delegate                = self.delegate
        textView.drawsBackground         = true
        textView.font                    = self.font
        textView.isEditable              = self.isEditable
        textView.isHorizontallyResizable = false
        textView.isVerticallyResizable   = true
        textView.maxSize                 = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        textView.minSize                 = NSSize(width: 0, height: contentSize.height)
        textView.textColor               = NSColor.labelColor
        textView.allowsUndo              = true
        textView.isRichText              = true
        
        return textView
    } ()
    
    // MARK: - Init
    init(text: String, isEditable: Bool, font: NSFont?) {
        self.font       = font
        self.isEditable = isEditable
        self.text       = text
        
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    
    // MARK: - Life cycle
    override func viewWillDraw() {
        super.viewWillDraw()
        
        setupScrollViewConstraints()
        
        scrollView.documentView = textView
    }
    
    private func setupScrollViewConstraints() {
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        
        addSubview(scrollView)
        
        refreshScrollViewConstrains()
    }
    
    func refreshScrollViewConstrains() {
        print("Constrains updated!")
        
        let finalHeight = min(textView.contentSize.height, font!.pointSize * 6)
        
        scrollView.removeConstraints(scrollView.constraints)
        
        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(lessThanOrEqualTo: topAnchor),
            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
            scrollView.heightAnchor.constraint(equalToConstant: finalHeight)
        ])
        
        scrollView.needsUpdateConstraints = true
    }
}

extension NSTextView {
    var contentSize: CGSize {
        get {
            guard let layoutManager = layoutManager, let textContainer = textContainer else {
                print("textView no layoutManager or textContainer")
                return .zero
            }
            
            layoutManager.ensureLayout(for: textContainer)
            return layoutManager.usedRect(for: textContainer).size
        }
    }
}
12
VonC On

If the ScrollView's height should automatically adapt to the text in the NSTextView up to a maximum height corresponding to 5 lines, that means involves calculating the text's height (you did that in refreshScrollViewConstrains()) and dynamically setting the ScrollView's height based on that value.

You need to make sure your refreshScrollViewConstrains() function is called whenever the text in your NSTextView changes. Currently, this is not happening. Try and update your Coordinator's textDidChange(_:) with a call to refreshScrollViewConstrains():

func textDidChange(_ notification: Notification) {
    guard let textView = notification.object as? NSTextView else {
        return
    }
    self.parent.text = textView.string
    self.selectedRanges = textView.selectedRanges

    // Call refreshScrollViewConstrains() whenever the text changes
    (textView.superview?.superview?.superview as? CustomTextView)?.refreshScrollViewConstrains()
}

This code works only in case of text changes to lower height, but does not works in case of text become larger

One potential issue is that calling refreshScrollViewConstrains() only updates the constraints but does not force an immediate layout update, which can cause problems if the text grows in size and exceeds the current constraint limits.

In AppKit, changing constraints dynamically can be complex. Instead of removing constraints or setting them to inactive, a better approach is to modify them directly, which can avoid ambiguity and problems with the layout engine.

Let's try updating the refreshScrollViewConstrains() method by having a single, strong height constraint that we modify directly. Store this constraint as an instance variable in the CustomTextView class:

final class CustomTextView: NSView {
    // other variables ...
    
    private var heightConstraint: NSLayoutConstraint?
    
    // other methods ...

    func refreshScrollViewConstrains() {
        let finalHeight = min(textView.contentSize.height, font!.pointSize * 6)
        
        if let heightConstraint = self.heightConstraint {
            heightConstraint.constant = finalHeight
        } else {
            let newHeightConstraint = scrollView.heightAnchor.constraint(equalToConstant: finalHeight)
            newHeightConstraint.isActive = true
            self.heightConstraint = newHeightConstraint
        }
        
        scrollView.needsUpdateConstraints = true
        scrollView.layoutSubtreeIfNeeded() // Force immediate layout update
    }

}

That would establish a single height constraint for the scrollView and update its constant value directly, which should lead to less ambiguity and fewer issues. If the constraint has not yet been set, it will be created and activated; otherwise, the existing constraint will be modified.

I have removed the NSLayoutConstraint.activate([...]) line you had inside the refreshScrollViewConstrains() method, as it would conflict with this new approach.


GeometryReader just broke view layout, and no matter will I use gr.size or not.

Remove the GeometryReader from your ContentView, and focus on just the DescriptionTextField and its underlying NSTextView.

Make sure you update the NSTextView's text and height constraint properly. In updateNSView, make sure the NSTextView's content is set properly:

func updateNSView(_ view: CustomTextView, context: Context) {
    if view.text != text {
        view.text = text
    }
    view.selectedRanges = context.coordinator.selectedRanges
}

To troubleshoot why text isn't displaying, you might want to start simple:

  • Comment out the refreshScrollViewConstrains() logic to eliminate it as a cause for now.
  • Verify that text is being set appropriately on your CustomTextView within makeNSView and updateNSView.

For dynamic height:

  • Try to set the NSTextView's isVerticallyResizable to true.
  • Be cautious when modifying NSLayoutConstraint: if you deactivate an existing constraint, make sure a new one is properly activated to replace it.

For instance:

func refreshScrollViewConstrains() {
    guard let scrollView = self.enclosingScrollView else {
        return
    }
    
    let contentHeight = self.intrinsicContentSize.height
    let finalHeight = min(contentHeight, font!.pointSize * 6)
    
    if let existingConstraint = scrollView.constraints.first(where: { $0.firstAttribute == .height }) {
        existingConstraint.isActive = false
    }
    
    let newHeightConstraint = NSLayoutConstraint(item: scrollView, 
                                                attribute: .height, 
                                                relatedBy: .equal, 
                                                toItem: nil, 
                                                attribute: .notAnAttribute, 
                                                multiplier: 1, 
                                                constant: finalHeight)
    
    newHeightConstraint.priority = .defaultHigh
    scrollView.addConstraint(newHeightConstraint)
}

This logic will change the height constraint of the NSTextView's enclosing NSScrollView based on the intrinsicContentSize of the NSTextView.

Make sure you uncomment the refreshScrollViewConstrains() line in textDidChange(_:) after implementing the changes.

Use break points or logs to track how this code reacts.

2
Benzy Neez On

A pure SwiftUI solution is possible if a simple TextField is used instead of an NSTextField. So if this would be acceptable then a solution is outlined below. However, the possibility of using a wrapper for CustomTextView is considered at the end of the answer.

A TextField will grow to a limit of 5 lines if you simply apply .lineLimit(5). In iOS, this already gives you a working solution, because when the line count exceeds 5 then it becomes scrollable. With macOS, it seems the scroll behavior is buggy or non-functional. So a workaround is to wrap the field in a ScrollView.

In order to size the ScrollView correctly, it is shown as an overlay over a hidden TextField containing the same text. An onChange callback is used to scroll to the bottom whenever more text is added to the end.

Here you go:

struct ContentView: View {
    @State var text: String = ""

    var body: some View {
        ZStack {
            VStack{
                Spacer()
                Text("Hello")
                Spacer()
            }

            VStack {
                Spacer()
                ScrollableTextField(text: $text)
                    .padding(EdgeInsets(top: 3, leading: 3, bottom: 6, trailing: 3))
                    .background(Color.green)
                    .font(.system(size: 17)) // larger than default
            }
        }
        .frame(minWidth: 450, minHeight: 300)
    }
}

struct ScrollableTextField: View {
    private let label: LocalizedStringKey
    @Binding private var text: String
    private let lineLimit: Int

    init(
        _ label: LocalizedStringKey = "",
        text: Binding<String>,
        lineLimit: Int = 5
    ) {
        self.label = label
        self._text = text
        self.lineLimit = lineLimit
    }

    var body: some View {

        // Use a hidden TextField to establish the footprint,
        // allowing it to extend up to the specified number of lines
        TextField("hidden", text: .constant(text), axis: .vertical)
            .textFieldStyle(.plain)
            .lineLimit(lineLimit)
            .hidden()

            // Overlay with the visible TextField inside a ScrollView
            .overlay {
                ScrollViewReader { proxy in
                    ScrollView {
                        TextField(label, text: $text, axis: .vertical)
                            .textFieldStyle(.plain)
                            .background(alignment: .top) {
                                Divider().hidden().id("top")
                            }
                            .background(alignment: .bottom) {
                                Divider().hidden().id("bottom")
                            }
                    }
                    .onAppear {
                        proxy.scrollTo("bottom", anchor: .bottom)
                    }
                    .onChange(of: text) { [text] newText in

                        // Auto-scroll to the bottom if the last
                        // character has changed
                        if newText.last != text.last {

                            // Credit to Sweeper for the scroll solution
                            // https://stackoverflow.com/a/77078707/20386264
                            DispatchQueue.main.async {
                                proxy.scrollTo("top", anchor: .top)
                                proxy.scrollTo("bottom", anchor: .bottom)
                            }
                        }
                    }
                }
            }
    }
}

5LineTextField

If the text field really does need to be an NSTextField as in the question then it should be possible to replace the TextField in the overlay with DescriptionTextField or a similar wrapper for CustomTextView. However, when I tried this there seemed to be height issues. I think DescriptionTextField is adjusting the height in an attempt to solve the original issue of size. If you were to take that out then you might find it works in place of the TextField in the solution here.