swiftuiuitextfieldone-time-password

Creating OTP page for SwiftUI Using TextField


I am trying to create an OTP page for my app but I don't know how to make the next textfield focus after I input a single digit in the first text field.

I created 6 text field for each digit of OTP. The next text field should be the first responder once I key in one digit from the first text field and so forth untill all 6 digits are complete.

I'm not sure how to do that in Swift UI. So far I manage to create 6 lines only as seen in the screenshot. The expected is only one digit should be per line. So the next text field should be focus once I input a single integer.

I tried other post like the use of @FocusState but it says unknown attribute.

I also tried the custom text field How to move to next TextField in SwiftUI? but I cannot seem to make it work.


import SwiftUI


struct ContentView: View {
    
    
    @State private var OTP1 = ""
    @State private var OTP2 = ""
    @State private var OTP3 = ""
    @State private var OTP4 = ""
    @State private var OTP5 = ""
    @State private var OTP6 = ""
    
    
    var body: some View {
        
        VStack {
            
            HStack(spacing: 16) {
                VStack {
                    TextField("", text: $OTP1)
                    Line()
                        .stroke(style: StrokeStyle(lineWidth: 1))
                        .frame(width: 41, height: 1)
                }
                
                VStack {
                    TextField("", text: $OTP2)
                    Line()
                        .stroke(style: StrokeStyle(lineWidth: 1))
                        .frame(width: 41, height: 1)
                }
                
                VStack {
                    TextField("", text: $OTP3)
                    Line()
                        .stroke(style: StrokeStyle(lineWidth: 1))
                        .frame(width: 41, height: 1)
                }
                
                VStack {
                    TextField("", text: $OTP4)
                    Line()
                        .stroke(style: StrokeStyle(lineWidth: 1))
                        .frame(width: 41, height: 1)
                }
                
                VStack {
                    TextField("", text: $OTP5)
                    Line()
                        .stroke(style: StrokeStyle(lineWidth: 1))
                        .frame(width: 41, height: 1)
                }
                
                VStack {
                    TextField("", text: $OTP6)
                    Line()
                        .stroke(style: StrokeStyle(lineWidth: 1))
                        .frame(width: 41, height: 1)
                }
            }
            
        }
    }
}

struct Line: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: 0, y: 0))
        path.addLine(to: CGPoint(x: rect.width, y: 0))
        return path
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .previewLayout(.fixed(width: 560, height: 50))
    }
}

My OTP Page

Expected field


Solution

  • Here is my answer for iOS 14.

    The view.

    
    struct ContentView: View {
        
          @StateObject var viewModel = ViewModel()
          @State var isFocused = false
          
          let textBoxWidth = UIScreen.main.bounds.width / 8
          let textBoxHeight = UIScreen.main.bounds.width / 8
          let spaceBetweenBoxes: CGFloat = 10
          let paddingOfBox: CGFloat = 1
          var textFieldOriginalWidth: CGFloat {
              (textBoxWidth*6)+(spaceBetweenBoxes*3)+((paddingOfBox*2)*3)
          }
          
          var body: some View {
                  
                  VStack {
                      
                      ZStack {
                          
                          HStack (spacing: spaceBetweenBoxes){
                              
                              otpText(text: viewModel.otp1)
                              otpText(text: viewModel.otp2)
                              otpText(text: viewModel.otp3)
                              otpText(text: viewModel.otp4)
                              otpText(text: viewModel.otp5)
                              otpText(text: viewModel.otp6)
                          }
                          
                          
                          TextField("", text: $viewModel.otpField)
                          .frame(width: isFocused ? 0 : textFieldOriginalWidth, height: textBoxHeight)
                          .disabled(viewModel.isTextFieldDisabled)
                          .textContentType(.oneTimeCode)
                          .foregroundColor(.clear)
                          .accentColor(.clear)
                          .background(Color.clear)
                          .keyboardType(.numberPad)
                      }
              }
          }
          
          private func otpText(text: String) -> some View {
              
              return Text(text)
                  .font(.title)
                  .frame(width: textBoxWidth, height: textBoxHeight)
                  .background(VStack{
                    Spacer()
                    RoundedRectangle(cornerRadius: 1)
                        .frame(height: 0.5)
                   })
                  .padding(paddingOfBox)
          }
    }
    

    This is the viewModel.

    class ViewModel: ObservableObject {
        
        @Published var otpField = "" {
            didSet {
                guard otpField.count <= 6,
                      otpField.last?.isNumber ?? true else {
                    otpField = oldValue
                    return
                }
            }
        }
        var otp1: String {
            guard otpField.count >= 1 else {
                return ""
            }
            return String(Array(otpField)[0])
        }
        var otp2: String {
            guard otpField.count >= 2 else {
                return ""
            }
            return String(Array(otpField)[1])
        }
        var otp3: String {
            guard otpField.count >= 3 else {
                return ""
            }
            return String(Array(otpField)[2])
        }
        var otp4: String {
            guard otpField.count >= 4 else {
                return ""
            }
            return String(Array(otpField)[3])
        }
        
        var otp5: String {
            guard otpField.count >= 5 else {
                return ""
            }
            return String(Array(otpField)[4])
        }
        
        var otp6: String {
            guard otpField.count >= 6 else {
                return ""
            }
            return String(Array(otpField)[5])
        }
        
        @Published var borderColor: Color = .black
        @Published var isTextFieldDisabled = false
        var successCompletionHandler: (()->())?
        
        @Published var showResendText = false
    
    }
    

    Not very reusable but it works.... If you want to change the length don't forget to update the viewModel's otpField's didSet and the views textFieldOriginalWidth.

    The idea here is to hide the TextField and make it seem like the user is typing in the boxes.

    An Idea could be to shrink the TextField when user is typing by using the isEditing closure from the TextField. You would want to shrink it so the user can't paste text or get that "popup" or the textfield cursor.