skip to Main Content

As a beginner in SwiftUI, I am creating a custom OTP view which is working fine, but I am facing an issue with switching focus to the next field. I expect that when the user types, it should automatically switch focus to the next available OTP field.

Please help!!!

struct CustomOtpFieldView: View {
    @Binding var text: String
    @Binding var otpFields: [String]
    var index: Int
    @FocusState private var isFocused: Bool

    var body: some View {
        TextField("", text: $text)
            .focused($isFocused)
            .keyboardType(.numberPad)
            .textContentType(.oneTimeCode)
            .frame(width: 50, height: 50)
            .multilineTextAlignment(.center)
            .background(RoundedRectangle(cornerRadius: 5).stroke(Color.green))
            .onChange(of: text) { newValue in
                let allowedCharacters = "0123456789"
                if !allowedCharacters.contains(newValue) {
                    text = String(newValue.filter { allowedCharacters.contains($0) })
                }
                if text.count > 1 {
                    text = String(text.prefix(1))
                }
                
                handleOtpFieldChange()
            }
            .onAppear {
                if index == 0 {
                    isFocused = true
                }
            }
    }

    private func handleOtpFieldChange() {
        if text.count == 1 && index < otpFields.count - 1 {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                isFocused = false
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    otpFields[index + 1] = ""
                    isFocused = true
                }
            }
        }
    }
}
@State private var otpFields: [String]

self.fieldsCount = 6
self._otpFields = State(initialValue: Array(repeating: "", count: fieldsCount))

ForEach(0..<fieldsCount, id: .self) { index in
                    CustomOtpFieldView(text: $otpFields[index], otpFields: $otpFields, index: index)
                }



Thanks in advance.

2

