How do I create a multiline TextField in SwiftUI?
Asked Answered
R

17

205

I've been trying to create a multiline TextField in SwiftUI, but I can't figure out how.

This is the code I currently have:

struct EditorTextView : View {
    @Binding var text: String
    
    var body: some View {
        TextField($text)
            .lineLimit(4)
            .multilineTextAlignment(.leading)
            .frame(minWidth: 100, maxWidth: 200, minHeight: 100, maxHeight: .infinity, alignment: .topLeading)
    }
}

#if DEBUG
let sampleText = """
Very long line 1
Very long line 2
Very long line 3
Very long line 4
"""

struct EditorTextView_Previews : PreviewProvider {
    static var previews: some View {
        EditorTextView(text: .constant(sampleText))
            .previewLayout(.fixed(width: 200, height: 200))
    }
}
#endif

But this is the output:

enter image description here

Rubin answered 6/6, 2019 at 6:30 Comment(1)
I just tried to make a multiline textfield with swiftui in Xcode Version 11.0 (11A419c), the GM, using lineLimit(). It still does not work. I can't believe Apple hasn't fixed this yet. A multiline textfield is fairly common in mobile Apps.Sclerodermatous
E
60

Update: While Xcode11 beta 4 now does support TextView, I've found that wrapping a UITextView is still be best way to get editable multiline text to work. For instance, TextView has display glitches where text does not appear properly inside the view.

Original (beta 1) answer:

For now, you could wrap a UITextView to create a composable View:

import SwiftUI
import Combine

final class UserData: BindableObject  {
    let didChange = PassthroughSubject<UserData, Never>()

    var text = "" {
        didSet {
            didChange.send(self)
        }
    }

    init(text: String) {
        self.text = text
    }
}

struct MultilineTextView: UIViewRepresentable {
    @Binding var text: String

    func makeUIView(context: Context) -> UITextView {
        let view = UITextView()
        view.isScrollEnabled = true
        view.isEditable = true
        view.isUserInteractionEnabled = true
        return view
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }
}

struct ContentView : View {
    @State private var selection = 0
    @EnvironmentObject var userData: UserData

    var body: some View {
        TabbedView(selection: $selection){
            MultilineTextView(text: $userData.text)
                .tabItemLabel(Image("first"))
                .tag(0)
            Text("Second View")
                .font(.title)
                .tabItemLabel(Image("second"))
                .tag(1)
        }
    }
}

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(UserData(
                text: """
                        Some longer text here
                        that spans a few lines
                        and runs on.
                        """
            ))

    }
}
#endif

enter image description here

Economic answered 11/6, 2019 at 17:51 Comment(4)
Great temporary solve! Accepting for now until it can be solved using pure SwiftUI.Rubin
This solution allows you to display text that already has newlines in it, but it doesn't seem to break/wrap naturally long lines. (The text just keeps growing horizontally on one line, outside the frame.) Any ideas how to get long lines to wrap?Hertzfeld
If I use State (instead of an EnvironmentObject with a Publisher) and pass it as a binding to MultilineTextView, it doesn't seem to work. How can I reflect changes back to State?Dekeles
Is there any way to set a default text in the textview without using an environmentObject?Harveyharvie
T
275

iOS 16+

TextField can be configured to expand vertically using the new axis parameter. Also, it takes the lineLimit modifier to limit the lines in the given range:

TextField("Title", text: $text,  axis: .vertical)
    .lineLimit(5...10)

The .lineLimit modifier now also supports more advanced behaviors, like reserving a minimum amount of space and expanding as more content is added, and then scrolling once the content exceeds the upper limit


iOS 14+

It is called TextEditor

struct ContentView: View {
    @State var text: String = "Multiline \ntext \nis called \nTextEditor"

    var body: some View {
        TextEditor(text: $text)
    }
}

🎁 Dynamic growing height:

If you want it to grow as you type, embed it in a ZStack with a Text like this:

Demo


iOS 13+

you can use the native UITextView right in the SwiftUI code with this struct:

struct TextView: UIViewRepresentable {
    
    typealias UIViewType = UITextView
    var configuration = { (view: UIViewType) in }
    
    func makeUIView(context: UIViewRepresentableContext<Self>) -> UIViewType {
        UIViewType()
    }
    
    func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<Self>) {
        configuration(uiView)
    }
}

Usage

struct ContentView: View {
    var body: some View {
        TextView() {
            $0.textColor = .red
            // Any other setup you like
        }
    }
}

