4

I am attempting to format the data in a SwiftUI TextField with a pattern or mask. (For clarity - NOT a UITextField). One example would be a US phone number. So the user can type 1115551212 and the result in the view is 111-555-1212. I'll use a numberPad in this case, but for future, with the full keyboard, if the user types a non-number, I'd like to be able to replace it with something, say 0. So entering 111abc1212 would result in 111-000-1212. Even though the code below converts the string to numbers, ideally, I want the mask to operate on a String not a number - giving the flexibility to format part numbers etc.

I have been able to do this with a func that operates from a button, but of course I want it to be automatic. I have been completely unsuccessful with SwiftUI modifiers to do the same. And using the built in .textContentType(.telephoneNumber) does absolutely nothing in my tests.

I would expect to have some modifier like .onExitCommand that can execute when the focus leaves the TextField but I don't see a solution.

This code works with the button (I will later add rules to filter for numbers when numbers are expected):

struct ContentView: View {

    @State private var phoneNumber = ""
    @State private var digitArray = [1]

    var body: some View {

        VStack {
            Group {//group one
                Text("Phone Number with Format").font(.title)
                    .padding(.top, 40)
            
                TextField("enter phone number", text: $phoneNumber)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .padding()
                    .keyboardType(.numberPad)

            }//Group one
        
            Group {//group two
                            
                Button(action: {self.populateTextFieldWithPhoneFormat(phoneString: self.phoneNumber)}) {
                    Text("Convert to Phone Format")
                }
            
            }//group two
            Spacer()
        }//VStack
    
    }

    func populateTextFieldWithPhoneFormat(phoneString: String) {

        var digitArray = [Int]()

        let padded = phoneNumber.padding(toLength: 10, withPad: "0", startingAt: 0)
        let paddedArray = padded.map {String($0)}

        for char in paddedArray {
            digitArray.append(Int(char) ?? 0)
        }

        phoneNumber = format(digits: digitArray)

    }//populate

    func format(digits: [Int]) -> String {
        var phone = digits.map(String.init)
            .joined()
        if digits.count > 3 {
            phone.insert("-", at: phone.index(
                phone.startIndex,
                offsetBy: 3)
            )
        }
        if digits.count > 7 {
            phone.insert("-", at: phone.index(
                phone.startIndex,
                offsetBy: 7)
            )
        }
        return phone
    }
}

I also attempted to make my own ViewModifier, but got nowhere.

Xcode Version 11.2.1 (11B500)

Sunderam Dubey
  • 1
  • 11
  • 20
  • 40
JohnSF
  • 3,736
  • 3
  • 36
  • 72

2 Answers2

6

TextField on SwiftUI has some interesting overloads that could solve your problem very easily. I managed to make a TextField mask for a MAC address formatter like the following (##:##:##:##:##). Below there's a simple snip of code that does the trick:

class MacAddressFormatter: Formatter {
    override func string(for obj: Any?) -> String? {
        if let string = obj as? String {
            return formattedAddress(mac: string)
        }
        return nil
    }
    
    override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
        obj?.pointee = string as AnyObject?
        return true
    }
    
    func formattedAddress(mac: String?) -> String? {
        guard let number = mac else { return nil }
        let mask = "##:##:##"
        var result = ""
        var index = number.startIndex
        for ch in mask where index < number.endIndex {
            if ch == "#" {
                result.append(number[index])
                index = number.index(after: index)
            } else {
                result.append(ch)
            }
        }
        return result
    }
}

In your case you should change the formattedAddress(mac: String) -> String? method in order to return your result as needed.

Below there's a simple SwiftUI implementation of the above:

struct ContentView: View {
    @State private var textFieldValue:String = ""
    var body: some View {
        NavigationView {
            List {
                TextField("First 6 bytes", value: $textFieldValue, formatter: MacAddressFormatter())
            }
            .navigationBarTitle("Mac Lookup", displayMode: .inline)
        }
    }
}

For the more curious: Here You can find a really interesting article about this topic.

valvoline
  • 7,737
  • 3
  • 47
  • 52
  • Really cool. For others - I was unaware of the overloads available, including value and formatter as you mention. Also, from the referenced article - you can inline launch closures when editing and committing the field. Highly recommend the referenced article. – JohnSF Mar 22 '21 at 22:53
  • There's a multi-run bug, but the SO edit queue is full. You should sanitize the string before each format run. One way is to: `mac?.filter("0123456789.".contains)` – Mykel Jan 05 '22 at 08:30
2

Isn't this constructor of what you are looking for?

/// Creates an instance with a `Text` label generated from a title string.
///
/// - Parameters:
///     - title: The title of `self`, describing its purpose.
///     - text: The text to be displayed and edited.
///     - onEditingChanged: An `Action` that will be called when the user
///     begins editing `text` and after the user finishes editing `text`,
///     passing a `Bool` indicating whether `self` is currently being edited
///     or not.
///     - onCommit: The action to perform when the user performs an action
///     (usually the return key) while the `TextField` has focus.
public init<S>(_ title: S, text: Binding<String>, 
            onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
            onCommit: @escaping () -> Void = {}) where S : StringProtocol
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Yes it certainly is. I can't believe I looked for so long but couldn't find this functionality. It works perfectly for me. – JohnSF Nov 21 '19 at 18:47
  • For others - added benefit. Using an if-else on the onEditingChange Bool you can clear the TextField when clicking into the field without further code. – JohnSF Nov 21 '19 at 19:04
  • You can use Binding like [here](https://stackoverflow.com/a/59872410/4067700) – Victor Kushnerov Feb 29 '20 at 09:33