My target at the moment is:
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 downSo 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
}
}
}
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)
}
}
}
}
}
}
}

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.
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
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With