💡 Advantages:

  • Support for iOS 13
  • Shared with the legacy code
  • Tested for years in UIKit
  • Fully customizable
  • All other benefits of the original UITextView
Tobey answered 3/2, 2020 at 12:35 Comment(12)
If anyone is looking at this answer and wondering how to pass the actual text to the TextView struct then add the following line below the one that sets the textColor: $0.text = "Some text"Poem
How do you bind the text to a variable? Or otherwise retrieve the text?Sager
The first option already have the text binding. The second one is a standard UITextView. You can interact with it as you usually do in UIKit.Tobey
When I use this approach in a VStack inside a ScrollView, the TextEditor resizes it's height (mostly) but still has a scroll bar inside it. Has anyone ever run into that?Sitting
In all my experiments with the dynamic growing height of the TextEditor, the magic sauce is that it's embedded in a List. Also, if you give the TextEditor a max height .frame(maxHeight: 200), it will grow up until that height and then begin scrolling.Animato
TextEditor doesn't resize automatically when typing in iOS15.4Montreal
@Montreal try the Hack I have provided 🎁Tobey
yea the Stack Text hack isn't working with the latest iosMontreal
Unfortunately unavailable in watchOS :(Paleface
You upated your answer after I posted mine.Jourdan
for iOS 16+ if the axis is set, you can just use the fixed number of lines: .lineLimit(2, reservesSpace: true)Essieessinger
When the format: parameter is set, there is no axis: anymore :(Dill
W
135

Ok, I started with @sas approach, but needed it really look&feel as multi-line text field with content fit, etc. Here is what I've got. Hope it will be helpful for somebody else... Used Xcode 11.1.

Provided custom MultilineTextField has:
1. content fit
2. autofocus
3. placeholder
4. on commit

Preview of swiftui multiline textfield with content fit Added placeholder

import SwiftUI
import UIKit

fileprivate struct UITextViewWrapper: UIViewRepresentable {
    typealias UIViewType = UITextView

    @Binding var text: String
    @Binding var calculatedHeight: CGFloat
    var onDone: (() -> Void)?

    func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
        let textField = UITextView()
        textField.delegate = context.coordinator

        textField.isEditable = true
        textField.font = UIFont.preferredFont(forTextStyle: .body)
        textField.isSelectable = true
        textField.isUserInteractionEnabled = true
        textField.isScrollEnabled = false
        textField.backgroundColor = UIColor.clear
        if nil != onDone {
            textField.returnKeyType = .done
        }

        textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        return textField
    }

    func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
        if uiView.text != self.text {
            uiView.text = self.text
        }
        if uiView.window != nil, !uiView.isFirstResponder {
            uiView.becomeFirstResponder()
        }
        UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
    }

    fileprivate static func recalculateHeight(view: UIView, result: Binding<CGFloat>) {
        let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
        if result.wrappedValue != newSize.height {
            DispatchQueue.main.async {
                result.wrappedValue = newSize.height // !! must be called asynchronously
            }
        }
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text, height: $calculatedHeight, onDone: onDone)
    }

    final class Coordinator: NSObject, UITextViewDelegate {
        var text: Binding<String>
        var calculatedHeight: Binding<CGFloat>
        var onDone: (() -> Void)?

        init(text: Binding<String>, height: Binding<CGFloat>, onDone: (() -> Void)? = nil) {
            self.text = text
            self.calculatedHeight = height
            self.onDone = onDone
        }

        func textViewDidChange(_ uiView: UITextView) {
            text.wrappedValue = uiView.text
            UITextViewWrapper.recalculateHeight(view: uiView, result: calculatedHeight)
        }

        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            if let onDone = self.onDone, text == "\n" {
                textView.resignFirstResponder()
                onDone()
                return false
            }
            return true
        }
    }

}

struct MultilineTextField: View {

    private var placeholder: String
    private var onCommit: (() -> Void)?

    @Binding private var text: String
    private var internalText: Binding<String> {
        Binding<String>(get: { self.text } ) {
            self.text = $0
            self.showingPlaceholder = $0.isEmpty
        }
    }

    @State private var dynamicHeight: CGFloat = 100
    @State private var showingPlaceholder = false

    init (_ placeholder: String = "", text: Binding<String>, onCommit: (() -> Void)? = nil) {
        self.placeholder = placeholder
        self.onCommit = onCommit
        self._text = text
        self._showingPlaceholder = State<Bool>(initialValue: self.text.isEmpty)
    }

