6

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

Steve Vinoski
  • 19,847
  • 3
  • 31
  • 46
Dreiohc
  • 317
  • 2
  • 12
  • 1
    `@FocusState` was added in iOS 15. To do that I think you need Xcode beta. Is it a problem to update your project to support iOS 15? If it is you can achieve the same look but it will be a hassle. – Alhomaidhi Sep 02 '21 at 07:40
  • @Alhomaidhi unforunately, we still need to continue our xcode version till next year. – Dreiohc Sep 02 '21 at 08:58

2 Answers2

10

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.

Alhomaidhi
  • 517
  • 4
  • 12
  • Hi @Alhomaidhi, Thank you for your answer. Can I ask a favor if you can covert the broders into broken lines, just like in my screenshot? I tried it on my own but the line bounces down every time I input a number on a line. – Dreiohc Sep 02 '21 at 10:26
  • No problem :).. I did that above. It is your desired look but I think there should be an easier way to get the line instead of a rectangle with a a height if 0.5. – Alhomaidhi Sep 02 '21 at 10:34
  • Sorry I'm trying to play with your code but still can't find how can I change the color of the line. For example. I typed in a number in the first digit, the 2nd line should turn into a different color like green. Example first digit the line is green, then I already input a number in first line, the next line should turn from black to green and so forth. when The text field is selected, the corresponding line should change to green. – Dreiohc Sep 02 '21 at 11:22
  • Here is the link to my post @Alhomaidhi if you interested again to answer https://stackoverflow.com/questions/69029915/change-color-of-lines-depending-on-the-focus-text-field – Dreiohc Sep 02 '21 at 12:04
  • Doesn't work property in landscape mode, also if you keep on entering text after 6 digits, it will add those digits too, but won't show it in the UI. try adding 10 digits and then start erasing one by one, you will se the 6th digit will remove after 5 taps. – Rehan Ali Khan Jul 09 '22 at 12:24
  • @Alhomaidhi hi how can detect user entered last number to do something? – Amin Rezaew Jan 03 '23 at 20:48
  • isFocused is always false. So is it really required? – chakkala Mar 02 '23 at 14:55
  • I'm new to SwiftUI how do you get the otp text from the field? – Adam Lee Apr 11 '23 at 09:06
0

Here is a dynamic one. I hope it works for you!

//
//  DesignTokenField.swift
//  design
//
//  Created by Constantine Kevin on 19.06.23.
//

import SwiftUI

/// A custom SwiftUI view representing a field for entering design tokens.
public struct DesignTokenField: View {
    
    private let size: Int
    
    private let isSecured: Bool
    
    private let emptyField: String = " "
    
    @State private var current: Int = 0
    
    @State private var text: String = ""
    
    @FocusState private var isFocused: Bool
    
    @Binding private var token: String
    
    /// Initializes a new instance of `DesignTokenField` with the specified size.
    /// - Parameter size: The number of token fields.
    public init(
        size: Int,
        isSecured: Bool = false,
        token: Binding<String>
    ) {
        self.size = size
        self.isSecured = isSecured
        
        _token = token
    }
    
    /// The view hierarchy of the `DesignTokenField`.
    public var body: some View {
        ZStack(alignment: .bottom) {
            TextField(emptyField, text: $text)
                .frame(maxWidth: .zero)
                .focused($isFocused)
            HStack(spacing: DesignTokenDimen.leading) {
                ForEach(.zero..<size, id: \.self) { offset in
                    renderTokenField(offset)
                }
            }
        }
    }
    
    @ViewBuilder private func renderTokenField(
        _ offset: Int
    ) -> some View {
        if (current == offset) {
            renderTextField(offset)
        } else {
            renderText(offset)
        }
    }
    
    @ViewBuilder private func renderTextField(
        _ offset: Int
    ) -> some View {
        DesignToken(
            index: offset,
            label: getTokenValue(offset),
            isSecured: isSecured,
            text: $text.onChange(offset, onUpdate)
        ).font(.subheadline)
    }
    
    private func getToken(_ offset: Int) -> String {
        if (offset < token.count) {
            return token.stringAt(offset)
        }
        return emptyField
    }
    
    private func getTokenValue(_ offset: Int) -> String {
        let icon = "\u{2022}"
        let token = getToken(offset)
        return (isSecured && token != emptyField) ? icon : token
    }
    
