SwiftUI TextField最大长度

87

有没有可能为TextField设置最大长度?我考虑使用onEditingChanged事件处理它,但该事件仅在用户开始/完成编辑时调用,而在用户输入时不调用。我也阅读了文档,但尚未找到任何解决方法。是否有任何变通方法?

TextField($text, placeholder: Text("Username"), onEditingChanged: { _ in
  print(self.$text)
}) {
  print("Finished editing")
}

嗨,请查看此链接:https://dev59.com/1F0Z5IYBdhLWcg3whAlv#31363255 - Yogesh Patel
在textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String)中设置它。 - manishsharma93
3
谢谢@YogeshPatel和@manishsharma93的回复,但这些方法是使用UIKit实现的。我正在寻找在SwiftUI中实现的方法。 - M Reza
20个回答

90
你可以用Combine以简单的方式完成它。就像这样:
import SwiftUI
import Combine

struct ContentView: View {

    @State var username = ""

    let textLimit = 10 //Your limit
    
    var body: some View {
        //Your TextField
        TextField("Username", text: $username)
        .onReceive(Just(username)) { _ in limitText(textLimit) }
    }

    //Function to keep text length in limits
    func limitText(_ upper: Int) {
        if username.count > upper {
            username = String(username.prefix(upper))
        }
    }
}

2
这太棒了,我喜欢它。谢谢! - Kai Zheng
4
应该接受这个答案。与绑定管理器解决方案不同,它可以实时工作,后者只能在回车时工作。 - Mihir
1
你会如何将该方法与observedObject一起使用? func limitText(_ upper: Int, text Binding<String>) ??? - cbear84
这种方法能否提供获取“username”先前值的方式? - Patrick
我很好奇这是如何确切地工作的。是因为每次视图重新绘制时Just(username)都会重新生成,因此它保持发出下一个值吗?据我所知,Just应该只发出一次,但我猜视图不断被重新创建会重置它? - Aggressor
显示剩余2条评论

74

Paulw11的回答稍微简短一些的版本是:

class TextBindingManager: ObservableObject {
    @Published var text = "" {
        didSet {
            if text.count > characterLimit && oldValue.count <= characterLimit {
                text = oldValue
            }
        }
    }
    let characterLimit: Int

    init(limit: Int = 5){
        characterLimit = limit
    }
}

struct ContentView: View {
    @ObservedObject var textBindingManager = TextBindingManager(limit: 5)
    
    var body: some View {
        TextField("Placeholder", text: $textBindingManager.text)
    }
}

你只需要为TextField字符串创建一个ObservableObject的包装器。可以将其视为解释器,每当有更改时就会得到通知,并能够将修改发送回TextField。但是,无需创建PassthroughSubject,使用@Published修饰符将具有相同的结果,而且代码更少。

需要注意的是,必须使用didSet而不是willSet,否则可能会陷入递归循环。


1
谢谢。在我看来,这应该是首选答案。 - Mikiko Jane
3
@AlexIoja-Yang,这个解决方案在SwiftUI版本2中不起作用。你能否更新一下适用于版本2的解决方案? - Sourav Mishra
2
我很确定我正在按照你的方式实现,但它并没有起作用。我仍然能够输入超过限制的字符数。 - FateNuller
12
在Xcode 13中无法正常工作。 - Jeesson_7
2
这个不起作用。 - Trihedron
显示剩余8条评论

42

使用 Binding 扩展。

extension Binding where Value == String {
    func max(_ limit: Int) -> Self {
        if self.wrappedValue.count > limit {
            DispatchQueue.main.async {
                self.wrappedValue = String(self.wrappedValue.dropLast())
            }
        }
        return self
    }
}

例子

struct DemoView: View {
    @State private var textField = ""
    var body: some View {
        TextField("8 Char Limit", text: self.$textField.max(8)) // Here
            .padding()
    }
}

谢谢!已在iOS 14.5和15.2上测试,运行得非常好。 - Fernando Cardenas
1
很棒的答案。干净且可重复使用。 - Adrian
有没有一种方法可以从TextFieldStyle内部使用它? - Adrian

27

使用现代API(iOS 14+),这基本上是一行代码。

let limit = 10

//...

