Tutorial: Creating an accessible One Time Code solution

Our problem

I will deviate a bit from the cliché of saying that accessibility is highly encouraged, a trending topic, and a focus for large companies. Accessibility should be considered fundamental and a basic acceptance criterion for any project.

As I mentioned in the previous post, today I will discuss how to address a common issue in many apps that use a second authentication factor: accessibility and One Time Code (OTC).

OTC is a single-use code sent to users to validate an authentication or transaction process

First, let’s examine the problem from two different perspectives: VoiceOver users and non-VoiceOver users.

VoiceOver is a screen reader that describes exactly what appears on your iPhone

A VoiceOver user doesn’t directly touch the screen components. Navigation is done through horizontal swipes that indicate where the VoiceOver reader’s focus should go (left to right: next; right to left: previous).

Let’s consider the One Time Code scenario. A very common model is shown in the image below:


#blindPeople: the image shows a One Time Code screen with each of the six digits separated into individual text boxes.

Imagine the user’s navigation:

Nine, text field, double tap to edit, Eight, text field, double tap to edit, Five, text field, double tap to edit, Three, text field, double tap to edit, Nine , text field, double tap to edit, One, text field, double tap to edit ”

All this just for the user to input the code 985391.

Now imagine it backwards:

One, text field, double tap to edit, Nine, text field, double tap to edit, Three, text field, double tap to edit, Five, text field, double tap to edit, Eight , text field, double tap to edit, Nine, text field, double tap to edit ”

The code is still 985391, but this time the user has to mentally assemble the code backwards! Horrible, isn’t it?

So, considering the user experience, we understand that:

The Access Code should be a single, unified code field.

Now, let’s discuss the technical solution that best meets both the User Interface and User Experience requirements.

The technical solution

The first step here is to think about how to structure our solution.

Let’s think like the drawing below:

#blindPeople: the image shows a low fidelity design of a One Time Code component. In the component, six small views with numbers centered inside are shown and a larger view overlaps them with a text input view.

Creating our base

Initially, we will create our One Time Code view as an extension of UIView.

So that the internal views can resize themselves and to be all the same size, we will use an object that will make our life easier (thousand times easier): the UIStackView.

To get the result as we want, let’s set the parameters:

distribution = .fillEqually so we can have all our internal views at the same size;

axis = .horizontal so we can have our views horizontally aligned;

spacing = 10 so we can have a space of 10 point between each view.

Next, we’re going to add our StackView in our class and configure the StackView’s Constraints so it will fill all the superView.

class OTCCodeView: UIView {
    
    var closeKeyboardWhenDone: Bool = true
    
    var startColor: UIColor = .black {
        didSet{
            stackView.arrangedSubviews.compactMap{ $0 as? OTCDigitView }.forEach {
                $0.startColor = startColor
                $0.updateLayers()
            }
        }
    }
    
    var endColor: UIColor = .black {
        didSet{
            stackView.arrangedSubviews.compactMap{ $0 as? OTCDigitView }.forEach {
                $0.endColor = endColor
                $0.updateLayers()
            }
        }
    }
    
    var numberOfFields: Int = 4 {
        didSet {
            if oldValue != numberOfFields {
                addFieldsToStackView()
                textField.maxLenght = numberOfFields
            }
        }
    }
    
    private var stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = 10
        stackView.distribution = .fillEqually
        return stackView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }
    
    private func setup() {
        setupStackView()        
    }
    
    private func setupStackView() {
        self.addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        let constraints = [
            stackView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
            stackView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
            stackView.widthAnchor.constraint(equalTo: self.widthAnchor),
            stackView.heightAnchor.constraint(equalTo: self.heightAnchor)
        ]
        self.addConstraints(constraints)
        NSLayoutConstraint.activate(constraints)
    }
}

Creating our digits

Let’s create a View to be our digits.

Start by extending a UIView. We’ll add the Layers that will compose the fields where the digits will be presented in those view.

In this case, we need 4 Layers. Let’s understand better why. Two of our layers will be the gradient background. We will use the other two to outline the text box and one for the text, using the mask property of our background layers.

The big deal here is the mask property of our Layer. All non-transparent content of the layer that will be our mask will be displayed on our background layer. See in the image below how this works:

#blindPeople: the image shows three circles. The first is the background layer, with a gradient. The second is the mask layer, with a black ring shape . The third is the mask applied to the background, with a gradient ring format, showing that the content of the background is displayed only where it’s overlaid by the mask layer.

