80

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")
}
M Reza
  • 18,350
  • 14
  • 66
  • 71

18 Answers18

80

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))
        }
    }
}
Pranav Kasetti
  • 8,770
  • 2
  • 50
  • 71
Roman Shelkford
  • 854
  • 6
  • 4
  • 2
    This is awesome, I love it. Thank you! – Kai Zheng Oct 10 '20 at 11:25
  • 4
    This should be accepted answer. Works realtime unlike the binding manager solution which only works upon carriage return – Mihir Jan 05 '21 at 20:53
  • Your use of `String(username.prefix(upper))` is also great because "same-y" values are propagated to the binding otherwise. – LordParsley Aug 04 '21 at 09:27
  • 1
    how would you use this with a observedObject? func limitText(_ upper: Int, text Binding) ??? – cbear84 Nov 06 '21 at 13:28
  • Would this method allow for a way to get the previous value of `username`? – Patrick Dec 26 '21 at 09:11
  • I would suggest using .onChange if your app is crashing while loading many fields because of .onRecieve. – Sanjeevcn Jan 12 '22 at 15:04
  • Im curious how this works exactly. Is it because the Just(username) is regenerated every time the view is redrawn so it keeps emitting the next value? As I understand it Just should emit once, but I guess the view being constantly being recreated resets this? – Aggressor Mar 24 '23 at 22:20
73

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.

Alex Ioja-Yang
  • 1,428
  • 13
  • 28
  • 1
    Thanks. This should be the preferred answer in my opinion. – Mikiko Jane Oct 21 '19 at 02:42
  • Thanks. Only one issue. When pressing the RETURN button on the keyboard the contents of the TextField disappear – Julio Bailon Jan 23 '20 at 16:22
  • I don't like the the didSet block of this solution. It's to complicated and doesn't work as expected. The issue is when you set the value of text directly in code (not via GUI). Let's assume text is an empty string. When you then assign a String that is too long, the didSet block will set the value of text to oldValue which was an empty string. What you really want is that text will end up having the first [characterLimit] characters of the assigned string. Better is to just use didSet { self.text = String(text.prefix(characterLimit))} – G. Marc Feb 12 '20 at 21:39
  • @G.Marc I am not sure why you say using the didSet block is too complicated but then suggest an alternative that maintains it. Also the question was suggesting a case where input is edited by the user. Although its true that something could be pasted and then completely swiped out without explanation, that would simply require a better handling of the `oldValue` vs `newValue` in the block. With your solution, what would happen if user moves cursor to start / middle of input and starts editing? Do you think that's better user experience? – Alex Ioja-Yang Feb 13 '20 at 08:59
  • @Alex Ioja-Yang You're right, this is an issue, my fault for not testing this. But again, a proper solution should also work for assignments outside the UI. I'll try to find a something better than I have proposed before. – G. Marc Feb 13 '20 at 10:39
  • 3
    @AlexIoja-Yang this solution doesn't work in SwiftUI version 2. Can you please update it for version 2? – Sourav Mishra Oct 05 '20 at 12:16
  • @SouravMishra thanks for letting me know (can't believe I haven't been on SO for 2 weeks :) ). I will add update the post, as right now I don't have any SwiftUI work and getting context again would take too long. – Alex Ioja-Yang Oct 19 '20 at 13:59
  • Have just used this again with Swift 5.3, Xcode 12.3 and it works as it did at first. – Alex Ioja-Yang Jan 10 '21 at 23:23
  • 2
    Pretty sure I'm implementing this exactly as you have here and it's not working. I'm able to enter more characters than the limit still. – FateNuller Oct 14 '21 at 17:52
  • Should be `@StateObject` instead – eXCore Nov 01 '21 at 19:40
  • 12
    This doesn't work in Xcode 13 – Jeesson_7 Nov 25 '21 at 14:39
  • 2
    This doesnt work. – Trihedron Oct 10 '22 at 16:33
36

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()
    }
}
Raja Kishan
  • 16,767
  • 2
  • 26
  • 52
