2

I need a SwiftUI multiline text input control for MacOS satisfying the following requirements:

  • allow cursor control like in a editor (i.e. pressing RETURN causes a new line)
  • working label in a form

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

enter image description here

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

enter image description here

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.

G. Marc
  • 4,987
  • 4
  • 32
  • 49
  • you could try using a `HStack`, such as: `HStack { Text("Note") TextEditor(text: $notes) }` to get a "label" for your TextEditor. – workingdog support Ukraine Dec 04 '21 at 12:28
  • Unfortunately, it's not that easy. The label will be left aligned to the other text fields and the notes field will be inset. "Form" is somehow recognizing the labels of the "real" text fields and does all the alignment stuff. But not for the self constructed label in the HStack. – G. Marc Dec 04 '21 at 13:06

5 Answers5

2

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:

Result

Although it doesn't set the TextEditor background, it's likely as close as you'll get.

George
  • 25,988
  • 10
  • 79
  • 133
  • This is by far the best solution yet! Thank you so much for this. Also, it's probably future proof as it's not a "hacky" approach. – G. Marc Dec 09 '21 at 06:49
1

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.

JeremyP
  • 84,577
  • 15
  • 123
  • 161
0

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)
    }
}
  • I thought about hacks like this, but was hoping for a clean solution. The problem with this is, that you have to adjust the width of the label manually whereas "Form" does it automatically according to the maximum width of all labels. If I'd go with something like this, I would probably remove "Form" completely so there's never a discrepancy between the width of the form controls and the ones in the HStack. – G. Marc Dec 04 '21 at 14:12
0

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.

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • I don't understand this approach. If I use this code, there's no label for the text editor in the "labels" column to the left, but it's overlayed to the text editor itself. Also, it's not possible to enter anything into the text editor. I'm on XCode 13.1 targeting MacOS 12. Did you test it with those versions? – G. Marc Dec 07 '21 at 04:47
  • These are exact versions I'm using. Have you added `NSTextView` extension from the linked answer? – Phil Dukhov Dec 07 '21 at 07:21
  • Alright, with the hack from the link the text is editable. But there's no form label, which is not ideal. – G. Marc Dec 08 '21 at 06:00
0

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.

thimic
  • 189
  • 1
  • 8