如何在SwiftUI中创建多行文本框?

195

我一直在尝试在SwiftUI中创建一个多行文本字段,但我无法弄清楚如何实现。

这是我目前的代码:

我一直在尝试在SwiftUI中创建一个多行TextField,但是我无法找到方法。

这是我当前的代码:

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

但这是输出结果:

enter image description here


2
我刚刚尝试在Xcode版本11.0(11A419c)中使用SwiftUI创建多行文本字段,并使用lineLimit()方法,但仍然无法正常工作。我无法相信苹果公司还没有修复这个问题。多行文本字段在移动应用程序中非常常见。 - e987
17个回答

251

iOS 16+

TextField可以使用新的axis参数来垂直扩展。此外,它还可以使用lineLimit修饰符来限制给定范围内的行数:

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

.lineLimit修饰符现在还支持更高级的行为,比如保留最小空间并在添加更多内容后扩展,一旦内容超过上限就滚动。


iOS 14+

它被称为TextEditor

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

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

动态增长的高度:

如果你希望它在你输入时动态增长,可以将其嵌入到一个包含TextZStack中,像这样:

Demo


iOS 13+

你可以在SwiftUI代码中直接使用原生的UITextView,使用以下结构:

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

使用方法

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

优势:

  • 支持 iOS 13
  • 与旧代码共享
  • UIKit 中经过多年测试
  • 可完全自定义
  • 具备原始 UITextView 的所有其他优点

8
如果有人正在查看此答案并想知道如何将实际文本传递给TextView结构体,则请在设置textColor的行下面添加以下行:$0.text =“一些文本” - Mattl
2
你如何将文本绑定到变量?或者以其他方式检索文本? - biomiker
1
当我在ScrollView内的VStack中使用此方法时,TextEditor会调整其高度(大多数情况下),但仍然在其中有一个滚动条。是否有人遇到过这种情况? - Clifton Labrum
1
在iOS15.4中,当输入时,文本编辑器不会自动调整大小。 - ngb
1
是的,Stack Text hack在最新的iOS上无法使用。 - ngb
显示剩余5条评论

131

好的,我开始使用@sas的方法,但是我需要它真正地看起来像是带有内容适应等多行文本框的外观。这就是我得到的东西。希望对其他人有所帮助... 使用了Xcode 11.1。

提供的自定义MultilineTextField具有:
1. 内容适应
2. 自动聚焦
3. 占位符
4. 在提交时执行

swiftui多行文本框预览,包括内容适应 添加了占位符

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

7
您应该考虑将UITextField的'backgroundColor'设置为UIColor.clear,以便在SwiftUI中启用自定义背景,并考虑删除auto-firstresponder,因为在一个视图中使用多个'MultilineTextFields'时会出现问题(每次按键,所有文本字段都会尝试重新获取响应者)。 - iComputerfreak
5
此问题的另一个答案所解释的那样,您只需执行textField.textContainerInset = UIEdgeInsets.zero + textField.textContainer.lineFragmentPadding = 0,它就可以正常工作。如果按照上述方法操作,则需要移除.padding(.leading,4).padding(.top,8),否则它会看起来不完整。 此外,您可以将.foregroundColor(. gray)更改为.foregroundColor(Color(UIColor.tertiaryLabel)),以匹配 TextField 中占位符的颜色 ( 我没有检查它是否在深色模式下更新 )。 - Rémi B.
3
噢,另外我还将 @State private var dynamicHeight: CGFloat = 100 更改为 @State private var dynamicHeight: CGFloat = UIFont.systemFontSize,以修复 MultilineTextField 出现时的一个小“故障”(它会在短时间内显示得很大,然后缩小)。 - Rémi B.
3
@q8yas,您可以评论或删除与 uiView.becomeFirstResponder 相关的代码。 - Asperi
3
感谢大家的评论!我非常感激。这个快照是展示一种特定目的的方法。所有提出的建议都是正确的,但是适用于你们自己的目的。你可以自由地复制粘贴这段代码,并根据你的需要进行任意配置。 - Asperi
显示剩余22条评论

60
更新:尽管Xcode11 beta 4现在支持 TextView,但我发现包装一个UITextView仍然是获取可编辑多行文本的最佳方法。例如,TextView 会出现显示故障,其中文本不会正确地显示在视图中。
原始(beta 1)答案:
目前,您可以包装一个 UITextView 来创建可组合的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

在此输入图片描述


很好的临时解决方案!目前接受,直到可以使用纯SwiftUI解决为止。 - gabriellanata
7
这个解决方案可以显示已经包含换行符的文本,但是似乎不能自然地打断或折叠超长的行(文本仅会在一个行上水平增长,并超出框架)。有什么方法可以让长行自动折行呢? - Michael
8
如果我使用 State(而不是具有 Publisher 的 EnvironmentObject,并将其作为绑定传递给 MultilineTextView),似乎无法正常工作。如何将更改反映回 State? 如果我使用 State(而不是通过Publisher使用EnvironmentObject),并将其作为绑定传递给MultilineTextView,那么它似久无法正常工作。如何将更改反映回State? - ygee
有没有办法在不使用environmentObject的情况下设置TextView中的默认文本? - Learn2Code

