G. Marc
G. Marc

Reputation: 5997

Multiline TextField/TextEditor in SwiftUI for MacOS in forms

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

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.

Upvotes: 4

Views: 5354

Answers (6)

Medvedev Ivan
Medvedev Ivan

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

thimic
thimic

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

JeremyP
JeremyP

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

George
George

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:

Result

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

Upvotes: 2

Phil Dukhov
Phil Dukhov

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

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

Related Questions