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:
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:
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:
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. 😁