How do I create a multiline TextField in SwiftUI?
IosSwiftuiTextviewTextfieldIos Problem Overview
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:
Ios Solutions
Solution 1 - Ios
iOS 14 - Native SwiftUI
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:
iOS 13 - Using UITextView
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
Solution 2 - Ios
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
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
Solution 3 - Ios
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
Solution 4 - Ios
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.
Solution 5 - Ios
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()
}
}
Solution 6 - Ios
@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)
}
}
}
Solution 7 - Ios
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
.
Solution 8 - Ios
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)
}
}
}
}
}
Solution 9 - Ios
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
}
}
}
Solution 10 - Ios
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)
}
}
Solution 11 - Ios
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)
}
}
Solution 12 - Ios
You can just use TextEditor(text: $text)
and then add any modifiers for things such as height.
Solution 13 - Ios
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:
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
}
}
}
Solution 14 - Ios
// You can use the .multiLineTextAlignment modifier
TextField("Random text", text: $text)
.multiLineTextAlignment(.leading)
// This aligns the text to the left
// There are more properties beside '.leading', more can be found at the source
Solution 15 - Ios
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))
}
}