简体   繁体   中英

SwiftUI TextField max length

Is it possible to set a maximum length for TextField ? I was thinking of handling it using onEditingChanged event but it is only called when the user begins/finishes editing and not called while user is typing. I've also read the docs but haven't found anything yet. Is there any workaround?

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

A slightly shorter version of Paulw11's answer would be:

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

All you need is an ObservableObject wrapper for the TextField string. Think of it as an interpreter that gets notified every time there's a change and is able to send modifications back to the TextField. However, there's no need to create the PassthroughSubject , using the @Published modifier will have the same result, in less code.

One mention, you need to use didSet , and not willSet or you can end up in a recursive loop.

You can do it with Combine in a simple way.

Like so:

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

With SwiftUI, UI elements, like a text field, are bound to properties in your data model. It is the job of the data model to implement business logic, such as a limit on the size of a string property.

For example:

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

By having the model take care of this the UI code becomes simpler and you don't need to be concerned that a longer value will be assigned to textValue through some other code; the model simply won't allow this.

In order to have your scene use the data model object, change the assignment to your rootViewController in SceneDelegate to something like

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

The most elegant (and simple) way I know to set a character limit on the TextField is to use the native publisher event collect() .

Usage:

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

Use Binding extension.

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

Example

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

This is a quick fix for iOS 15 (wrap it in 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()
        }
      }
    }
  }

EDIT: There currently is a bug / change in iOS 15 where code below does not work anymore .

The simplest solution I could find is by overriding didSet :

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

Here is a full example to test with SwiftUI Previews:

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

To make this flexible, you can wrap the Binding in another Binding that applies whatever rule you want. Underneath, this employs the same approach as Alex's solutions (set the value, and then if it's invalid, set it back to the old value), but it doesn't require changing the type of the @State property. I'd like to get it to a single set like Paul's, but I can't find a way to tell a Binding to update all its watchers (and TextField caches the value, so you need to do something to force an update).

Note that all of these solutions are inferior to wrapping a UITextField. In my solution and Alex's, since we use reassignment, if you use the arrow keys to move to another part of the field and start typing, the cursor will move even though the characters aren't changing, which is really weird. In Paul's solution, since it uses prefix() , the end of the string will be silently lost, which is arguably even worse. I don't know any way to achieve UITextField's behavior of just preventing you from typing.

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

With this, you can just change your TextField initialization to:

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

Whenever iOS 14+ is available, it's possible to do this with 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)"))
  }
}

How it works. Every time the text changes, the onChange callback will make sure the text is not longer than the specified length (using prefix ). In the example, I don't want the text to be longer than 1.

For this specific example, where the max length is 1. Whenever the text is entered for the very first time, onChange is called once. If one tries to enter another character, onChange will be called twice: first time the callback argument will be, say, aa , so the text will be set to a . The second time it will be called with an argument of a and set text , which is already a to the same value of a but this will not trigger any more callbacks unless the input value is changed, as onChange verifies equality underneath.

So:

  • first input "a": "a" != "" , one call to onChange which will set text to the same value as it already has. "a" == "a" , no more calls to onChange
  • second input "aa": "aa" != "a" , first call to onChange, text is adjusted and set to a , "a" != "aa" , second call to onChange with adjusted value, "a" == "a" , onChange is not executed
  • and so on and so forth, every other input change will trigger onChange twice

It's basically a one-liner with the modern APIs (iOS 14+)

let limit = 10

//...

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

Combined a bunch of answers into something I was happy with.
Tested on iOS 14+

Usage:

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

Write a custom Formatter and use it like this:

    class LengthFormatter: Formatter {

    //Required overrides

    override func string(for obj: Any?) -> String? {
       if obj == nil { return nil }

       if let str = (obj as? String) {
           return String(str.prefix(10))
       }
         return nil
    }

    override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {

                obj?.pointee = String(string.prefix(10)) as AnyObject
                error?.pointee = nil
                return true
            }

        }
}

Now for TextField:

struct PhoneTextField: View {
        @Binding var number: String
        let myFormatter = LengthFormatter()

        var body: some View {
            TextField("Enter Number", value: $number, formatter: myFormatter, onEditingChanged: { (isChanged) in
                //
            }) {
                print("Commit: \(self.number)")
            }
            .foregroundColor(Color(.black))
        }

    }

You will see the correct length of text get assigned to $number. Also, whatever arbitrary length of text is entered, it gets truncated on Commit.

SwiftUI TextField max length

I believe Roman Shelkford's answer uses a better approach than that of Alex Ioja-Yang, or at least an approach that works better with iOS 15. However, Roman's answer is hard-coded to a single variable, so can't be re-used.

Below is a version that is more extensible.

(I tried adding this as an edit to Roman's comment, but my edit was rejected. I don't currently have the reputation to post a comment. So I'm posting this as a separate answer.)

import SwiftUI
import Combine

struct ContentView: View {
    @State var firstName = ""
    @State var lastName = ""
    
    var body: some View {
        TextField("First name", text: $firstName)
        .onReceive(Just(firstName)) { _ in limitText(&firstName, 15) }

        TextField("Last name", text: $lastName)
        .onReceive(Just(lastName)) { _ in limitText(&lastName, 25) }
    }
}

func limitText(_ stringvar: inout String, _ limit: Int) {
    if (stringvar.count > limit) {
        stringvar = String(stringvar.prefix(limit))
    }
}

Regarding the reply of @Paulw11, for the latest Betas I made the UserData class work again like that:

final class UserData: ObservableObject {
    let didChange = PassthroughSubject<UserData, Never>()
    var textValue = "" {
        didSet {
            textValue = String(textValue.prefix(8))
            didChange.send(self)
        }
    }
}

I changed willSet to didSet because the prefix was immediately overwritten by the user`s input. So using this solution with didSet, you will realize that the input is cropped right after the user typed it in.

In MVVM super simple, bind TextField or TextEditor text to published property in your viewModel.

@Published var detailText = "" {
     didSet {
        if detailText.count > 255 {
             detailText = String(detailText.prefix(255))
        }
     }
}

Here's a simple solution.

TextField("Phone", text: $Phone)
.onChange(of: Phone, perform: { value in 
   Phone=String(Search.Phone.prefix(10))
})

Masked phone number with limit. No need to use 3rd party library like iPhoneNumberField.

@ViewBuilder
var PhoneInputView: some View {
     TextField("Phone Area", text: getMaskedPhoneNumber())
            .keyboardType(.phonePad)
}

private func getMaskedPhoneNumber() -> Binding<String> {
    let maskedPhoneNumber = Binding(
        get: { self.user.phoneNumber.applyPatternOnNumbers(pattern: "(###) ### ## ##", maxCount: 10},
        set: { self.user.phoneNumber = $0.applyPatternOnNumbers(pattern: "(###) ### ## ##", maxCount: 10)}
    )
    
    return maskedPhoneNumber
}

extension String {

func applyPatternOnNumbers(pattern: String, replacmentCharacter: Character = "#", maxCount: Int) -> String {
    var pureNumber = self.replacingOccurrences( of: "[^0-9]", with: "", options: .regularExpression)
    
    if pureNumber.count > maxCount {
        return pureNumber.prefix(maxCount).lowercased()
    }
    
    for index in 0 ..< pattern.count {
        guard index < pureNumber.count else { return pureNumber }
        let stringIndex = String.Index(utf16Offset: index, in: self)
        let patternCharacter = pattern[stringIndex]
        guard patternCharacter != replacmentCharacter else { continue }
        pureNumber.insert(patternCharacter, at: stringIndex)
    }
    
    return pureNumber
}
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM