Verification Code Design and Functionality in SwiftUI

1.1k Views Asked by At

I tried implementing it with six text fields but found a number of problems as a lot of work, blocking all but the first text field for initial input, the laggy move of the first responder, and whatnot, which made me wonder if having 6 text fields are really the best approach.

The hard part is the functionality (i.e the cursor moving smoothly, getting back and forth, making all of them red when input is wrong, etc) How could I achieve such behavior/functionality?

Screenshot:-

Design

Code Below:-

import SwiftUI

struct VerficationCode: View {
@State private var numberOfCells: Int = 6
@State private var currentlySelectedCell = 0

var body: some View {
HStack {
    Group {
        ForEach(0 ..< self.numberOfCells) { index in
            CharacterInputCell(currentlySelectedCell: self.$currentlySelectedCell, index: index)
        }
    }.frame(width:15,height: 56)
    .padding(.horizontal)
    .foregroundColor(.white)
    .cornerRadius(10)
    .keyboardType(.numberPad)
     }
   }
 }

struct CharacterInputCell: View {
@State private var textValue: String = ""
@Binding var currentlySelectedCell: Int

var index: Int

var responder: Bool {return index == currentlySelectedCell }

var body: some View {
 CustomTextField(text: $textValue, currentlySelectedCell: $currentlySelectedCell, isFirstResponder: responder)
    }
}

struct CustomTextField: UIViewRepresentable {

class Coordinator: NSObject, UITextFieldDelegate {

@Binding var text: String
@Binding var currentlySelectedCell: Int

var didBecomeFirstResponder = false

init(text: Binding<String>, currentlySelectedCell: Binding<Int>){
    _text = text
    _currentlySelectedCell = currentlySelectedCell
}

func textFieldDidChangeSelection(_ textField: UITextField) {
    DispatchQueue.main.async {
        self.text = textField.text ?? ""
    }
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    let currentText = textField.text ?? ""
    guard let stringRange = Range(range, in: currentText) else { return false }
    let updatedText = currentText.replacingCharacters(in: stringRange, with: string)
    if updatedText.count <= 1 {
        self.currentlySelectedCell += 1
      }
       return updatedText.count <= 1
     }
  }

 @Binding var text: String
 @Binding var currentlySelectedCell: Int

 var isFirstResponder: Bool = false

 func makeUIView(context: UIViewRepresentableContext<CustomTextField>) -> UITextField {
        let textField = UITextField(frame: .zero)
        textField.delegate = context.coordinator
        textField.textAlignment = .center
        textField.keyboardType = .decimalPad
        return textField
 }

func makeCoordinator() -> CustomTextField.Coordinator {
       return Coordinator(text: $text, currentlySelectedCell: $currentlySelectedCell)
}

func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<CustomTextField>) {
uiView.text = text
if isFirstResponder && !context.coordinator.didBecomeFirstResponder  {
    uiView.becomeFirstResponder()
    context.coordinator.didBecomeFirstResponder = true
          }
      }
  }

Can someone please explain to me How could I achieve such behavior/functionality? I've tried to implement but no results yet.

Any help would be greatly appreciated.

Thanks in advance.

2

There are 2 best solutions below

0
Robert Vunabandi On

Ok this is not a fully working solution but an idea that can get you there if you choose to use SwiftUI.

First, you have this enum to use as focus state

enum VerificationCodeFocusField: Hashable {
  case field(index: Int)
}

Then, here's the general idea for the VerificationCodeTextField (which will implement what you want with a set number of digits)

struct VerificationCodeTextField: View {
    /**
     * The number of digits this verification code will have
     */
    let digits: Int
    /**
     * The outer facing code input that the digits should be fed into. Though we will use
     * another encoding internally, it will be synced with this string.
     */
    @Binding
    var text: String

    @State
    private var ready = false
    @State
    private var cells: [String] = []
    @FocusState
    private var focus: VerificationCodeFocusField?

    func initialize() {
        cells = []
        for i in 0 ..< digits {
            cells.append(value(i))
        }
        ready = true
    }
    
    func value(_ index: Int) -> String {
        if index >= text.count {
            return ""
        }
        return String(text[text.index(text.startIndex, offsetBy: index)])
    }

    var body: some View {
        Group {
            if !ready {
                ProgressView()
            } else {
                HStack {
                    ForEach(0 ..< digits) { index in
                        HStack {
                            TextField("", text: $cells[index])
                                .multilineTextAlignment(.center)
                                .keyboardType(.numberPad)
                                .focused($focus, equals: .field(index: index))
                                .onTapGesture {
                                    // TODO - DIDN'T HAVE TIME TO IMPLEMENT
                                    updateFocus()
                                }
                                .onChange(of: cells[index]) { newCharacter in
                                    // TODO - DIDN'T HAVE TIME TO IMPLEMENT
                                }
                        }
                        .fFrame(width: .xs(.icon), height: .xs(.icon))
                        .fPadding(.m())
                        .backgroundFColor(.black(.o15))
                        .cornerFRadius(.s())
                    }
                }
            }
        }
        .onAppear(perform: initialize)
    }
}

The .onChange(of: cells[index]) function and the updateFocus function are what you have to now implement. The general idea I had is this.

onChange

  • Check if it's a deletion, addition, or neutral change.
    • deletion brings you back to previous cell
    • additions bring you to the next cell(s)
    • neutral (1 character) keep you on this cell
    • all of these should ensure to sync up with text
  • Each cells[index] should have at most 1 character
  • If cells[index] has 1 character, every previous indices should also have a character
  • Keep track of a "focusIndex" which is the last cell with at least 1 digit (or zero if there's no code yet, then it's index 0)
  • Handle all edge cases (can't add more than digits code, edge case of text = "", etc).

updateFocus

This should essentially bring the focus to the focusIndex. That way, the user is always typing from there. Remember, focusIndex should be the last cell with 1 character.

Final Notes

Ok, I haven't actually implemented this fully so I could be missing something. But I believe this should work.

Also, Note the view methods above:

.fFrame(width: .xs(.icon), height: .xs(.icon))
.fPadding(.m())
.backgroundFColor(.black(.o15))
.cornerFRadius(.s())

That is coming from a system I essentially built up but these are all what they sound like:

  • A frame size that is of an extra small "icon"
  • a "medium" padding
  • a black background with opacity of 0.15
  • a "small" corner radius
0
Jayant Badlani On

I created this reusable OTPFieldView SwiftUI component, 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()
    }
}