    @ViewBuilder private func renderText(_ offset: Int) -> some View {
        Text(getTokenValue(offset))
            .applyTextStyle()
            .foregroundColor(.accentColor)
            .frame(
                maxWidth: .infinity,
                maxHeight: DesignTokenDimen.height
            ).background(textBackground)
            .onTapGesture {
                current = min(offset, token.count)
            }
    }
    
    private var textBackground: some View {
        background.foregroundColor(
            .accentColor
        )
    }
    
    private func onUpdate(_ value: String, index: Int) {
        isFocused = true
        current = (index + 1) % size
        token = token.insertOrReplace(index, value.first!)
        isFocused = false
    }
}

/// A custom text field view.
struct DesignToken: View {
    private let index: Int
    private let label: String
    private let isSecured: Bool
    private let emptyString: String = ""
    @Binding var text: String
    @State private var token: String = ""
    @FocusState private var isFocused: Bool

    /// Initializes a new instance of `DesignToken`.
    /// - Parameters:
    ///   - index: The view index.
    ///   - label: The label for the text field.
    ///   - text: The binding for the text field's text value.
    init(
        index: Int,
        label: String,
        isSecured: Bool,
        text: Binding<String>
    ) {
        self.index = index
        self.label = label
        self.isSecured = isSecured
        _text = text
    }

    public var body: some View {
        ZStack {
            if isSecured {
                securedField
            } else {
                textField
            }
        }.frame(height: DesignTokenDimen.height)
            .background(
                self.background.foregroundColor(
                    isFocused
                    ? Color.accentColor : .black
                )
            )
    }
    
    private var textField: some View {
        TextField(
            emptyString,
            text: $token.onChange(index, onUpdate),
            prompt: prompt
        ).keyboardType(.numberPad)
            .frame(maxWidth: .infinity)
            .applyTokenStyle()
            .focused($isFocused)
            .foregroundColor(.blue)
            .multilineTextAlignment(.center).onAppear {
                isFocused = true
            }
    }
    
    private var prompt: Text {
        Text(label).applyTextStyle()
            .foregroundColor(.blue)
    }

    private var securedField: some View {
        SecureField(
            label,
            text: $token.onChange(index, onUpdate)
        ).keyboardType(.numberPad)
            .frame(maxWidth: .infinity)
            .applyTokenStyle()
            .focused($isFocused)
            .multilineTextAlignment(.center).onAppear {
                isFocused = true
            }
    }
    
    private func onUpdate(_ value: String, index: Int) {
        if (value.isEmpty) {
            return
        }
        if (Int(value) == nil) {
            token = emptyString
        } else {
            text = value
        }
    }
}

/// A class to hold the dimensions for the `DesignTokenField`.
fileprivate enum DesignTokenDimen {
    static let radius: CGFloat = 18
    static let height: CGFloat = 56
    static let padding: CGFloat = 24
    static let leading: CGFloat = 8
    static let lineWidth: CGFloat = 1
}

extension String {
    func stringAt(_ i: Int) -> String {
        return String(Array(self)[i])
    }
    
    func insertOrReplace(_ index: Int, _ value: Character) -> String {
        if (count <= index) {
            return "\(self)\(value)"
        }
        return replace(index, value)
    }
    
    private func replace(_ index: Int, _ value: Character) -> String {
        var chars = Array(self)
        self.enumerated().forEach { (key, entry) in
            if (key == index) {
                chars[key] = value
            } else {
                chars[key] = entry
            }
        }
        return String(chars)
    }
}

extension Text {
    internal func applyTextStyle() -> Text {
        self.font(.subheadline)
    }
}

extension View {
    /// Applies the common style properties to the `DesignTextField`.
    internal func applyTokenStyle() -> some View {
        self
            .autocapitalization(.none)
            .font(.subheadline)
            .foregroundColor(.accentColor)
            .contentShape(Rectangle())
    }
    
    fileprivate var background: some View {
        Rectangle()
            .frame(height: DesignTokenDimen.lineWidth)
            .padding(.top, DesignTokenDimen.height)
    }
}

extension Binding {
    
    fileprivate func onChange(
        _ index: Int,
        _ handler: @escaping (Value, Int) -> Void
    ) -> Binding<Value> {
        Binding(
            get: { self.wrappedValue },
            set: { newValue in
                self.wrappedValue = newValue
                handler(newValue, index)
            }
        )
    }
    
}

struct DesignTokenField_Preview : PreviewProvider {
    
    static var previews: some View {
        
        @State var token: String = "91744"
        
        return DesignTokenField(
            size: 5,
            isSecured: false,
            token: $token
        ).padding(48)
    }
}


Replace colors where necessary, and you're good to roll