    var body: some View {
        UITextViewWrapper(text: self.internalText, calculatedHeight: $dynamicHeight, onDone: onCommit)
            .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
            .background(placeholderView, alignment: .topLeading)
    }

    var placeholderView: some View {
        Group {
            if showingPlaceholder {
                Text(placeholder).foregroundColor(.gray)
                    .padding(.leading, 4)
                    .padding(.top, 8)
            }
        }
    }
}

#if DEBUG
struct MultilineTextField_Previews: PreviewProvider {
    static var test:String = ""//some very very very long description string to be initially wider than screen"
    static var testBinding = Binding<String>(get: { test }, set: {
//        print("New value: \($0)")
        test = $0 } )

    static var previews: some View {
        VStack(alignment: .leading) {
            Text("Description:")
            MultilineTextField("Enter some text here", text: testBinding, onCommit: {
                print("Final text: \(test)")
            })
                .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.black))
            Text("Something static here...")
            Spacer()
        }
        .padding()
    }
}
#endif
Winker answered 31/10, 2019 at 8:4 Comment(27)
This works great in a VStack, but if you put it in a scroll view (either directly or wrapping the VStack), it breaks the behavior and the text scrolls off the screen rather than wrapping.Ornas
Fixed that caseWinker
There is still some sort of update problem, when using your approach in a List. The cells miss the last update, meaning, the cells are too small to fit the text fields. If you force a view update (e.g. by rotating the device), everything gets resized correctly. I suppose this could be due to updating the dynamicHeight asyncronously in UITextViewWrapper.recalculateHeight, but I haven't found a good solution yet how to fix it.Fifteen
Also you should think about setting the backgroundColor of the UITextField to UIColor.clear to enable custom backgrounds using SwiftUI and about removing the auto-firstresponder, because it breaks when using multiple MultilineTextFields in one view (every keystroke, all the text fields try to get the responder again).Fifteen
This is the best answer! Thank you! Also, when using forms, you need to remove the label .padding, set the backgroundColor to clear as mentioned above, and use textField.textContainerInset = UIEdgeInsets(top: 0, left: -textField.textContainer.lineFragmentPadding, bottom: 0, right: -textField.textContainer.lineFragmentPadding)Yearlong
@Yearlong As explained in this answer from another question, you can just do textField.textContainerInset = UIEdgeInsets.zero + textField.textContainer.lineFragmentPadding = 0 and it works fine 👌🏻 @Winker If you do as mentioned, you will then need to remove .padding(.leading, 4) and .padding(.top, 8) otherwise it'll look broken. Also, you could change .foregroundColor(.gray) for .foregroundColor(Color(UIColor.tertiaryLabel)) to match the placeholders' color in TextFields (I didn't check if if is updating with dark mode).Brunella
Oh, and, I also changed @State private var dynamicHeight: CGFloat = 100 for @State private var dynamicHeight: CGFloat = UIFont.systemFontSize to fix a small "glitch" when the MultilineTextField appears (it shows big for a short time and then shrinks).Brunella
when open always open keyboard how cancel that ?Martymartyn
@q8yas, you can comment or remove code related to uiView.becomeFirstResponderWinker
@Winker I made a few changes to fix some minor graphical bugs. I also commented the becomeFirstResponder on appear because it might create problems for some people. If they need it, they'll find it easily as it is still in the code.Brunella
Thanks everybody for comments! I really appreciate that. The provided snapshot is a demo of approach, which was configured for a specific purpose. All your proposals are correct, but for your purposes. You are free to copy-paste this code and reconfigure it as much far as you wish for your purpose.Winker
Yeah but some of our points are just bugs… it should be fixed for every purpose 🤔 I don't understand why you didn't accept my changes. I understand you want to keep the becomeFirstResponder on appear, but the rest is just bug fixes 👾Brunella
Hi, I really liked your approach after trying to make something like that on my own, you helped a lot, thanks ) gist.github.com/Lavmint/80991b17144f06ba913fed9aee2c51cfPrig
excellent solution thank you for the effort, however, if I disable auto first responder feature, tapping exactly over the placeholder overlay will not trigger the keyboard, I used a ZStack in the body of MultilinwTextField struct and put the placeholder behind the text field and setting backgroundColor of the text field to clear in its init.Tko
@JAHelia, I've updated example to handle your case. It's just needed to make text view transparent and use placeholder as background instead of overlay.Winker
Thank you .. XCode fires two thread warnings for the two statements in the internalText setter, the warning says: Modifying state during view update, this will cause undefined behavior. I think as solution to these warnings there must be some sort of delay to prevent any interference with state update, but don't have a clue on how to do it.Tko
@Tko I had the same warnings come up as well; after commenting out the becomeFirstResponder segment of code, the warnings appear to resolve themselves. Might be worth starting a better investigation there.Maurine
@Winker do you know how we can modify the MultilineTextField to apply font (and other appearance) changes to the internal UITextViewWrapper.UITextView?Maurine
@royalmurder, it seems usage of environment key/value is most appropriate, because passed though subviews automatically and can be easily wrapped into view modifier.Winker
Alrighty. I'll jump on that idea for now and probably give up if it doesn't pan out xDMaurine
Awesome solution. Dropped into my existing project perfectly. Any idea how to have it focus the next MultifieldTextInput when tab is pressed?Elfreda
@Winker Thank you. How to limit MultilineTextField height?Maller
I made height limit by ScrollView.Maller
@Asperi. Great solution apart from one issue - If I place it in a Scrollview and have some existing text the height is calculated correctly before the item is visible, but when it appears the size is out by a huge amount. The moment the field is focused and a key is pressed the correct size is calculated - this is probably yet another a SwiftUI bug as opposed to a problem with your codeBiased
AppKit people... here's a conversion. https://mcmap.net/q/129227/-how-to-auto-expand-height-of-nstextview-in-swiftuiFerren
I've gone crazy trying to find something like this and this is the most thorough solution. There's just one bug when using it in a ScrollView and a VStack, though. The initial height of the MultilineTextFields are HUGE (like ~500px). As soon as I type in one, they all recalculate and get fixed. Has anyone run into this? Any ideas for a fix?Sitting
Best solution. Still works great in 15.2, and the only alternative to a TextEditor, since this solution brings Keyboard Avoidance with it, as the native TextEditor doesn't.Clint
E
60