TextField("", text: $text)
    .onChange(of: text) { _ in
        text = String(text.prefix(limit))
    }

3
这应该是简单用例的首选方法。 - arthas
1
简短而有效,非常适合我的需求。 - Marcy
只有当我使用"perform"重载的onChange函数时,这才对我有效。 - Jace
只有当我将onChange更改为.onChange(of: text) { newValue in text = String(newValue.prefix(100)) }时,这才对我有效 //例如,提供newValue in指令 - Adrian Föder

26

使用SwiftUI,UI元素(比如文本框)与数据模型中的属性绑定。数据模型的工作就是实现业务逻辑,比如对字符串属性大小的限制。

例如:

import Combine
import SwiftUI

final class UserData: BindableObject {

    let didChange = PassthroughSubject<UserData,Never>()

    var textValue = "" {
        willSet {
            self.textValue = String(newValue.prefix(8))
            didChange.send(self)
        }
    }
}

struct ContentView : View {

    @EnvironmentObject var userData: UserData

    var body: some View {
        TextField($userData.textValue, placeholder: Text("Enter up to 8 characters"), onCommit: {
        print($userData.textValue.value)
        })
    }
}

通过让数据模型来处理这一点,UI代码变得更简单,您不需要担心其他代码会将更长的值分配给textValue;模型只是不允许这样做。

为了使您的场景使用数据模型对象,在SceneDelegate中将分配到rootViewController的命令更改为类似以下的内容:

UIHostingController(rootView: ContentView().environmentObject(UserData()))

4
谢谢,运行得非常完美!不过我需要提到,在SceneDelegate类中,我必须将window.rootViewControllerUIHostingController(rootView: ContentView())改为UIHostingController(rootView: ContentView().environmentObject(UserData()))。否则应用程序会崩溃。如果您能提及这一点,那就更好了。 - M Reza
1
你尝试使用新的BindableObject willChange机制了吗?在我的情况下,它会导致TextField产生奇怪的行为并且很容易崩溃。 - Dimillian
3
最新测试版似乎对我来说无法正常工作,原因不明。 - Thomas Vos

20

当 iOS 14+ 可用时,可以使用 onChange(of:perform:) 来实现此操作。

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

  var body: some View {
    VStack {
      TextField("Name", text: $text, prompt: Text("Name"))
        .onChange(of: text, perform: {
          text = String($0.prefix(1))
        })
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
      .previewDevice(.init(rawValue: "iPhone SE (1st generation)"))
  }
}
每次更改文本(使用 prefix)时,onChange 回调函数都会确保文本不超过指定长度。在这个例子中,我不希望文本超过 1 个字符。
对于这个特定的例子,最大长度为 1。当第一次输入文本时,将调用 onChange 一次。如果尝试再输入一个字符,则会调用两次:第一次回调参数将是 aa,因此 text 将被设置为 a。第二次将使用参数 a 调用它,并设置 text,即使已经是 a,但这不会触发任何其他回调,除非输入值发生了变化,因为 onChange 在下面验证相等性。
所以: - 第一次输入 “a” :"a" != "",调用 onChange 一次,将 text 设置为与其已有的值相同。 "a" == "a",没有更多的 onChange 调用。 - 第二次输入 “aa” :"aa" != "a",第一次调用 onChange,调整 text 并将其设置为 a"a" != "aa",使用调整后的值第二次调用 onChange,"a" == "a",onChange 不会执行。 - 以此类推,每次输入更改都会触发两次 onChange

15

我所知道的设置TextField字符限制的最优雅(且简单)方法是使用本地发布者事件collect()

用法:

struct ContentView: View {

  @State private var text: String = ""
  var characterLimit = 20

  var body: some View {

    TextField("Placeholder", text: $text)
      .onReceive(text.publisher.collect()) {
        let s = String($0.prefix(characterLimit))
        if text != s {
          text = s
        }
      }
  }
}

2
这里很好地运用了Combine! - Eneko Alonso
这会导致一个无限循环。 - Joris Mans
这种方法的限制是您无法访问先前的值。 - Patrick

10

这是iOS 15的快速修复方法(用dispatch async将其包装起来):

@Published var text: String = "" {
    didSet {
      DispatchQueue.main.async { [weak self] in
        guard let self = self else { return }
        while self.text.count > 80 {
          self.text.removeLast()
        }
      }
    }
  }