Our background layers will be CAGradientLayer type, so we may create a gradient layer in a simple and quick way. We just need to set the colors that we want to use in the colors array of our layers.

backLayer.frame = CGRect(x: 0.0, y: 0.0,
                         width: self.frame.width,
                         height: self.frame.height)
backLayer.colors = [startColor.cgColor, endColor.cgColor]
backTextLayer.frame = CGRect(x: 0.0, y: 0.0,
                             width: self.frame.width,
                             height: self.frame.height)
backTextLayer.colors = [startColor.cgColor, endColor.cgColor]

Let’s move to our mask layers. First, we’re going to create a mask that will be our field border. So we’re going to create a layer, with bordWidth as the width of our border, cornerRadius as the radius of the curve of our vertexes and borderColor set as black, to be our mask. 

One important point here is to remember to set contentScale equals to our screen(UIScreen) scale. That will grant that our content will have the best proportionate be shown in screen (1x, 2x (Retina), 3x (Super Retina)) and not get blurred.

maskLayer.frame = CGRect(x: 1.0,
                         y: 1.0,
                         width: backLayer.frame.width - 2.0,
                         height: backLayer.frame.height - 2.0)
maskLayer.backgroundColor = UIColor.clear.cgColor
maskLayer.cornerRadius = 8
maskLayer.borderColor = UIColor.black.cgColor
maskLayer.borderWidth = 1
maskLayer.contentsScale = UIScreen.main.scale

Next, we’re going to prepare our number’s Layer. So we will use a CATextLayer. The CATextLayer allows us to use text as a layer. In that layer, we must set fontSize as the size of our font; the font to be used (font) and, of course, the string to be shown. The other parameters should be the same as our other mask layer.

maskTextLayer.frame = CGRect(x: 0.0, y: (self.frame.height - 20.0) / 2.0,
                             width: self.frame.width,
                             height: self.frame.height)
         maskTextLayer.fontSize = 17.0
         maskTextLayer.font =
             CTFontCreateWithName(UIFont.systemFont(ofSize: UIFont.systemFontSize).fontName as CFString, 17.0, nil)
         maskTextLayer.string = value
         maskTextLayer.contentsScale = UIScreen.main.scale
         maskTextLayer.foregroundColor = UIColor.black.cgColor
         maskTextLayer.alignmentMode = .center

At last, we’re going to set our masks to their respective backgrouds.

backLayer.mask = maskLayer
backTextLayer.mask = maskTextLayer

To grant that our layers will be always updated, with their right sizes and shapes, regardless device’s orientation changing, we’re going to update them in method layoutSubviews of our View.

override func layoutSubviews() {
    updateLayers()
}

Our final class will be pretty similar to that:

class OTCDigitView: UIView {
    
    private let backLayer: CAGradientLayer = CAGradientLayer()
    private let maskLayer = CALayer()
    
    private let backTextLayer: CAGradientLayer = CAGradientLayer()
    private let maskTextLayer = CATextLayer()
    
    var isEditing: Bool = false {
        didSet {
            updateLayers()
        }
    }
    
    var value: String = "" {
        didSet {
            updateLayers()
        }
    }
    
    var startColor: UIColor = .black {
        didSet{
            updateLayers()
        }
    }
    
