close

multi-line textfield in flutter

How to Build a Multi-Line TextField in SwiftUI

Developed on Xcode 11.4 with SwiftUI

Image for post

As SwiftUI on iOS 13 doesn’t uphold TextView of course, we need to make our own UIViewRepresentable. This UIViewRepresentable can wrap TextView which we can use in SwiftUI.

The UIViewRepresentable convention expects us to actualize makeUIView, updateUIView, makeCoordinator, UIViewType, and so on In this way, presently we will attempt to make a UITextView covering for SwiftUI.

private struct UITextViewWrapper: UIViewRepresentable {
     /// The type of `UIView` to be presented.
     typealias UIViewType = UITextView
     func makeUIView(context:          UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
          let textField = UITextView()
/// `Coordinators` are any class to coordinate with view 
///  will implement it later. 
/// `Coordinator` can be accessed via `Context`.
          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
      }
 ....
}

Since we are finished making the view, we need to set the facilitator delegate.

private struct UITextViewWrapper: UIViewRepresentable {
     func makeCoordinator() -> Coordinator {
//$text => is a bindable object to get set textview string
//$calculatedHeight => is to update height of view on newline
//onDone => is a callback, when user press keyboard done.
          return Coordinator(text: $text, height: $calculatedHeight, onDone: onDone)
     }
}
//Coordinator
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
           // call parent to update view height if needed 
           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
      }
}

Recalculate the tallness and update the view in updateUIView.

Done.

How to Use

struct MultilineTextField: View {
      private var placeholder: String
      private var onCommit: (() -> Void)?
      @State private var viewHeight: CGFloat = 40 //start with one line
      @State private var shouldShowPlaceholder = false
      @Binding private var text: String
       private var internalText: Binding<String> {
              Binding<String>(get: { self.text } ) {
              self.text = $0
              self.shouldShowPlaceholder = $0.isEmpty
       }
}
var body: some View {
    UITextViewWrapper(text: self.internalText, calculatedHeight: $viewHeight, onDone: onCommit)
     .frame(minHeight: viewHeight, maxHeight: viewHeight)
     .background(placeholderView, alignment: .topLeading)
}
var placeholderView: some View {
  Group {
     if shouldShowPlaceholder {
        Text(placeholder).foregroundColor(.gray)
        .padding(.leading, 4)
        .padding(.top, 8)
     }
   }
}
init (_ placeholder: String = "", text: Binding<String>, onCommit: (() -> Void)? = nil) {
      self.placeholder = placeholder
      self.onCommit = onCommit
      self._text = text
      self._shouldShowPlaceholder = State<Bool>(initialValue: self.text.isEmpty)
}}

Full Project Gist

import SwiftUI

struct ContentView: View {
    static var test:String = ""
    static var testBinding = Binding<String>(get: { test }, set: { test = $0 } )
    var body: some View {
        NavigationView {
            VStack(alignment: .leading) {
                Text("Enter Review Comments:")
                MultilineTextField("Type here", text: ContentView.testBinding, onCommit: {
                    print("Final text: \(ContentView.test)")
                })
                .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.gray))
                Button(action: {
                    print("send Clicked")
                }) {
                    Text("send")
                }
                Spacer()
            }
            .padding()
            .navigationBarTitle(Text("SwiftUI"))
        }
        
    }
}

#if DEBUG
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#endif


struct MultilineTextField: View {

    private var placeholder: String
    private var onCommit: (() -> Void)?
    @State private var viewHeight: CGFloat = 40 //start with one line
    @State private var shouldShowPlaceholder = false
    @Binding private var text: String
    
    private var internalText: Binding<String> {
        Binding<String>(get: { self.text } ) {
            self.text = $0
            self.shouldShowPlaceholder = $0.isEmpty
        }
    }

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

    var placeholderView: some View {
        Group {
            if shouldShowPlaceholder {
                Text(placeholder).foregroundColor(.gray)
                    .padding(.leading, 4)
                    .padding(.top, 8)
            }
        }
    }
    
    init (_ placeholder: String = "", text: Binding<String>, onCommit: (() -> Void)? = nil) {
        self.placeholder = placeholder
        self.onCommit = onCommit
        self._text = text
        self._shouldShowPlaceholder = State<Bool>(initialValue: self.text.isEmpty)
    }

}


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

    private 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 // call in next render cycle.
            }
        }
    }

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

}

Expanding on iOS 14 — XCode 12

Apple has added uphold for ‘TextEditor’, utilizing TextEditor we can straightforwardly deliver multiline TextView.

import SwiftUI

struct ContentView: View {
    @State private var text = "Test Multiline"
    var body: some View {
        TextEditor(text: $text)
            .padding()
            .font(.subheadline)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Thanks for reading this article ❤
If I got something wrong, Let me know in the comments. I would love to improve.
Clap 👏 If this article helps you.

Leave a Comment