编辑:目前iOS 15存在一个错误/更改,导致位于以下代码下方的不再有效

我能找到的最简单的解决方法是通过覆盖didSet

@Published var text: String = "" {
  didSet {
    if text.count > 10 {
      text.removeLast() 
    }
  }
}

这里是一个完整的示例,用于使用SwiftUI预览进行测试:

class ContentViewModel: ObservableObject {
  @Published var text: String = "" {
    didSet {
      if text.count > 10 {
        text.removeLast() 
      }
    }
  }
}

struct ContentView: View {

  @ObservedObject var viewModel: ContentViewModel

  var body: some View {
    TextField("Placeholder Text", text: $viewModel.text)
  }
}

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

1
当把一个单词粘贴到文本框中时,这个功能能够正常工作吗? - Joris Mans
1
我可以确认使用DispatchQueue.main.asyncdidSet的主体包装起来可以解决在Xcode 13 / iOS 15上的问题。感谢您找到了这个解决方法。 - Nathan Dudley

10
为了使其更灵活,您可以将Binding包装在另一个应用任何规则的Binding中。在底层,这采用了与Alex的解决方案相同的方法(设置值,然后如果无效,则将其设置回旧值),但不需要更改@State属性的类型。我想让它像Paul那样只需一次设置,但我找不到一种方法告诉绑定更新它的所有观察者(而TextField缓存值,因此您需要做一些事情以强制更新)。
请注意,所有这些解决方案都不如包装UITextField。在我的解决方案和Alex的解决方案中,由于我们使用重新赋值,如果您使用箭头键移动到字段的另一部分并开始输入,光标将移动,即使字符没有改变,这真的很奇怪。在Paul的解决方案中,由于它使用prefix(),字符串的末尾将悄悄丢失,这可能甚至更糟。我不知道任何方法可以实现UITextField的行为,只是防止您输入。
extension Binding {
    func allowing(predicate: @escaping (Value) -> Bool) -> Self {
        Binding(get: { self.wrappedValue },
                set: { newValue in
                    let oldValue = self.wrappedValue
                    // Need to force a change to trigger the binding to refresh
                    self.wrappedValue = newValue
                    if !predicate(newValue) && predicate(oldValue) {
                        // And set it back if it wasn't legal and the previous was
                        self.wrappedValue = oldValue
                    }
                })
    }
}

有了这个,您只需将TextField的初始化更改为:

TextField($text.allowing { $0.count <= 10 }, ...)

4
这个解决方案在SwiftUI 2版本中不起作用。您能否更新它以适用于版本2? - Sourav Mishra

6

我将一些答案汇总成我满意的内容。
在iOS 14+上进行了测试。

用法:

class MyViewModel: View {
    @Published var text: String
    var textMaxLength = 3
}

struct MyView {
    @ObservedObject var viewModel: MyViewModel

    var body: some View {
         TextField("Placeholder", text: $viewModel.text)
             .limitText($viewModel.text, maxLength: viewModel.textMaxLength)
    }
}

extension View {
    func limitText(_ field: Binding<String>, maxLength: Int) -> some View {
        modifier(TextLengthModifier(field: field, maxLength: maxLength))
    }
}

struct TextLengthModifier: ViewModifier {
    @Binding var field: String
    let maxLength: Int

    func body(content: Content) -> some View {
        content
            .onReceive(Just(field), perform: { _ in
                let updatedField = String(
                    field
                        // do other things here like limiting to number etc...
                        .enumerated()
                        .filter { $0.offset < maxLength }
                        .map { $0.element }
                )

                // ensure no infinite loop
                if updatedField != field {
                    field = updatedField
                }
            })
    }
}

这是一个很好的实现方式!谢谢分享。我该如何扩展它以允许TextField对数字有最大长度限制(例如,年龄是Int类型,最大长度为3)? - Chris Langston
@ChrisLangston 如果你想强制它只能是数字,在我写的注释// do other things here like limiting to number etc...后面加上.filter { $0.isNumber } - RefuX
它在iOS 15上运行得非常好,不幸的是,在iOS 14上对我不起作用 :/. - Fernando Cardenas

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