    var endColor: UIColor = .black {
        didSet{
            updateLayers()
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.layer.addSublayer(backLayer)
        self.layer.addSublayer(backTextLayer)
        updateLayers()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    override func layoutSubviews() {
        updateLayers()
    }
    
    func updateLayers() {
        backLayer.frame = CGRect(x: 0.0, y: 0.0,
                                 width: self.frame.width,
                                 height: self.frame.height)
        
        if !value.isEmpty || isEditing {
            backLayer.colors = [startColor.cgColor, endColor.cgColor]
        } else {
            backLayer.colors = [UIColor.lightGray.cgColor, UIColor.lightGray.cgColor]
        }
        
        maskLayer.frame = CGRect(x: 1.0,
                                 y: 1.0,
                                 width: backLayer.frame.width - 2.0,
                                 height: backLayer.frame.height - 2.0)
        maskLayer.backgroundColor = UIColor.clear.cgColor
        maskLayer.cornerRadius = 8
        maskLayer.borderColor = UIColor.black.cgColor
        maskLayer.borderWidth = 1
        maskLayer.contentsScale = UIScreen.main.scale
        
        backLayer.mask = maskLayer
        
        backTextLayer.frame = CGRect(x: 0.0, y: 0.0,
                                 width: self.frame.width,
                                 height: self.frame.height)
        
        backTextLayer.colors = [startColor.cgColor, endColor.cgColor]
        
        
        maskTextLayer.frame = CGRect(x: 0.0, y: (self.frame.height - 20.0) / 2.0,
                            width: self.frame.width,
                            height: self.frame.height)
        maskTextLayer.fontSize = 17.0
        maskTextLayer.font =
            CTFontCreateWithName(UIFont.systemFont(ofSize: UIFont.systemFontSize).fontName as CFString, 17.0, nil)
        maskTextLayer.string = value
        maskTextLayer.contentsScale = UIScreen.main.scale
        maskTextLayer.foregroundColor = UIColor.black.cgColor
        maskTextLayer.alignmentMode = .center
        
        backTextLayer.mask = maskTextLayer
        
    }
    
}

The big deal: UITextField

The big deal about our solution resides in UITextField. We’re going to use a UITextField overlapped to our display views, so it can be user’s real input.

But why a UITextField? We have some reasons to consider:

  • We will treat the One Time Code as a single field
  • VoiceOver users will have their text field concepts preserved, such as the sounds the keyboard makes when typing or deleting a character.
  • In iOS 12, we can use the One Time Code Auto Fill feature, which allows the user to input the code directly from the received SMS.

The One Time Code Auto-Fill feature is only present on iOS 12 and later. In Apple’s documentation, it’s clearly that only UIKit components can receive the SMS code.

If you use a custom input view for a security code input text field, iOS cannot display the necessary AutoFill UI.

https://developer.apple.com/documentation/security/password_autofill/enabling_password_autofill_on_a_text_input_view

Let’s prepare our UITextField to “NOT” show our code. When we create our Field, we’re going to let all visual elements set to “clear, as the code bellows.

self.borderStyle = .none
self.backgroundColor = .clear
self.textColor = .clear
self.text = ""
self.tintColor = .clear

To completely hidden our text indicator of the Field, we’re going to override the method caretRect.

override func caretRect(for position: UITextPosition) -> CGRect {
    return .zero
}

Let’s prepare our keyboard. We’re going to set the keyboardType property to numberPad and, if iOS 12 is available, set textContentType to oneTimeCode.

self.keyboardType = .numberPad
if #available(iOS 12.0, *) {
    self.textContentType = .oneTimeCode
}

Another important point of our TextField class will be how we deal with inputed text. to deal with that, our class will also extends the UITextFieldDelegate, allowing us to manipulate what the user is “typing” in our field.

Talking about Delegates, let’s create a Protocol to be our delegate, and inform our main View about the events that we want to map in our TextField. In general, we’ll need 3 methods: one to notify the text change, one to notify that the field has been edited, and another one to notify that the editing is over.

protocol OTCTextFieldDelegate: AnyObject {
    func update(text: String)
    func beginEditing(at index:Int)
    func endEditing()
}

We’ll implement the shouldChangeCharactersIn method of the UITextFieldDelegate, so we can handle the inputs. In this way, our method will check if the input’s resulting string is an addition or an exclusion operation, then we update our fields.

But, why? In our case, there would be no real need for this distinction, but there is a case where this approach is valid. Think about the safe text field, where characters are replaced by symbols and VoiceOver does not read those characters.

When you need to transform it into a secureText, we face a particular iOS behaviour. By default, when we lose focus on a safe text field and then resume it, deleting a character, TextField erases all characters. To avoid this situation, we will keep track of deleting characters from TextField and leave the addition to it to handle.

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        let newText = NSString(string: textField.text ?? "").replacingCharacters(in: range, with: string)
        if newText.count < (textField.text ?? "").count {
            textField.text = newText
            contentDelegate?.update(text: newText)
            return false
        } else {
            if newText.count <= maxLenght && !(string.filter{"0123456789".contains($0)}.isEmpty) {
                contentDelegate?.update(text: newText)
                return true
            }
        }
        return false
    }

Did you notice an extra check we did on our addition?

string.filter{"0123456789".contains($0)}.isEmpty

This check will allow us to enter only numbers in the textField, preventing other characters from being entered, crashing our OTC.

Our final class will be like this:

protocol OTCTextFieldDelegate: AnyObject {
    func update(text: String)
    func beginEditing(at index:Int)
    func endEditing()
}