Update: While Xcode11 beta 4 now does support TextView, I've found that wrapping a UITextView is still be best way to get editable multiline text to work. For instance, TextView has display glitches where text does not appear properly inside the view.

Original (beta 1) answer:

For now, you could wrap a UITextView to create a composable View:

import SwiftUI
import Combine

final class UserData: BindableObject  {
    let didChange = PassthroughSubject<UserData, Never>()

    var text = "" {
        didSet {
            didChange.send(self)
        }
    }

    init(text: String) {
        self.text = text
    }
}

struct MultilineTextView: UIViewRepresentable {
    @Binding var text: String

    func makeUIView(context: Context) -> UITextView {
        let view = UITextView()
        view.isScrollEnabled = true
        view.isEditable = true
        view.isUserInteractionEnabled = true
        return view
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }
}

struct ContentView : View {
    @State private var selection = 0
    @EnvironmentObject var userData: UserData

    var body: some View {
        TabbedView(selection: $selection){
            MultilineTextView(text: $userData.text)
                .tabItemLabel(Image("first"))
                .tag(0)
            Text("Second View")
                .font(.title)
                .tabItemLabel(Image("second"))
                .tag(1)
        }
    }
}

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(UserData(
                text: """
                        Some longer text here
                        that spans a few lines
                        and runs on.
                        """
            ))

    }
}
#endif

enter image description here

Economic answered 11/6, 2019 at 17:51 Comment(4)
Great temporary solve! Accepting for now until it can be solved using pure SwiftUI.Rubin
This solution allows you to display text that already has newlines in it, but it doesn't seem to break/wrap naturally long lines. (The text just keeps growing horizontally on one line, outside the frame.) Any ideas how to get long lines to wrap?Hertzfeld
If I use State (instead of an EnvironmentObject with a Publisher) and pass it as a binding to MultilineTextView, it doesn't seem to work. How can I reflect changes back to State?Dekeles
Is there any way to set a default text in the textview without using an environmentObject?Harveyharvie
J
38

With a Text() you can achieve this using .lineLimit(nil), and the documentation suggests this should work for TextField() too. However, I can confirm this does not currently work as expected.

I suspect a bug - would recommend filing a report with Feedback Assistant. I have done this and the ID is FB6124711.

EDIT: Update for iOS 14: use the new TextEditor instead.