38

通过使用 Text(),您可以使用 .lineLimit(nil) 来实现此目的,并且文档建议这也适用于 TextField()。然而,我可以确认这目前无法正常工作。

我怀疑这是一个错误 - 建议使用Feedback Assistant提交报告。我已经这样做了,ID为FB6124711。

编辑:iOS 14的更新:改用新的TextEditor


也看到了。为了未来的记录,此报告和答案中可用的Xcode版本是:版本11.0 beta(11M336w)[第一个Xcode 11 beta]。希望这很快就会成为过时的答案(和问题)。 - Joseph Lord
2
确认在Xcode 11.0 beta 2(11M337n)版本中仍存在此问题。 - Andrew Ebling
3
确认在Xcode版本11.0 beta 3 (11M362v)中仍存在此问题。您可以将字符串设置为"Some\ntext",它会显示为两行,但输入新内容将导致一行文本在视图框架外水平增长。 - Michael
我发现如果明确设置帧宽度,那么文本就会自动换行。Xcode 11 发布版本,iOS 13.1。 - Feldur
3
在 Xcode 11.4 中,这仍然是一个问题——真的吗?我们要如何在生产中使用 SwiftUI,如果有这样的漏洞。 - Trev14
显示剩余4条评论

32

这个在 Xcode 11.0 beta 6 版本中可以使用(在 Xcode 11 GM seed 2 上仍然有效)将 UITextView 包装起来:

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

1
TextField在Xcode 11.0(11A420a),GM Seed 2,2019年9月版本中仍未受lineLimit()影响。 - e987
3
在 VStack 中这个方法很好用,但是当使用 List 时,行的高度不会展开以显示 TextView 中的所有文本。我尝试了几种方法:在 TextView 实现中更改 isScrollEnabled;在 TextView 框架上设置固定宽度;甚至将 TextView 和 Text 放在 ZStack 中(希望行会展开以匹配 Text 视图的高度),但都无效。有人知道如何修改这个答案以使它也适用于 List 吗? - MathewS
@Meo Flute 有没有办法使高度与内容匹配? - Abdullah
我已将 isScrollEnabled 更改为 false,它可以工作了,谢谢。 - Abdullah

17

@Meo Flute的答案很好!但对于多段文字输入无效。 结合@Asperi的答案,这里是修复方法,我还加入了占位符支持,只是为了好玩!

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

像这样使用:

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

我喜欢这个。占位符似乎不起作用,但从中开始很有用。我建议使用语义化颜色,如UIColor.tertiaryLabel代替UIColor.lightGray和UIColor.label代替UIColor.black,以便支持浅色和深色模式。 - Helam
在我的设置中,我把它设成了其他东西。 - Helam
谢谢您提供这个新组件,但是我这边似乎无法使用占位符。 - evya
对于任何无法使占位符工作的人,请在updateUIView中删除view.text条件。 - user1909186
Xcode 11.4.1 抱怨在这一行代码上“在视图更新期间修改状态,这将导致未定义的行为”:parent.text = textView.text ?? String() - Mike Taverne
显示剩余3条评论

14

目前最佳解决方案是使用我创建的名为TextView的包。

您可以使用Swift Package Manager进行安装(在README中有说明)。它允许切换编辑状态,并提供Numerous定制功能(在README中也有详细说明)。

以下是一个例子:

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

在这个例子中,首先定义了两个@State变量。一个是文本,当TextView被输入时,它会将其写入;另一个是TextView的isEditing状态。

当选中TextView时,它会切换到isEditing状态。当您点击按钮时,也会切换isEditing状态,这将显示键盘并在true时选择TextView,在false时取消选择TextView。


1
我会在仓库上添加一个问题,但它与Asperi的原始解决方案有类似的问题,在VStack中运行良好,但在ScrollView中不起作用。 - RogerTheShrubber
No such module 'TextView' - trusk
编辑:您的目标是macOS,但由于UIViewRepresentable,该框架仅支持UIKit。 - trusk
我也可以让它增长到一定程度吗? - andrei

10

SwiftUI有 TextEditor,类似于TextField但提供可换行的长文本输入:

var body: some View {
    NavigationView{
        Form{
            Section{
                List{
                    Text(question6)
                    TextEditor(text: $responseQuestion6).lineLimit(4)
                    Text(question7)
                    TextEditor(text:  $responseQuestion7).lineLimit(4)
                }
            }
        }
    }
}

6

SwiftUI 的 TextView(UIViewRepresentable) 具有以下可用参数:fontStyle、isEditable、backgroundColor、borderColor 和 borderWidth。

通过如下方式使用 TextView: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
    }
  }
}

我该如何为多行文本字段添加默认文本? - Learn2Code

5
我想分享一下我的UITextView解决方案,不需要使用协调器。我注意到SwiftUI在调用UITextView.intrinsicContentSize时没有告诉它应该适合哪个宽度。默认情况下,UITextView假定它有无限的宽度来布局内容,因此如果它只有一行文本,它将返回适合那一行文本所需的大小。
为了解决这个问题,我们可以创建UITextView的子类,并在视图的宽度发生更改时使内在大小失效,并在计算内在大小时考虑宽度。
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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接