Reputation: 5997
I need a SwiftUI multiline text input control for MacOS satisfying the following requirements:
I tried using TextField with lineLimit() modifier which looks exactly how I need it, i.e. the label is showing correctly (incl. alignment), but it only has a height of 1 line if it's empty and the RETURN key doesn't do what I want (i.e. new line):
struct ContentView: View {
@State var field1 = ""
@State var field2 = ""
@State var notes = ""
var body: some View {
Form {
TextField("Label", text: $field1)
TextField("Long Label", text: $field2)
TextField("Notes", text: $notes)
.lineLimit(10)
}
.padding()
.frame(height: 150)
}
}
Then I tried a TextEditor, but this lacks the ability to define a label. The placement of the label is what makes the Form element extremly usefull for MacOS as it allows the right alignment of the labels without any hacks. The missing border style is only a small issue that can probably solved using border styles:
struct ContentView: View {
@State var field1 = ""
@State var field2 = ""
@State var notes = ""
var body: some View {
Form {
TextField("Label", text: $field1)
TextField("Long Label", text: $field2)
TextEditor(text: $notes)
}
.padding()
.frame(height: 150)
}
}
I'm only interested in a clean solution that is future-proof. If there's none, a hack must be at least very flexible, i.e. all the labels must be correctly aligned. The solution from workingdog doesn't fit for me, because as soon as the label text changes, everything falls apart.
Upvotes: 4
Views: 5354
Reputation: 19
There is my solution with auto-height, (cmd/shift/..)+enter to new line, enter to submit:
import SwiftUI
struct TextArea: NSViewRepresentable {
let setupTextView: (NSTextView) -> Void
let onSubmit: (NSTextView) -> Void
func makeNSView(
context: NSViewRepresentableContext<TextArea>
) -> NSTextView {
let textView = MyTextView(frame: .zero)
textView.parent = self
setupTextView(textView)
return textView
}
func updateNSView(
_ textView: NSTextView,
context: NSViewRepresentableContext<TextArea>
) {
}
}
// https://stackoverflow.com/a/76278534/23126671
private class MyTextView: NSTextView {
var parent: TextArea! // todo Set by constructor
//
private var heightConstraint: NSLayoutConstraint?
private var contentSize: CGSize {
get {
guard let layoutManager = layoutManager, let textContainer = textContainer else {
return .zero
}
layoutManager.ensureLayout(for: textContainer)
return layoutManager.usedRect(for: textContainer).size
}
}
override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) {
super.init(frame: frameRect, textContainer: container)
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.translatesAutoresizingMaskIntoConstraints = false
heightConstraint = self.heightAnchor.constraint(equalToConstant: 0)
heightConstraint?.isActive = true
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 36 {
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if (modifiers.isEmpty) {
parent.onSubmit(self)
return
} else if modifiers == [.command] || modifiers == [.shift] || modifiers == [.option] || modifiers == [.control] {
if let pointerIndex = self.selectedRanges.first?.rangeValue.location {
var newText = self.string
newText.insert("\n", at: newText.index(newText.startIndex, offsetBy: pointerIndex))
self.string = newText
self.setSelectedRange(NSMakeRange(pointerIndex + 1, 0))
return
} else {
// todo report if nil
}
}
}
super.keyDown(with: event)
}
required init?(coder: NSCoder) {
fatalError()
}
override var string: String {
didSet {
didChangeText()
}
}
override func layout() {
updateHeight()
super.layout()
}
override func didChangeText() {
updateHeight()
super.didChangeText()
}
private func updateHeight() {
heightConstraint?.constant = self.contentSize.height + textContainerInset.height * 2
}
}
Upvotes: 0
Reputation: 189
Building on the answer from JeremyP:
Looks like SwiftUI aligns the label and TextEditor
using .firstTextBaseline
, and that the alignment guide on TextEditor
is off. Knowing that, we can tweak it:
Form {
TextField("Title", text: .constant("Foo"))
LabeledContent("Label") {
TextEditor(text: .constant("My\nText\nView"))
.alignmentGuide(.firstTextBaseline) { $0[.firstTextBaseline] + 9 }
}
}
This feels like the most SwiftUI native solution to me.
Upvotes: 0
Reputation: 86651
This is a partial solution,
Form
{
TextField("Title", text: .constant("Foo"))
LabeledContent("Label")
{
TextEditor(text: .constant("My\nText\nView"))
}
}
The word "Label" will appear in the label position in the form correctly justified and aligned vertically and horizontally.
Unfortunately, the TextEditor
field itself is vertically displaced downwards slightly and I lack the SwiftUI expertise to fix it. If I find a way to do it, I'll amend my answer.
Upvotes: 5
Reputation: 30341
I made a 'custom' Form
to look like a real one.
Code:
struct ContentView: View {
@State private var field1 = ""
@State private var field2 = ""
@State private var notes = ""
@State private var maxLabelWidth: CGFloat?
var body: some View {
VStack {
FormItem("Label", text: $field1)
FormItem("Long Label", text: $field2)
FormItem("Notes", text: $notes, kind: .textEditor)
}
.padding()
.onPreferenceChange(MaxWidthKey.self) { maxWidth in
maxLabelWidth = maxWidth
}
.environment(\.maxLabelWidth, maxLabelWidth)
}
}
struct MaxWidthKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct MaxLabelWidthKey: EnvironmentKey {
static let defaultValue: CGFloat? = nil
}
extension EnvironmentValues {
var maxLabelWidth: CGFloat? {
get { self[MaxLabelWidthKey.self] }
set { self[MaxLabelWidthKey.self] = newValue }
}
}
struct FormItem: View {
enum Kind {
case textEditor
case textField
}
@Environment(\.maxLabelWidth) private var maxLabelWidth
@Binding private var text: String
private let title: String
private let kind: Kind
init(_ title: String, text: Binding<String>, kind: Kind = .textField) {
_text = text
self.title = title
self.kind = kind
}
var body: some View {
HStack(alignment: .top) {
Text(title)
.foregroundColor(Color(NSColor.labelColor))
.frame(maxWidth: maxLabelWidth, alignment: .trailing)
.background(
GeometryReader { geo in
Color.clear.preference(
key: MaxWidthKey.self,
value: geo.size.width
)
}
)
.padding(.top, 3)
switch kind {
case .textEditor:
TextEditor(text: $text)
.font(.system(size: 13))
.padding(.top, 3)
case .textField:
TextField("", text: $text)
}
}
}
}
Result:
Although it doesn't set the TextEditor
background, it's likely as close as you'll get.
Upvotes: 2
Reputation: 87605
I personally prefer putting same view with overlay
in such cases, like this:
Form {
TextField("Label", text: $field1)
TextField("Long Label", text: $field2)
TextEditor(text: $notes)
.overlay(
TextEditor(text: .constant("label"))
.allowsHitTesting(false)
.opacity(notes.isEmpty ? 1 : 0)
)
}
The disadvantage is that TextEditor
does not work like most other SwiftUI views: it draws the default background itself. You can use this hack to make the cursor visible through the overlay and draw the background yourself on the main TextEditor
.
Upvotes: 0
Reputation: 36119
How about this type of approach (adjust to your needs):
struct ContentView: View {
@State var field1 = ""
@State var field2 = ""
@State var notes = ""
var body: some View {
VStack (alignment: .leading, spacing: 20) {
Form {
TextField("Label", text: $field1)
TextField("Long Label", text: $field2)
}
HStack (alignment: .top) {
Spacer().frame(width: 30)
Text("Notes")
TextEditor(text: $notes).frame(height: 200)
}
}
.padding()
.frame(height: 400)
}
}
Upvotes: 0