Jerrome answered 6/6, 2019 at 7:38 Comment(9)
Is there a way that I can search the bug using id FB6124711? As I am checking on feedback assistant but it's not very much helpfulSubdominant
I don't believe there is a way to do that. But you could mention that ID in your report, explaining yours is a dupe of the same issue. This helps the triage team to raise the priority of the issue.Jerrome
Seeing this too. For the future record the Xcode version available at the time of this report and answer is: Version 11.0 beta (11M336w) [the very first Xcode 11 beta]. Hopefully this will very soon become a obsolete answer (and question).Nolanolan
Confirmed this is still an issue in Xcode version 11.0 beta 2 (11M337n)Jerrome
Confirmed this is still an issue in Xcode version 11.0 beta 3 (11M362v). You can set the string to "Some\ntext" and it will display on two lines, but typing new content will just cause one line of text to grow horizontally, outside the frame of your view.Hertzfeld
Confirmed in Xcode 11 GM seed 3 and the behavior is the same: line doesn't break and new content typed will just cause one line of text to grow horizontally, outside the frame of your viewShocking
I found that if I explicitly set a frame width then the text does wrap. Xcode 11 release version, iOS 13.1.Strangury
This is still an issue in Xcode 11.4 - Seriously??? How are we supposed to use SwiftUI in production with bugs like this.Aquiver
Good answer, but TextEditor doesn't have a Done button. ☹️Bivalve
S
33

This wraps UITextView in Xcode Version 11.0 beta 6 (still working at Xcode 11 GM seed 2):

import SwiftUI

struct ContentView: View {
     @State var text = ""

       var body: some View {
        VStack {
            Text("text is: \(text)")
            TextView(
                text: $text
            )
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
        }

       }
}

struct TextView: UIViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UITextView {

        let myTextView = UITextView()
        myTextView.delegate = context.coordinator

        myTextView.font = UIFont(name: "HelveticaNeue", size: 15)
        myTextView.isScrollEnabled = true
        myTextView.isEditable = true
        myTextView.isUserInteractionEnabled = true
        myTextView.backgroundColor = UIColor(white: 0.0, alpha: 0.05)

