3

I want to make a login code screen. This consists of 4 separate UITextField elements, each accepting one character. What I did is implement a system whereby every time one of the UITextField's changes it will verify if all the values are filled out, and if they are update a boolean binding to tell the parent object that the code is correct.

In order to do this I wrap the @State variables inside a custom binding that does a callback on the setter, like this:

@State private var chars:[String] = ["","","",""]

...
var body: some View {
        var bindings:[Binding<String>] = []

        for x in 0..<self.chars.count {
            let b = Binding<String>(get: {
                return self.chars[x]
            }, set: {
                self.chars[x] = $0
                self.validateCode()
            })

            bindings.append(b)
        }

and those bindings are passed to the components. Every time my text value changes validateCode is called. This works perfectly.

However now I want to add an extra behavior: If the user types 4 characters and the code is wrong I want to move the first responder back to the first textfield and clear its contents. The first responder part works fine (I also manage that using @State variables, but I do not use a binding wrapper for those), however I can't change the text inside my code. I think it's because my components use that wrapped binding, and not the variable containing the text.

This is what my validateCode looks like:

    func validateCode() {
        let combinedCode = chars.reduce("") { (result, string) -> String in
            return result + string
        }

        self.isValid = value == combinedCode

        if !isValid && combinedCode.count == chars.count {

            self.hasFocus = [true,false,false,false]
            self.chars = ["","","",""]
        }
    }

hasFocus does its thing correctly and the cursor is being moved to the first UITextField. The text however remains in the text fields. I tried creating those bindings in the init so I could also use them in my validateCode function but that gives all kinds of compile errors because I am using self inside the getter and the setter.

Any idea how to solve this? Should I work with Observables? I'm just starting out with SwiftUI so it's possible I am missing some tools that I can use for this.

For completeness, here is the code of the entire file:

import SwiftUI

struct CWCodeView: View {
    var value:String
    @Binding var isValid:Bool

    @State private var chars:[String] = ["","","",""]

    @State private var hasFocus = [true,false,false,false]
    @State private var nothingHasFocus:Bool = false

    init(value:String,isValid:Binding<Bool>) {
        self.value = value
        self._isValid = isValid

    }

    func validateCode() {
        let combinedCode = chars.reduce("") { (result, string) -> String in
            return result + string
        }
        self.isValid = value == combinedCode


        if !isValid && combinedCode.count == chars.count {

            self.hasFocus = [true,false,false,false]
            self.nothingHasFocus = false
            self.chars = ["","","",""]
        }
    }

    var body: some View {
        var bindings:[Binding<String>] = []

        for x in 0..<self.chars.count {
            let b = Binding<String>(get: {
                return self.chars[x]
            }, set: {
                self.chars[x] = $0
                self.validateCode()
            })

            bindings.append(b)
        }


        return GeometryReader { geometry in
            ScrollView (.vertical){
                VStack{
                    HStack {
                        CWNumberField(letter: bindings[0],hasFocus: self.$hasFocus[0], previousHasFocus: self.$nothingHasFocus, nextHasFocus: self.$hasFocus[1])
                        CWNumberField(letter: bindings[1],hasFocus: self.$hasFocus[1], previousHasFocus: self.$hasFocus[0], nextHasFocus: self.$hasFocus[2])
                        CWNumberField(letter: bindings[2],hasFocus: self.$hasFocus[2], previousHasFocus: self.$hasFocus[1], nextHasFocus: self.$hasFocus[3])
                        CWNumberField(letter: bindings[3],hasFocus: self.$hasFocus[3], previousHasFocus: self.$hasFocus[2], nextHasFocus: self.$nothingHasFocus)
                    }
                }
                .frame(width: geometry.size.width)
                .frame(height: geometry.size.height)
                .modifier(AdaptsToSoftwareKeyboard())
            }
        }
    }
}

struct CWCodeView_Previews: PreviewProvider {
    static var previews: some View {
        CWCodeView(value: "1000", isValid: .constant(false))
    }
}

struct CWNumberField : View {
    @Binding var letter:String
    @Binding var hasFocus:Bool
    @Binding var previousHasFocus:Bool
    @Binding var nextHasFocus:Bool

    var body: some View {
        CWSingleCharacterTextField(character:$letter,hasFocus: $hasFocus, previousHasFocus: $previousHasFocus, nextHasFocus: $nextHasFocus)
            .frame(width: 46,height:56)
            .keyboardType(.numberPad)
            .overlay(
                RoundedRectangle(cornerRadius: 5)
                    .stroke(Color.init("codeBorder"), lineWidth: 1)
            )

    }
}

struct CWSingleCharacterTextField : UIViewRepresentable {
    @Binding var character: String
    @Binding var hasFocus:Bool
    @Binding var previousHasFocus:Bool
    @Binding var nextHasFocus:Bool

    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField.init()
        //textField.isSecureTextEntry = true
        textField.keyboardType = .numberPad
        textField.delegate = context.coordinator
        textField.textAlignment = .center
        textField.font = UIFont.systemFont(ofSize: 16)
        textField.tintColor = .black
        textField.text = character

        return textField
    }

    func updateUIView(_ uiView: UITextField, context: Context) {
        if hasFocus {
            DispatchQueue.main.async {
                uiView.becomeFirstResponder()
            }
        }
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }

    class Coordinator : NSObject, UITextFieldDelegate {
        var parent:CWSingleCharacterTextField

        init(_ parent:CWSingleCharacterTextField) {
            self.parent = parent
        }

        func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
            let result = (textField.text! as NSString).replacingCharacters(in: range, with: string)

            if result.count > 0 {
                DispatchQueue.main.async{
                    self.parent.hasFocus = false
                    self.parent.nextHasFocus = true
                }
            } else {
                DispatchQueue.main.async{
                    self.parent.hasFocus = false
                    self.parent.previousHasFocus = true
                }
            }

            if result.count <= 1 {
                parent.character = string
                return true
            }

            return false
        }
    }
}

