191

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

Paulo Mattos
  • 18,845
  • 10
  • 77
  • 85
gabriellanata
  • 3,946
  • 2
  • 21
  • 27
  • 2
    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. – e987 Sep 11 '19 at 17:45

16 Answers16

241

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
Mojtaba Hosseini
  • 95,414
  • 31
  • 268
  • 278
  • 8
    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" – Mattl Jun 29 '20 at 17:27
  • 2
    How do you bind the text to a variable? Or otherwise retrieve the text? – biomiker Jul 24 '20 at 23:11
  • 1
    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. – Mojtaba Hosseini Jul 24 '20 at 23:20
  • 1
    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? – Clifton Labrum Mar 05 '21 at 04:27
  • 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. – Nathan Dudley Mar 02 '22 at 21:38
  • 1
    TextEditor doesn't resize automatically when typing in iOS15.4 – ngb Mar 29 '22 at 09:17
  • @ngb try the Hack I have provided – Mojtaba Hosseini Mar 29 '22 at 09:58
  • 1
    yea the Stack Text hack isn't working with the latest ios – ngb Mar 29 '22 at 11:26
  • Unfortunately unavailable in watchOS :( – Edison Apr 21 '22 at 08:39
  • You upated your answer after I posted mine. – mahan Jun 09 '22 at 19:20
131

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
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 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. – RogerTheShrubber Nov 05 '19 at 18:15
  • 1
    Fixed that case – Asperi Nov 07 '19 at 06:35
  • 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. – iComputerfreak Nov 07 '19 at 19:42
  • 7
    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). – iComputerfreak Nov 07 '19 at 19:50
  • 1
    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)` – kdion4891 Nov 12 '19 at 01:05
  • 5
    @kdion4891 As explained in [this answer from another question](https://stackoverflow.com/a/42333832/10967642), you can just do `textField.textContainerInset = UIEdgeInsets.zero` + `textField.textContainer.lineFragmentPadding = 0` and it works fine @Asperi 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 `TextField`s (I didn't check if if is updating with dark mode). – Rémi B. Nov 20 '19 at 00:19
  • 3
    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). – Rémi B. Nov 20 '19 at 00:34
  • when open always open keyboard how cancel that ? – q8yas Dec 01 '19 at 14:32
  • 3
    @q8yas, you can comment or remove code related to `uiView.becomeFirstResponder` – Asperi Dec 01 '19 at 14:35
  • @Asperi 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. – Rémi B. Dec 06 '19 at 23:07
  • 3
    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. – Asperi Dec 07 '19 at 04:54
  • 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 – Rémi B. Dec 08 '19 at 15:41
  • 2
    Hi, I really liked your approach after trying to make something like that on my own, you helped a lot, thanks ) https://gist.github.com/Lavmint/80991b17144f06ba913fed9aee2c51cf – Lex Feb 07 '20 at 18:21
  • 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. – JAHelia Feb 24 '20 at 13:36
  • 1
    @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. – Asperi Feb 24 '20 at 14:59
  • 1
    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. – JAHelia Feb 26 '20 at 05:21
  • 1
    @JAHelia 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. – royalmurder Mar 19 '20 at 20:06
  • @Asperi do you know how we can modify the `MultilineTextField` to apply font (and other appearance) changes to the internal `UITextViewWrapper.UITextView`? – royalmurder Mar 19 '20 at 20:07
  • 1
    @royalmurder, it seems usage of environment key/value is most appropriate, because passed though subviews automatically and can be easily wrapped into view modifier. – Asperi Mar 19 '20 at 20:14
  • Alrighty. I'll jump on that idea for now and probably give up if it doesn't pan out xD – royalmurder Mar 19 '20 at 20:18
  • Awesome solution. Dropped into my existing project perfectly. Any idea how to have it focus the next MultifieldTextInput when tab is pressed? – ty1 Mar 29 '20 at 23:26
  • @Asperi Thank you. How to limit MultilineTextField height? – Victor Kushnerov Jun 10 '20 at 14:03
  • I made height limit by ScrollView. – Victor Kushnerov Jun 10 '20 at 14:23
  • @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 code – mark Jun 18 '20 at 07:14
  • 1
    AppKit people... here's a conversion. https://stackoverflow.com/a/63144255/11420986 – Beginner Jul 29 '20 at 02:45
  • 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 `MultilineTextField`s 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? – Clifton Labrum Mar 10 '21 at 00:41
  • 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. – LilaQ Mar 06 '22 at 01:21
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

George
  • 25,988
  • 10
  • 79
  • 133
sas
  • 7,017
  • 4
  • 36
  • 50
  • Great temporary solve! Accepting for now until it can be solved using pure SwiftUI. – gabriellanata Jun 11 '19 at 22:13
  • 7
    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? – Michael Jul 03 '19 at 11:44
  • 8
    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? – ygee Oct 06 '19 at 07:16
  • Is there any way to set a default text in the textview without using an environmentObject? – Learn2Code Mar 07 '20 at 02:48
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.

Andrew Ebling
  • 10,175
  • 10
  • 58
  • 75
  • 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 helpful – CrazyPro007 Jun 06 '19 at 10:22
  • 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. – Andrew Ebling Jun 06 '19 at 14:00
  • 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). – Joseph Lord Jun 08 '19 at 22:18
  • 2
    Confirmed this is still an issue in Xcode version 11.0 beta 2 (11M337n) – Andrew Ebling Jun 18 '19 at 09:58
  • 3
    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. – Michael Jul 03 '19 at 11:30
  • 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 view – Carla Camargo Sep 23 '19 at 12:15
  • I found that if I explicitly set a frame width then the text does wrap. Xcode 11 release version, iOS 13.1. – Feldur Sep 25 '19 at 14:12
  • 3
    This is still an issue in Xcode 11.4 - Seriously??? How are we supposed to use SwiftUI in production with bugs like this. – Trev14 Apr 05 '20 at 19:55
  • Good answer, but `TextEditor` doesn't have a **Done** button. ☹️ –  Sep 02 '20 at 03:41
32

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()
    }
}
Meo Flute
  • 1,211
  • 11
  • 9
  • 1
    TextField is still not affected by lineLimit() in Xcode Version 11.0 (11A420a), GM Seed 2, Sept., 2019 – e987 Sep 18 '19 at 22:48
  • 3
    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? – MathewS Oct 13 '19 at 17:41
  • @Meo Flute is there a away to make the height match the content. – Abdullah Apr 29 '20 at 01:54
  • I have changed isScrollEnabled to false and it works, thanks. – Abdullah Apr 29 '20 at 09:05
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)
        }
    }
}
user1909186
  • 1,124
  • 2
  • 12
  • 26
Daniel Tseng
  • 292
  • 3
  • 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. – Helam Feb 28 '20 at 20:13
  • @Helam You mind explaining how is the placeholder not working? – Daniel Tseng Feb 29 '20 at 22:51
  • @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. – Helam Mar 01 '20 at 00:27
  • @Helam, In my example, I have the placeholder to be empty. Have you tried changing it to something else? ("Hello World!" instead of "") – Daniel Tseng Mar 01 '20 at 04:46
  • Yes in mine I set it to be something else. – Helam Mar 01 '20 at 04:55
  • Thanks for your new component but the placeholder doesn't seem to work also from my side. – evya Apr 15 '20 at 03:49
  • For anyone unable to get placeholder working, remove the view.text conditional on updateUIView. – user1909186 Apr 28 '20 at 16:22
  • Xcode 11.4.1 complains "Modifying state during view update, this will cause undefined behavior" on this line: `parent.text = textView.text ?? String()` – Mike Taverne May 27 '20 at 05:07
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.

Ken Mueller
  • 3,659
  • 3
  • 21
  • 33
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)
                }
            }
        }
    }
}
superhawk610
  • 2,457
  • 2
  • 18
  • 27
Jitendra Puri
  • 109
  • 1
  • 2
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
    }
  }
}
Abdul Karim
  • 4,359
  • 1
  • 40
  • 55
Di Nerd Apps
  • 770
  • 8
  • 15
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)
    }
}
gandhi Mena
  • 2,115
  • 1
  • 19
  • 20
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)
    }
}
Denis Rybkin
  • 471
  • 6
  • 7
4

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

yusuke024
  • 2,189
  • 19
  • 12
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
        }
    }
}
ramzesenok
  • 5,469
  • 4
  • 30
  • 41
  • 2
    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. – J.E.K Sep 17 '21 at 14:30
2

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

double-beep
  • 5,031
  • 17
  • 33
  • 41
JMan
  • 29
  • 2
2

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])
Mark A. Donohoe
  • 28,442
  • 25
  • 137
  • 286
Nat Serrano
  • 472
  • 1
  • 4
  • 17
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)
        }
    }
}
malhal
  • 26,330
  • 7
  • 115
  • 133