        return myTextView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }

    class Coordinator : NSObject, UITextViewDelegate {

        var parent: TextView

        init(_ uiTextView: TextView) {
            self.parent = uiTextView
        }

        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            return true
        }

        func textViewDidChange(_ textView: UITextView) {
            print("text now: \(String(describing: textView.text!))")
            self.parent.text = textView.text
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Silurid answered 9/9, 2019 at 12:18 Comment(4)
TextField is still not affected by lineLimit() in Xcode Version 11.0 (11A420a), GM Seed 2, Sept., 2019Sclerodermatous
This works well in a VStack, but when using a List the height of the row doesn't expand to show all of the text in the TextView. I've tried a few things: changing isScrollEnabled in the TextView implementation; setting a fixed width on the TextView frame; and even putting the TextView and the Text in a ZStack (in the hope that the row would expand to match the height of the Text view) but nothing works. Does anyone have advice on how to adapt this answer to also work in a List?Roscoeroscommon
@Meo Flute is there a away to make the height match the content.Narrow
I have changed isScrollEnabled to false and it works, thanks.Narrow
W
17

@Meo Flute's answer is great! But it doesn't work for multistage text input. And combined with @Asperi's answer, here is the fixed for that and I also added the support for placeholder just for fun!

struct TextView: UIViewRepresentable {
    var placeholder: String
    @Binding var text: String

    var minHeight: CGFloat
    @Binding var calculatedHeight: CGFloat

    init(placeholder: String, text: Binding<String>, minHeight: CGFloat, calculatedHeight: Binding<CGFloat>) {
        self.placeholder = placeholder
        self._text = text
        self.minHeight = minHeight
        self._calculatedHeight = calculatedHeight
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator

        // Decrease priority of content resistance, so content would not push external layout set in SwiftUI
        textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

        textView.isScrollEnabled = false
        textView.isEditable = true
        textView.isUserInteractionEnabled = true
        textView.backgroundColor = UIColor(white: 0.0, alpha: 0.05)

        // Set the placeholder
        textView.text = placeholder
        textView.textColor = UIColor.lightGray

        return textView
    }

    func updateUIView(_ textView: UITextView, context: Context) {
        textView.text = self.text

        recalculateHeight(view: textView)
    }

    func recalculateHeight(view: UIView) {
        let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
        if minHeight < newSize.height && $calculatedHeight.wrappedValue != newSize.height {
            DispatchQueue.main.async {
                self.$calculatedHeight.wrappedValue = newSize.height // !! must be called asynchronously
            }
        } else if minHeight >= newSize.height && $calculatedHeight.wrappedValue != minHeight {
            DispatchQueue.main.async {
                self.$calculatedHeight.wrappedValue = self.minHeight // !! must be called asynchronously
            }
        }
    }

    class Coordinator : NSObject, UITextViewDelegate {

        var parent: TextView

        init(_ uiTextView: TextView) {
            self.parent = uiTextView
        }

        func textViewDidChange(_ textView: UITextView) {
            // This is needed for multistage text input (eg. Chinese, Japanese)
            if textView.markedTextRange == nil {
                parent.text = textView.text ?? String()
                parent.recalculateHeight(view: textView)
            }
        }

        func textViewDidBeginEditing(_ textView: UITextView) {
            if textView.textColor == UIColor.lightGray {
                textView.text = nil
                textView.textColor = UIColor.black
            }
        }

        func textViewDidEndEditing(_ textView: UITextView) {
            if textView.text.isEmpty {
                textView.text = parent.placeholder
                textView.textColor = UIColor.lightGray
            }
        }
    }
}

Use it like this:

struct ContentView: View {
    @State var text: String = ""
    @State var textHeight: CGFloat = 150

    var body: some View {
        ScrollView {
            TextView(placeholder: "", text: self.$text, minHeight: self.textHeight, calculatedHeight: self.$textHeight)
            .frame(minHeight: self.textHeight, maxHeight: self.textHeight)
        }
    }
}
Weeper answered 7/2, 2020 at 17:35 Comment(8)
I like this. Placeholder doesn't seem to be working but it was useful to start from. I suggest using semantic colors like UIColor.tertiaryLabel instead of UIColor.lightGray and UIColor.label instead of UIColor.black so that both light and dark mode are supported.Urdar
@Urdar You mind explaining how is the placeholder not working?Weeper
@DanielTseng it doesn't show up. How is it supposed to behave? I was expecting it to show if the text is empty but it never shows for me.Urdar
@Helam, In my example, I have the placeholder to be empty. Have you tried changing it to something else? ("Hello World!" instead of "")Weeper
Yes in mine I set it to be something else.Urdar
Thanks for your new component 👍🏻 but the placeholder doesn't seem to work also from my side.Roil
For anyone unable to get placeholder working, remove the view.text conditional on updateUIView.Hygrometry
Xcode 11.4.1 complains "Modifying state during view update, this will cause undefined behavior" on this line: parent.text = textView.text ?? String()Scherer
W
14

Currently, the best solution is to use this package I created called TextView.

You can install it using Swift Package Manager (explained in the README). It allows for toggle-able editing state, and numerous customizations (also detailed in the README).

Here's an example:

import SwiftUI
import TextView

struct ContentView: View {
    @State var input = ""
    @State var isEditing = false

    var body: some View {
        VStack {
            Button(action: {
                self.isEditing.toggle()
            }) {
                Text("\(isEditing ? "Stop" : "Start") editing")
            }
            TextView(text: $input, isEditing: $isEditing)
        }
    }
}

In that example, you first define two @State variables. One is for the text, which the TextView writes to whenever it is typed in, and another is for the isEditing state of the TextView.

The TextView, when selected, toggles the isEditing state. When you click the button, that also toggles the isEditing state which will show the keyboard and select the TextView when true, and deselect the TextView when false.

Winegrower answered 10/11, 2019 at 1:13 Comment(4)
I'll add an issue on the repo, but it has a similar problem to Asperi's original solution, it works great in a VStack, but not in a ScrollView.Ornas
No such module 'TextView'Castellanos
Edit: you're targeting macOS but the framework only supports UIKit because of UIViewRepresentableCastellanos
can I also make it grow until a certain extend?Pierrette
C
10

SwiftUI has TextEditor, which is akin to TextField but offers long-form text entry which wraps into multiple lines:

var body: some View {
    NavigationView{
        Form{
            Section{
                List{
                    Text(question6)
                    TextEditor(text: $responseQuestion6).lineLimit(4)
                    Text(question7)
                    TextEditor(text:  $responseQuestion7).lineLimit(4)
                }
            }
        }
    }
}
Corycorybant answered 25/3, 2021 at 19:43 Comment(0)
V
6

SwiftUI TextView(UIViewRepresentable) with following parameters available: fontStyle, isEditable, backgroundColor, borderColor & border Width

TextView(text: self.$viewModel.text, fontStyle: .body, isEditable: true, backgroundColor: UIColor.white, borderColor: UIColor.lightGray, borderWidth: 1.0) .padding()

TextView (UIViewRepresentable)

struct TextView: UIViewRepresentable {

@Binding var text: String
var fontStyle: UIFont.TextStyle
var isEditable: Bool
var backgroundColor: UIColor
var borderColor: UIColor
var borderWidth: CGFloat

func makeCoordinator() -> Coordinator {
    Coordinator(self)
}

func makeUIView(context: Context) -> UITextView {

    let myTextView = UITextView()
    myTextView.delegate = context.coordinator

    myTextView.font = UIFont.preferredFont(forTextStyle: fontStyle)
    myTextView.isScrollEnabled = true
    myTextView.isEditable = isEditable
    myTextView.isUserInteractionEnabled = true
    myTextView.backgroundColor = backgroundColor
    myTextView.layer.borderColor = borderColor.cgColor
    myTextView.layer.borderWidth = borderWidth
    myTextView.layer.cornerRadius = 8
    return myTextView
}

func updateUIView(_ uiView: UITextView, context: Context) {
    uiView.text = text
}

class Coordinator : NSObject, UITextViewDelegate {

    var parent: TextView

    init(_ uiTextView: TextView) {
        self.parent = uiTextView
    }

    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        return true
    }

    func textViewDidChange(_ textView: UITextView) {
        self.parent.text = textView.text
    }
  }
}
Versicle answered 11/7, 2020 at 17:15 Comment(1)
How would i add a default text to the multi line textfield?Harveyharvie
K
6