Thanks!

1 Answer 1

1

you just make a little mistake, but i cannot believe you just "started" SwiftUI ;)

1.) just build textfield one time, so i took it as a member variable instead of building always a new one 2.) update the text in updateuiview -> that's it 3.) ...nearly: there is still a focus/update problem...the last of the four textfields won't update correctly ...i assume this is a focus problem....

try this:

struct CWSingleCharacterTextField : UIViewRepresentable {
    @Binding var character: String
    @Binding var hasFocus:Bool
    @Binding var previousHasFocus:Bool
    @Binding var nextHasFocus:Bool

    let textField = UITextField.init()

    func makeUIView(context: Context) -> UITextField {
        //textField.isSecureTextEntry = true
        textField.keyboardType = .numberPad
        textField.delegate = context.coordinator
        textField.textAlignment = .center
        textField.font = UIFont.systemFont(ofSize: 16)
        textField.tintColor = .black
        textField.text = character

        return textField
    }

    func updateUIView(_ uiView: UITextField, context: Context) {

        uiView.text = character

        if hasFocus {
            DispatchQueue.main.async {
                uiView.becomeFirstResponder()
            }
        }
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }

    class Coordinator : NSObject, UITextFieldDelegate {
        var parent:CWSingleCharacterTextField

        init(_ parent:CWSingleCharacterTextField) {
            self.parent = parent
        }

        func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
            let result = (textField.text! as NSString).replacingCharacters(in: range, with: string)

            if result.count > 0 {
                DispatchQueue.main.async{
                    self.parent.hasFocus = false
                    self.parent.nextHasFocus = true
                }
            } else {
                DispatchQueue.main.async{
                    self.parent.hasFocus = false
                    self.parent.previousHasFocus = true
                }
            }

            if result.count <= 1 {
                parent.character = string
                return true
            }

            return false
        }
    }
}
Sign up to request clarification or add additional context in comments.

4 Comments

What do you mean you don't believe me? I started this weekend ;) I will try your solution today and let you know how it went.
because it is very tricky what you are doing and it looks a pro....!
I have been doing iOS for 10 years ;)
It works if I add DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) around the 'reset contents' code

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.