swiftmacosswiftuilayoutnstextview

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


My target at the moment is:

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

Solution

  • So reason of my issues was:

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