Just want to share my UITextView solution minus the coordinator. I noticed that SwiftUI calls UITextView.intrinsicContentSize without telling it what width it should fit in. By default UITextView assumes that it has unlimited width to lay out the content so if it has only one line of text it will return the size required to fit that one line.

To fix this, we can subclass UITextView and invalidate the intrinsic size whenever the view's width changes and take the width into account when calculating the intrinsic size.

struct TextView: UIViewRepresentable {

  var text: String

  public init(_ text: String) {
    self.text = text
  }

  public func makeUIView(context: Context) -> UITextView {
    let textView = WrappedTextView()
    textView.backgroundColor = .clear
    textView.isScrollEnabled = false
    textView.textContainerInset = .zero
    textView.textContainer.lineFragmentPadding = 0
    textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    textView.setContentHuggingPriority(.defaultHigh, for: .vertical)
    return textView
  }

  public func updateUIView(_ uiView: UITextView, context: Context) {
    uiView.text = text
  }
}

class WrappedTextView: UITextView {

  private var lastWidth: CGFloat = 0

  override func layoutSubviews() {
    super.layoutSubviews()
    if bounds.width != lastWidth {
      lastWidth = bounds.width
      invalidateIntrinsicContentSize()
    }
  }

  override var intrinsicContentSize: CGSize {
    let size = sizeThatFits(
      CGSize(width: lastWidth, height: UIView.layoutFittingExpandedSize.height))
    return CGSize(width: size.width.rounded(.up), height: size.height.rounded(.up))
  }
}

screenrecord

Karakoram answered 13/5, 2022 at 8:49 Comment(0)
T
4

Available for Xcode 12 and iOS14, it's really easy.

import SwiftUI

struct ContentView: View {
    
    @State private var text = "Hello world"
    
    var body: some View {
        TextEditor(text: $text)
    }
}
Taco answered 23/6, 2020 at 12:58 Comment(2)
isnt this only if u are working with iOS14, what if user is still on iOS13Versicle
Also TextEditor is lacking as many features as are the ones that it implements and are broken.Berkelium
K
4

MacOS implementation

struct MultilineTextField: NSViewRepresentable {
    
    typealias NSViewType = NSTextView
    private let textView = NSTextView()
    @Binding var text: String
    
    func makeNSView(context: Context) -> NSTextView {
        textView.delegate = context.coordinator
        return textView
    }
    func updateNSView(_ nsView: NSTextView, context: Context) {
        nsView.string = text
    }
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    class Coordinator: NSObject, NSTextViewDelegate {
        let parent: MultilineTextField
        init(_ textView: MultilineTextField) {
            parent = textView
        }
        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            self.parent.text = textView.string
        }
    }
}

