SwiftUI TextField max length

IosSwiftSwiftui

Ios Problem Overview


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

Ios Solutions


Solution 1 - Ios

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.

Solution 2 - Ios

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

Solution 3 - Ios

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

Solution 4 - Ios

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

Solution 5 - Ios

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

Solution 6 - Ios

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

Solution 7 - Ios

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

Solution 8 - Ios

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

Solution 9 - Ios

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.

Solution 10 - Ios

https://stackoverflow.com/questions/56476007/swiftui-textfield-max-length/64199094#64199094

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

Solution 11 - Ios

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

Solution 12 - Ios

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.

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionM RezaView Question on Stackoverflow
Solution 1 - IosAlex Ioja-YangView Answer on Stackoverflow
Solution 2 - IosRoman ShelkfordView Answer on Stackoverflow
Solution 3 - IosPaulw11View Answer on Stackoverflow
Solution 4 - IosRufat MirzaView Answer on Stackoverflow
Solution 5 - IosRaja KishanView Answer on Stackoverflow
Solution 6 - IosRob NapierView Answer on Stackoverflow
Solution 7 - IoskgaidisView Answer on Stackoverflow
Solution 8 - IosNeverwinterMoonView Answer on Stackoverflow
Solution 9 - IosTapan BiswasView Answer on Stackoverflow
Solution 10 - IosGreg BoettcherView Answer on Stackoverflow
Solution 11 - IosRefuXView Answer on Stackoverflow
Solution 12 - IosMystic MuffinView Answer on Stackoverflow