Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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
        }
    }
}
like image 943
Andrew Avatar asked Nov 01 '25 18:11

Andrew


2 Answers

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.

like image 145
Benzy Neez Avatar answered Nov 03 '25 12:11

Benzy Neez


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
        }
    }
}
like image 25
Andrew Avatar answered Nov 03 '25 12:11

Andrew



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!