class OTCTextField: UITextField, UITextFieldDelegate {
    
    var maxLenght: Int = 4
    
    weak var contentDelegate: OTCTextFieldDelegate?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.borderStyle = .none
        self.backgroundColor = .clear
        self.textColor = .clear
        self.text = ""
        self.keyboardType = .numberPad
        if #available(iOS 12.0, *) {
            self.textContentType = .oneTimeCode
        }
        self.tintColor = .clear
        self.delegate = self
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    override func caretRect(for position: UITextPosition) -> CGRect {
        return .zero
    }
    
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        let newText = NSString(string: textField.text ?? "").replacingCharacters(in: range, with: string)
        if newText.count < (textField.text ?? "").count {
            textField.text = newText
            contentDelegate?.update(text: newText)
            return false
        } else {
            if newText.count <= maxLenght && !(string.filter{"0123456789".contains($0)}.isEmpty) {
                contentDelegate?.update(text: newText)
                return true
            }
        }
        return false
    }
    
    func textFieldDidBeginEditing(_ textField: UITextField) {
        contentDelegate?.beginEditing(at: textField.text?.count ?? 0)
    }
    
    func textFieldDidEndEditing(_ textField: UITextField) {
        contentDelegate?.endEditing()
    }
    
}

Let’s put all together

Having created our classes, it’s time to connect everything. We will create some attributes that will allow us to customize our view. They are:

  • closeKeyboardWhenDone: will inform the class if we want to close the keyboard when the user fills in all the characters.
  • startColor: the start color of the gradient that will be displayed in the field outline.
  • endColor: the end color of the gradient that will be displayed in the field outline.
  • numberOfFields: the number of digits of our code.

To be used with Interface Builder, we will mark our class as IBDesignable and our attributes as IBInspectable.

In addition, we will implement didSet in our properties so that we can replicate the values for our subViews when necessary.

@IBDesignable class OTCCodeView: UIView {
    
    @IBInspectable var closeKeyboardWhenDone: Bool = true
    
    @IBInspectable var startColor: UIColor = .black {
        didSet{
            //Repassar valores
        }
    }
    
    @IBInspectable var endColor: UIColor = .black {
        didSet{
            //Repassar valores
        }
    }
    
    @IBInspectable var numberOfFields: Int = 4 {
        didSet {
            //Repassar valores
        }
    }

.
.
.

One thing that we must not forget when connecting our views, is that we need to block the user from clicking directly on the UITextField, because we want him to make the input only at the end of the TextField. To archive this, we will place a transparent View over the entire component, and address its touchUpInside method to the becomeFirstResponder method of our TextField, making it active, always with the input at the end of the string.

    private var blockView: UIView = {
        let blockView = UIView()
        blockView.backgroundColor = .clear
        return blockView
    }()

