SwiftUI custom OTP View, Firebase OTP authentication-Part 01
This tutorial consists of three parts. In first part, we will create custom view that consists of countdown timer and line progress view that follows the countdown timer.
In part 2, we will create OTP-one time password view.
In part 3 we will show how to create reusable swift package that consists of OTP view from part 2 and implement firebase authentication, that will allow us to authenticate our user with phone number. Code will be available on GitHub.
So, shall we start?
First things first, lets create a new project in XCode. I will not go through process of creating project. As final solution, out project should look like this.
As we can see, we have bolierplate code, that we put in every solution like extensions, colors and some ViewModels. We will consider that code as self explanatory. Also, this code will be available on GitHub.
So lets start from simple functionalities like countdown view. Our CountDownView will look like this.
struct CountDownView : View {
@ObservedObject var phoneViewModel: PhoneViewModel
init(phoneViewModel: PhoneViewModel){
self.phoneViewModel = phoneViewModel
}
var body: some View {
Text(phoneViewModel.timeStr)
.font(Font.system(size: 15))
.foregroundColor(Color.textColorGray)
.fontWeight(.semibold)
.onReceive(phoneViewModel.timer) { _ in
phoneViewModel.countDownString()
}
}
}
As we can see we are using ViewModel to abstract functionalities that this view is providing. PhoneViewModel consists of couple functions that are important for our CuntDownView.
class PhoneViewModel: ObservableObject {
//MARK: vars
var nowDate = Date()
@Published var pin: String = ""
@Published var phoneNumber = "+387 61 555 666"
@Published var countryCodeNumber = ""
@Published var country = ""
@Published var code = ""
@Published var timerExpired = false
@Published var timeStr = ""
@Published var timeRemaining = Constants.COUNTDOWN_TIMER_LENGTH
var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
//MARK: functions
func startTimer() {
timerExpired = false
timeRemaining = Constants.COUNTDOWN_TIMER_LENGTH
self.timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
}
func stopTimer() {
timerExpired = true
self.timer.upstream.connect().cancel()
}
func countDownString() {
guard (timeRemaining > 0) else {
self.timer.upstream.connect().cancel()
timerExpired = true
timeStr = String(format: "%02d:%02d", 00, 00)
return
}
timeRemaining -= 1
timeStr = String(format: "%02d:%02d", 00, timeRemaining)
}
func getPin(at index: Int) -> String {
guard self.pin.count > index else {
return ""
}
return self.pin[index]
}
func limitText(_ upper: Int) {
if pin.count > upper {
pin = String(pin.prefix(upper))
}
}
}
First things first, let check our timer variable.
var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
If we check definition, we can find out next explanation:
- Timer.publish- Returns a publisher that repeatedly emits the current date on the given interval.
- interval- The time interval on which to publish events. For example, a value of
0.5
publishes an event approximately every half-second. (in our case it is every 1 second) - runLoop- The run loop on which the timer runs. (It means that main Thread should auto initialize RunLoop)
- mode- The run loop mode in which to run the timer.
- autoconnect -Returns: A publisher which automatically connects to its upstream connectable publisher.
If we check our constants class ,we have declared our countdown timer length.
public static let COUNTDOWN_TIMER_LENGTH = 60
That means that we are counting down 60 seconds. Function that is important here is func countDownString(). We are checking if remaining time is larger than zero, and if it is we are returning current value of timeStr var, in mm:ss format. Otherwise we set our timer to expired and cancel further publishing of events from our publisher with self.timer.upstream.connect().cancel().
Whole purpose of countDownString func is that we are waiting for 60 seconds for out OTP code to arrive.
So in our ContDownView we create textfield that triggers action when our timer publisher emit an event. And that action is calling countDownString() func that display remaining time.
Next thing is to connect our progress view to countdown timer. For that purpose we have created LinearProgressBarView.
struct LinearProgressBarView: View {
@State private var offset: CGFloat = CGFloat(Constants.COUNTDOWN_TIMER_LENGTH)
@ObservedObject var phoneViewModel: PhoneViewModel
init(phoneViewModel: PhoneViewModel){
self.phoneViewModel = phoneViewModel
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.frame(width: geometry.size.width , height: geometry.size.height)
.opacity(0.5)
.foregroundColor(Color.progressBarBackground)
Rectangle()
.frame(width: (CGFloat(offset - CGFloat(phoneViewModel.timeRemaining)) * geometry.size.width)/offset, height: geometry.size.height)
.foregroundColor(Color.progressBarFill)
.animation(Animation.linear(duration: 1.0), value: offset)
}.cornerRadius(45.0)
}
.frame(height: 5)
}
}
As we can see, we are using PhoneViewModel and timeRemaining var to display progress of our progress view.
.frame(width: (CGFloat(offset — CGFloat(phoneViewModel.timeRemaining)) * geometry.size.width)/offset, height: geometry.size.height)
So when we start our app we should get this.
Now, only thing that we are left with is to implement OtpTextFieldView. Lets show what we want want achieve.
Every time we click on number we want to focus on next field, and when we click on return key we want to delete last number in otp textfield. We could have achieved this with mix of UIKit protocols and SwiftUI like UIViewRepresentable as explained here. So we manage our UIView inside of SwiftUI.
Instead we are going to use one TextField in ZStack with multiple Text Views for showing text from TextField.
public struct OtpTextFieldView: View {
enum FocusField: Hashable {
case field
}
@ObservedObject var phoneViewModel: PhoneViewModel
@FocusState private var focusedField: FocusField?
init(phoneViewModel: PhoneViewModel){
self.phoneViewModel = phoneViewModel
}
private var backgroundTextField: some View {
return TextField("", text: $phoneViewModel.pin)
.frame(width: 0, height: 0, alignment: .center)
.font(Font.system(size: 0))
.accentColor(.blue)
.foregroundColor(.blue)
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.onReceive(Just(phoneViewModel.pin)) { _ in phoneViewModel.limitText(Constants.OTP_CODE_LENGTH) }
.focused($focusedField, equals: .field)
.task {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5)
{
self.focusedField = .field
}
}
.padding()
}
public var body: some View {
ZStack(alignment: .center) {
backgroundTextField
HStack {
ForEach(0..<Constants.OTP_CODE_LENGTH) { index in
ZStack {
Text(phoneViewModel.getPin(at: index))
.font(Font.system(size: 27))
.fontWeight(.semibold)
.foregroundColor(Color.textColorPrimary)
Rectangle()
.frame(height: 2)
.foregroundColor(Color.textColorPrimary)
.padding(.trailing, 5)
.padding(.leading, 5)
.opacity(phoneViewModel.pin.count <= index ? 1 : 0)
}
}
}
}
}
So, first we will use number pad as keyboard. Next, we need to make sure that our otp code length is less or equal than defined in our Constants.
public static let OTP_CODE_LENGTH = 6
For that we are using limitText func in PhoneViewModel.
For us to be able to populate corresponding TextView with correct pin at correct index, we are using func getPin(at index: Int) from PhoneViewModel.
And last, to show a keyboard at opening of the screen, we first set binding to focusField and set value of .field from our enum. Next we create task that is setting focusField bind variable to .field. This opens our keyboard when we show this view.
That is it, hope you have enjoyed this tutorial, and stay tuned for part 02 and more content.
The code is available in the GitHub repository:
GitHubff