26

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()))
Paulw11
  • 108,386
  • 14
  • 159
  • 186
  • 4
    Thank you, worked perfectly! However I should mention that I had to change the `window.rootViewController` inside `SceneDelegate` class from `UIHostingController(rootView: ContentView())` to `UIHostingController(rootView: ContentView().environmentObject(UserData()))` . Otherwise the app crashes. Would be an even greater answer if you mind mentioning this as well. – M Reza Jun 06 '19 at 13:08
  • 1
    Did you try to use it with the new BindableObject willChange mechanism? It create a weird behaviour with the TextField and very prone to crash in my case. – Dimillian Jul 18 '19 at 11:18
  • 3
    For some reason this does not appear to work for me on the latest betas – Thomas Vos Oct 09 '19 at 10:17
23

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

let limit = 10

//...

TextField("", text: $text)
    .onChange(of: text) { _ in
        text = String(text.prefix(limit))
    }
arkir
  • 1,336
  • 2
  • 10
  • 9
20

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
NeverwinterMoon
  • 2,363
  • 21
  • 24
15

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
        }
      }
  }
}
Gabriel
  • 964
  • 9
  • 8
Rufat Mirza
  • 1,425
  • 14
  • 20
10

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 }, ...)
Rob Napier
  • 286,113
  • 34
  • 456
  • 610
10

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())
  }
}
kgaidis
  • 14,259
  • 4
  • 79
  • 93
4

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
                }
            })
    }
}
RefuX
  • 490
  • 5
  • 11
  • Great way to implement this! Thanks for sharing. How would I extend this to allow a TextField to have max length for numbers (e.g. Age which is an Int to be max length of 3)? – Chris Langston Feb 27 '22 at 03:28
  • @ChrisLangston To force it to be numbers only where I have the comment `// do other things here like limiting to number etc...` you would add `.filter { $0.isNumber }` – RefuX Feb 28 '22 at 14:24
  • It worked great for iOS 15, unfortunately it didn't work for me at iOS 14 :/. – Fernando Cardenas Mar 03 '22 at 15:05
3

Here's a simple solution.

TextField("Phone", text: $Phone)
.onChange(of: Phone, perform: { value in 
   Phone=String(Search.Phone.prefix(10))
})
micah
  • 838
  • 7
  • 21
2

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.

Al Foиce ѫ
  • 4,195
  • 12
  • 39
  • 49
  • 4
    When I try this, the text still goes into the text field, it just gets truncated at the end. I believe the goal is to prevent users from typing more text than is allowed. Throwing away text that looks like it was entered would seem a very bad user experience. – Rob Napier Aug 12 '20 at 21:55
2

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

Nearly every one of the methods mentioned here has bugs. I tried a custom Binding, @State, @StateObject, and .onChange(), all to no avail.

The primary recurring bugs (imagine the TextField is limited to 10 characters in all these scenarios):

  • If the user types quickly and repeats the same keys, they can easily insert more than 10 characters. In the most common scenario, the user is repeating the same key and it inserts 11 characters. If they type a 12th character, it resets back to 10, but this is clearly a bug.
  • Typing a character at the start of the text can move the caret to the end. For example, imagine the text is "HelloWorld" and the caret as at the beginning. If the user types "A", the caret should stay at the beginning and do nothing. Many of the solutions will incorrectly move the caret to the end.
  • Some solutions overwrite existing text. Imagine the text was "HelloWorld". If the user goes to the beginning of the text and types "A", it will change the text to "AHelloWorl", which is usually not what the user expects.

We need to wait for Apple to introduce a more stable API, or file bugs to fix these problems.

Until then, if you want an bug-free approach, you're forced to wrap a UITextField using UIViewRepresentable and expose UITextFieldDelegate.textField(_:shouldChangeCharactersIn:replacementString:).

Senseful
  • 86,719
  • 67
  • 308
  • 465
1

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.

Mystic Muffin
  • 201
  • 2
  • 14
1

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

This works for me (except for a glitch when repeatedly pasting long text)

    @State var text = ""
    @State var changeCount = 0
    let limit = 5
        
    var limitedText: Binding<String> {
        let _ = changeCount // required
        return Binding {
            text
        } set: { value in
            if value.count < limit + 1 {
               text = value
            }
            changeCount+=1
        }
    }
        
    var body: some View {
        TextField("Limited", text: limitedText)
    }
malhal
  • 26,330
  • 7
  • 115
  • 133
0

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))
        }
     }
}
Lukasz D
  • 241
  • 4
  • 8