    private func setupBlockView() {
        
    blockView.addGestureRecognizer(UITapGestureRecognizer.init(target: textField, action: #selector(becomeFirstResponder)))
        
        self.addSubview(blockView)
        blockView.translatesAutoresizingMaskIntoConstraints = false
        let constraints = [
            blockView.centerXAnchor.constraint(equalTo: textField.centerXAnchor),
            blockView.centerYAnchor.constraint(equalTo: textField.centerYAnchor),
            blockView.widthAnchor.constraint(equalTo: textField.widthAnchor),
            blockView.heightAnchor.constraint(equalTo: textField.heightAnchor)
        ]
        self.addConstraints(constraints)
        NSLayoutConstraint.activate(constraints)
    }

Our final solution

The main points of our solution were dealt with, and the concepts involved were addressed, now we go into the details. See how our final solution will look like:

//
//  OneTimeCodeView.swift
//  OneTimeCode
//
//  Created by Andre Luiz Salla on 28/05/2019.
//  Copyright © 2019 Andre Luiz Salla. All rights reserved.
//

import Foundation
import UIKit

@IBDesignable class OTCCodeView: UIView {
    
    @IBInspectable var closeKeyboardWhenDone: Bool = true
    
    @IBInspectable var startColor: UIColor = .black {
        didSet{
            stackView.arrangedSubviews.compactMap{ $0 as? OTCDigitView }.forEach {
                $0.startColor = startColor
                $0.updateLayers()
            }
        }
    }
    
    @IBInspectable var endColor: UIColor = .black {
        didSet{
            stackView.arrangedSubviews.compactMap{ $0 as? OTCDigitView }.forEach {
                $0.endColor = endColor
                $0.updateLayers()
            }
        }
    }
    
    @IBInspectable var numberOfFields: Int = 4 {
        didSet {
            if oldValue != numberOfFields {
                addFieldsToStackView()
                textField.maxLenght = numberOfFields
            }
        }
    }
    
    private var stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = 10
        stackView.distribution = .fillEqually
        return stackView
    }()
    
    private var textField: OTCTextField = OTCTextField(frame: .zero)
    
    private var blockView: UIView = {
        let blockView = UIView()
        blockView.backgroundColor = .clear
        return blockView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
        addFieldsToStackView()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
        addFieldsToStackView()
    }
    
    private func setup() {
        
        setupStackView()
        setupTextField()
        setupBlockView()
        
    }
    
    private func setupStackView() {
        self.addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        let constraints = [
            stackView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
            stackView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
            stackView.widthAnchor.constraint(equalTo: self.widthAnchor),
            stackView.heightAnchor.constraint(equalTo: self.heightAnchor)
        ]
        self.addConstraints(constraints)
        NSLayoutConstraint.activate(constraints)
    }
    
    private func setupTextField() {
        textField.contentDelegate = self
        
        self.addSubview(textField)
        textField.translatesAutoresizingMaskIntoConstraints = false
        let constraintsText = [
            textField.centerXAnchor.constraint(equalTo: self.centerXAnchor),
            textField.centerYAnchor.constraint(equalTo: self.centerYAnchor),
            textField.widthAnchor.constraint(equalTo: self.widthAnchor),
            textField.heightAnchor.constraint(equalTo: self.heightAnchor)
        ]
        self.addConstraints(constraintsText)
        NSLayoutConstraint.activate(constraintsText)
    }
    
    private func addFieldsToStackView() {
        let views = stackView.arrangedSubviews
        views.forEach { stackView.removeArrangedSubview($0) }
        if numberOfFields > 0 {
            for _ in 0..<numberOfFields {
                let digit = OTCDigitView(frame: .zero)
                digit.startColor = startColor
                digit.endColor = endColor
                stackView.addArrangedSubview(digit)
            }
        }
    }
    
    private func setupBlockView() {
        blockView.addGestureRecognizer(UITapGestureRecognizer.init(target: textField, action: #selector(becomeFirstResponder)))
        
        self.addSubview(blockView)
        blockView.translatesAutoresizingMaskIntoConstraints = false
        let constraints = [
            blockView.centerXAnchor.constraint(equalTo: textField.centerXAnchor),
            blockView.centerYAnchor.constraint(equalTo: textField.centerYAnchor),
            blockView.widthAnchor.constraint(equalTo: textField.widthAnchor),
            blockView.heightAnchor.constraint(equalTo: textField.heightAnchor)
        ]
        self.addConstraints(constraints)
        NSLayoutConstraint.activate(constraints)
    }
    
}

extension OTCCodeView: OTCTextFieldDelegate {
    
    func beginEditing(at index: Int) {
        stackView.arrangedSubviews.compactMap{ $0 as? OTCDigitView }.forEach { $0.isEditing = false }
        if index < stackView.arrangedSubviews.count,
            let digitView = stackView.arrangedSubviews[index] as? OTCDigitView {
            digitView.isEditing = true
        }
    }
    
    func endEditing() {
        stackView.arrangedSubviews.compactMap{ $0 as? OTCDigitView }.forEach { $0.isEditing = false }
    }
    
    
    func update(text: String) {
        let characters = text.compactMap{ String($0) }
        guard stackView.arrangedSubviews.count > 0 else { return }
        var willEdit = true
        for counter in 0...stackView.arrangedSubviews.count - 1 {
            if let digitView = stackView.arrangedSubviews[counter] as? OTCDigitView {
                if counter < characters.count {
                    digitView.value = characters[counter]
                } else {
                    if willEdit {
                        digitView.isEditing = true
                        willEdit = false
                    } else {
                        digitView.isEditing = false
                    }
                    digitView.value = ""
                }
            }
        }
        if closeKeyboardWhenDone && characters.count == numberOfFields {
            DispatchQueue.main.async {
                self.endEditing(true)
            }
        }
    }
    
}

class OTCDigitView: UIView {
    
    private let backLayer: CAGradientLayer = CAGradientLayer()
    private let maskLayer = CALayer()
    
    private let backTextLayer: CAGradientLayer = CAGradientLayer()
    private let maskTextLayer = CATextLayer()
    
    var isEditing: Bool = false {
        didSet {
            updateLayers()
        }
    }
    
    var value: String = "" {
        didSet {
            updateLayers()
        }
    }
    
    var startColor: UIColor = .black {
        didSet{
            updateLayers()
        }
    }
    
    var endColor: UIColor = .black {
        didSet{
            updateLayers()
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.layer.addSublayer(backLayer)
        self.layer.addSublayer(backTextLayer)
        updateLayers()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    override func layoutSubviews() {
        updateLayers()
    }
    
    func updateLayers() {
        backLayer.frame = CGRect(x: 0.0, y: 0.0,
                                 width: self.frame.width,
                                 height: self.frame.height)
        
        if !value.isEmpty || isEditing {
            backLayer.colors = [startColor.cgColor, endColor.cgColor]
        } else {
            backLayer.colors = [UIColor.lightGray.cgColor, UIColor.lightGray.cgColor]
        }
        
        maskLayer.frame = CGRect(x: 1.0,
                                 y: 1.0,
                                 width: backLayer.frame.width - 2.0,
                                 height: backLayer.frame.height - 2.0)
        maskLayer.backgroundColor = UIColor.clear.cgColor
        maskLayer.cornerRadius = 8
        maskLayer.borderColor = UIColor.black.cgColor
        maskLayer.borderWidth = 1
        maskLayer.contentsScale = UIScreen.main.scale
        
        backLayer.mask = maskLayer
        
        backTextLayer.frame = CGRect(x: 0.0, y: 0.0,
                                     width: self.frame.width,
                                     height: self.frame.height)
        
        backTextLayer.colors = [startColor.cgColor, endColor.cgColor]
        
        
        maskTextLayer.frame = CGRect(x: 0.0, y: (self.frame.height - 20.0) / 2.0,
                                     width: self.frame.width,
                                     height: self.frame.height)
        maskTextLayer.fontSize = 17.0
        maskTextLayer.font =
            CTFontCreateWithName(UIFont.systemFont(ofSize: UIFont.systemFontSize).fontName as CFString, 17.0, nil)
        maskTextLayer.string = value
        maskTextLayer.contentsScale = UIScreen.main.scale
        maskTextLayer.foregroundColor = UIColor.black.cgColor
        maskTextLayer.alignmentMode = .center
        
        backTextLayer.mask = maskTextLayer
        
    }
    
}

protocol OTCTextFieldDelegate: AnyObject {
    func update(text: String)
    func beginEditing(at index:Int)
    func endEditing()
}

class OTCTextField: UITextField, UITextFieldDelegate {
    
    var maxLenght: Int = 4
    
    weak var contentDelegate: OTCTextFieldDelegate?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.borderStyle = .none
        self.backgroundColor = .clear
        self.textColor = .clear
        self.text = ""
        self.keyboardType = .numberPad
        self.tintColor = .clear
        if #available(iOS 12.0, *) {
            self.textContentType = .oneTimeCode
        }
        self.delegate = self
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    override func caretRect(for position: UITextPosition) -> CGRect {
        return .zero
    }
    
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        let newText = NSString(string: textField.text ?? "").replacingCharacters(in: range, with: string)
        if newText.count < (textField.text ?? "").count {
            textField.text = newText
            contentDelegate?.update(text: newText)
            return false
        } else {
            if newText.count <= maxLenght && !(string.filter{"0123456789".contains($0)}.isEmpty) {
                contentDelegate?.update(text: newText)
                return true
            }
        }
        return false
    }
    
    func textFieldDidBeginEditing(_ textField: UITextField) {
        contentDelegate?.beginEditing(at: textField.text?.count ?? 0)
    }
    
    func textFieldDidEndEditing(_ textField: UITextField) {
        contentDelegate?.endEditing()
    }
    
}

iOS does its role, but you may do yours too

IOS will handle VoiceOver for you, there is no need to make any other adjustments for the reading to be done correctly.

However, it is always worth remembering that we can improve the user experience when using VoiceOver, adding Hints to our fields, or improving their Accessibility Labels.

When talking about Hints, it’s important to remember that not all VoiceOver users have Hints activated. More experienced users tend to disable this option to shorten what is said by the reader. Be careful not to put any important information in this field.

I hope this tutorial was helpful, and that I managed to be as clear as possible; but if there was any doubt or suggestion, just say it !! Sharing ideas and knowledge is always a pleasure to me. 😁