and how to use

struct ContentView: View {

    @State var someString = ""

    var body: some View {
         MultilineTextField(text: $someString)
    }
}
Kiely answered 27/9, 2020 at 21:13 Comment(0)
M
3

Here's what I came up with based on Asperi's answer. This solution doesn't require to calculate and propagate size. It uses the contentSize and intrinsicContentSize inside the TextView itself:

resizable text view

struct TextView: UIViewRepresentable {
    @Binding var text: String
    
    func makeUIView(context: UIViewRepresentableContext<TextView>) -> UITextView {
        let textView = UIKitTextView()
        
        textView.delegate = context.coordinator
        
        return textView
    }
    
    func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<TextView>) {
        if textView.text != self.text {
            textView.text = self.text
        }
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text)
    }
    
    final private class UIKitTextView: UITextView {
        override var contentSize: CGSize {
            didSet {
                invalidateIntrinsicContentSize()
            }
        }
        
        override var intrinsicContentSize: CGSize {
            // Or use e.g. `min(contentSize.height, 150)` if you want to restrict max height
            CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height)
        }
    }
    
    final class Coordinator: NSObject, UITextViewDelegate {
        var text: Binding<String>
        
        init(text: Binding<String>) {
            self.text = text
        }
        
        func textViewDidChange(_ textView: UITextView) {
            text.wrappedValue = textView.text
        }
    }
}
Morion answered 26/8, 2021 at 14:39 Comment(1)
Your solution works fine for a one-line (short) text content. It does not calculate the contentSize.hight correctly if you initialize the TextView with a very long text content. This initial multiline content is shown in a TextView with insufficient height. Only after editing the text content by adding a new line the size gets recalculated and the TextView fits its height correctly.Mouldy
D
3

I use textEditor

TextEditor(text: $text)
    .multilineTextAlignment(.leading)
    .cornerRadius(25)
    .font(Font.custom("AvenirNext-Regular", size: 20, relativeTo: .body))
    //.autocapitalization(.words)
    .disableAutocorrection(true)
    .border(Color.gray, width: 3)
    .padding([.leading, .bottom, .trailing])
Dianndianna answered 26/8, 2022 at 1:31 Comment(0)
J
2

You can just use TextEditor(text: $text) and then add any modifiers for things such as height.

Jacobina answered 4/1, 2021 at 22:13 Comment(0)
R
2

TextEditor available from iOS 14.0 serves the purpose.

A sample code for what you are asking for:

import SwiftUI


struct ContentView: View {
    @State var text: String = ""
    
    var body: some View {
        ZStack {
            Color.mint.ignoresSafeArea()
            VStack(alignment: .leading) {
                Text("Enter your text")
                
                // MARK: Your main answer
                TextEditor(text: $text)
                    .foregroundStyle(.black) // Set your text color
                    .frame(height: 300) // Set the height you want for your field
                    .cornerRadius(8.0)
            }
            .padding()
        }
        
    }
}

Output of the sample code:

Demo

Romanticism answered 29/1 at 11:49 Comment(0)
I
0

I thought I'd share my code since the other answers aren't using the Coordinator correctly:

struct UITextViewTest: View {
    @State var text = "Hello, World!"
    var body: some View {
        VStack {
            TextField("", text: $text)
            MultilineTextField(text: $text)
        }
    }
}


struct MultilineTextField: UIViewRepresentable {
    @Binding var text: String
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeUIView(context: Context) -> UITextView {
        context.coordinator.textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        // update in case the text value has changed, we assume the UIView checks if the value is different before doing any actual work.
        // fortunately UITextView doesn't call its delegate when setting this property (in case of MKMapView, we would need to set our did change closures to nil to prevent infinite loop).
        uiView.text = text

        // since the binding passed in may have changed we need to give a new closure to the coordinator.
        context.coordinator.textDidChange = { newText in
            text = newText
        }
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        lazy var textView: UITextView = {
            let textView = UITextView()
            textView.font = .preferredFont(forTextStyle: .body)
            textView.delegate = self
            return textView
        }()
        
        var textDidChange: ((String) -> Void)?
        
        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            return true
        }
        
        func textViewDidChange(_ textView: UITextView) {
            textDidChange?(textView.text)
        }
    }
}
Ilysa answered 17/11, 2022 at 14:59 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.