Answers


  1. Yeah the way you’re doing it seems a little off, here is how I did it. First create an enum FocusField this will contain all our fields.

    enum FocusField {
        case one
        case two
        case three
    }
    

    Then I created a reusable View called ‘SingleDigitTextFieldView’ that does the validation for me on the onChange modifier. This is how it looks like.

    struct SingleDigitTextFieldView: View {
        @Binding var text: String
        @FocusState.Binding var ff: FocusField?
        var assignedField: FocusField
        var nextField: FocusField?
        
        var body: some View {
            TextField("0", text: $text)
                .focused($ff, equals: assignedField)
                .keyboardType(.numberPad)
                .textContentType(.oneTimeCode)
                .frame(width: 50, height: 50)
                .multilineTextAlignment(.center)
                .background(RoundedRectangle(cornerRadius: 5).stroke(Color.green))
                .onChange(of: text) { oldValue, newValue in
                    let exceptedValues = "0123456789"
                    if exceptedValues.contains(newValue) {
                        if newValue.count > 0 {
                            ff = nextField
                        }
                    }
                    else {
                        text = ""
                    }
                }
        }
    }
    

    Then you can use it like this.

    struct ContentView: View {
        @FocusState private var ff: FocusField?
        @State private var oneText: String = ""
        @State private var twoText: String = ""
        @State private var threeText: String = ""
        
        var body: some View {
            HStack {
                SingleDigitTextFieldView(
                    text: $oneText,
                    ff: $ff,
                    assignedField: .one,
                    nextField: .two
                )
                
                SingleDigitTextFieldView(
                    text: $twoText,
                    ff: $ff,
                    assignedField: .two,
                    nextField: .three
                )
                
                SingleDigitTextFieldView(
                    text: $threeText,
                    ff: $ff,
                    assignedField: .three,
                    nextField: nil
                )
            }
        }
    }
    

    You can do more validation in the onChange modifier. Happy Coding.

    Login or Signup to reply.
  2. I created this reusable OTPFieldView SwiftUI component where I’ve utilized the @FocusState property wrapper and created a FocusPin enum to manage focus on each field dynamically. The onChange handler on each TextField plays a key role in moving the focus to the next field once a digit is entered
    I hope it proves helpful for others facing the same challenge.

    You can download the full source code in my Github repo
    https://github.com/JayantBadlani/OTPFieldView-SwiftUI

    SOURCE CODE:

    import SwiftUI
    import Combine
    
    
    // A SwiftUI view for entering OTP (One-Time Password).
    struct OTPFieldView: View {
        
        @FocusState private var pinFocusState: FocusPin?
        @Binding private var otp: String
        @State private var pins: [String]
        
        var numberOfFields: Int
        
        enum FocusPin: Hashable {
            case pin(Int)
        }
        
        init(numberOfFields: Int, otp: Binding<String>) {
            self.numberOfFields = numberOfFields
            self._otp = otp
            self._pins = State(initialValue: Array(repeating: "", count: numberOfFields))
        }
        
        var body: some View {
            HStack(spacing: 15) {
                ForEach(0..<numberOfFields, id: .self) { index in
                    TextField("", text: $pins[index])
                        .modifier(OtpModifier(pin: $pins[index]))
                        .foregroundColor(.white)
                        .onChange(of: pins[index]) { newVal in
                            if newVal.count == 1 {
                                if index < numberOfFields - 1 {
                                    pinFocusState = FocusPin.pin(index + 1)
                                } else {
                                    // Uncomment this if you want to clear focus after the last digit
                                    // pinFocusState = nil
                                }
                            }
                            else if newVal.count == numberOfFields, let intValue = Int(newVal) {
                                // Pasted value
                                otp = newVal
                                updatePinsFromOTP()
                                pinFocusState = FocusPin.pin(numberOfFields - 1)
                            }
                            else if newVal.isEmpty {
                                if index > 0 {
                                    pinFocusState = FocusPin.pin(index - 1)
                                }
                            }
                            updateOTPString()
                        }
                        .focused($pinFocusState, equals: FocusPin.pin(index))
                        .onTapGesture {
                            // Set focus to the current field when tapped
                            pinFocusState = FocusPin.pin(index)
                        }
                }
            }
            .onAppear {
                // Initialize pins based on the OTP string
                updatePinsFromOTP()
            }
        }
        
        private func updatePinsFromOTP() {
            let otpArray = Array(otp.prefix(numberOfFields))
            for (index, char) in otpArray.enumerated() {
                pins[index] = String(char)
            }
        }
        
        private func updateOTPString() {
            otp = pins.joined()
        }
    }
    
    struct OtpModifier: ViewModifier {
        @Binding var pin: String
        
        var textLimit = 1
        
        func limitText(_ upper: Int) {
            if pin.count > upper {
                self.pin = String(pin.prefix(upper))
            }
        }
        
        func body(content: Content) -> some View {
            content
                .multilineTextAlignment(.center)
                .keyboardType(.numberPad)
                .onReceive(Just(pin)) { _ in limitText(textLimit) }
                .frame(width: 40, height: 48)
                .font(.system(size: 14))
                .background(
                    RoundedRectangle(cornerRadius: 2)
                        .stroke(Color.gray, lineWidth: 1)
                )
        }
    }
    
    struct OTPFieldView_Previews: PreviewProvider {
        
        static var previews: some View {
            
            VStack(alignment: .leading, spacing: 8) {
                Text("VERIFICATION CODE")
                    .foregroundColor(Color.gray)
                    .font(.system(size: 12))
                OTPFieldView(numberOfFields: 5, otp: .constant("54321"))
                    .previewLayout(.sizeThatFits)
            }
        }
    }
    

    EXAMPLE: HOW TO USE

    import SwiftUI
    
    struct ContentView: View {
        @State private var otp: String = ""
        @FocusState private var isOTPFieldFocused: Bool
        private let numberOfFieldsInOTP = 6
        
        var body: some View {
            
            VStack(alignment: .leading, spacing: 8) {
                Text("VERIFICATION CODE")
                    .foregroundColor(Color.gray)
                    .font(.system(size: 12))
                
                OTPFieldView(numberOfFields: numberOfFieldsInOTP, otp: $otp)
                    .onChange(of: otp) { newOtp in
                        if newOtp.count == numberOfFieldsInOTP {
                            // Verify OTP
                        }
                    }
                    .focused($isTextFieldFocused)
                
                Text("Entered OTP: (otp)")
            }
            .onAppear {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    isTextFieldFocused = true
                }
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search