Menu Home

Tutorial: Criando uma solução acessível para o One Time Code

O problema

Vou fugir um pouco do clichê de dizer que a acessibilidate é algo super atual, um assunto que está em alta e no foco das grandes empresas. Acessibilidade é algo que tem que ser tomado como básico, e deveria ser critério de aceite de qualquer projeto.

Como eu havia dito no post anterior, hoje eu vou tratar um pouco de como resolver uma situação muito comum em grande parte dos apps que usam um segundo fator de autenticação: a acessibilidade e o One Time Code.

OTC – código de utilização única enviado para o usuário para validar um processo de autenticação ou transação.

Primeiro, vamos analisar um pouco o problema do ponto de vista do usuário que usa o VoiceOver para se orientar na tela e do que não usa.

O VoiceOver é um leitor de tela que descreve exatamente o que está aparecendo no seu iPhone.

Quem usa o VoiceOver não toca diretamente em cima dos componentes da tela. A navegação é feita através de swipes horizontais que indicam para onde o foco do leitor do VoiceOver deve passar (esquerda para direita: próximo; direita pra esquerda: anterior).

Sendo assim, vamos ver o cenário do One Time Code. Um modelo muito comum é o mostrado na imagem abaixo:

#PraCegoVer: a imagem mostra uma tela de One Time Code, com cada um dos seis dígitos que o compões separados em uma caixa de texto.

Imagine a navegação do usuário:

Nove, campo de texto, dê dois toques para editar, Oito, campo de texto, dê dois toques para editar, Cinco, campo de texto, dê dois toques para editar, Três, campo de texto, dê dois toques para editar, Nove, campo de texto, dê dois toques para editar, Um, campo de texto, dê dois toques para editar”

Tudo isso para que o usuário possa navegar no código 985391.

Agora pense em como fica a navegação quando ele navega voltando pelos campos:

Um, campo de texto, dê dois toques para editar, Nove, campo de texto, dê dois toques para editar, Três, campo de texto, dê dois toques para editar, Cinco, campo de texto, dê dois toques para editar, Oito, campo de texto, dê dois toques para editar, Nove, campo de texto, dê dois toques para editar”

Novamente o código é 985391, mas dessa vez ele terá que formar esse código mentalmente de trás pra frente!!! Horrível, não?

Então, pensando na experiência de usuário, nós entendemos que:

O Código de Acesso é um campo de código único.

Vamos então para a solução técnica que melhor atende tanto os requisitos de Interface de Usuário quanto de Experiência de Usuário.

A solução

O primeiro passo aqui é pensar em como estruturar nossa solução.

Vamos pensar como no desenho abaixo:

#PraCegoVer: A imagem mostra um desenho de baixa fidelidade de um componente de One Time Code. Nele, seis views pequenas com números centralizados no seu interior se sobrepões a uma view maior que contém todas as outras.

Criando nossa base

Inicialmente então vamos criar nossa view de One Time Code como uma extensão de UIView.

Para que as views internas possam se redimensionar sozinhas e para que todas tenham o mesmo tamanho, vamos utilizar um objeto que irá facilitar (e muito) nossa vida: o UIStackView.

Para obtermos o resultado que queremos, vamos setar os parâmetros:

distribution = .fillEqually para termos todas as nossas views internas com o mesmo tamanho;

axis = .horizontal para que as views fiquem alinhadas horizontalmente;

spacing = 10 para termos um espaçamento de 10 pontos entre cada view.

Em seguida, vamos adicionar nossa StackView na nossa classe e configurar as Constraints desse StackView para que ele tome toda a 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)
    }
}

Criando nossos dígitos

Vamos criar uma View para ser nossos dígitos.

Comece estendendo uma UIView. Nela iremos adicionar as Layers que vão compor os campos onde os dígitos serão apresentados.

Nesse caso, precisamos de 4 Layers. Vamos entender melhor o porquê. Duas de nossas layers serão o fundo degradê. Usaremos as outras duas para fazer o contorno da caixa de texto e uma para o texto, utilizando a propriedade mask de nossas layers de background.

O segredo aqui é a propriedade mask das nossas Layer. Todo conteúdo não transparente da layer que será nossa mask irá ser exibido na nossa layer de background. Veja na imagem abaixo como isso funciona:

#PraCegoVer: a imagem mostra três círculos. O primeiro é a Layer de background, com um degradê. A segunda é a Layer de máscara, no formato de um anel preto. A terceira é a máscara aplicada ao background, com um formato de anel degradê, mostrando que apenas é exibido o conteúdo do background onde este é sobreposto pela Layer de máscara.

Nossas background layers serão do tipo CAGradientLayer. Com ela, podemos criar layers degradê de um modo simples e rápido. Basta colocarmos as cores que desejamos dentro do array colors das nossas 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]

Em seguida, vamos às nossas mask layers. Primeiro, vamos criar a máscara que será a borda do nosso campo. Para isso, criaremos uma layer, com bordWidth sendo a largura da nossa borda, o cornerRadius sendo o raio da curva dos vértices e o borderColor como preto, para servir de máscara. 

Um ponto importante aqui é lembrarmos de setar o contentScale igual ao scale da nossa tela (UIScreen). Isso garantirá que o conteúdo tenha a proporção ideal para ser exibido na tela: 1x, 2x (Retina), 3x (Super Retina) e não fique desfocado.

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

Em seguida, vamos preparar a Layer do nosso número. Para isso, iremos usar a CATextLayer. Ela permite que utilizemos um texto como Layer. Nela, setamos o fontSize, que é o tamanho da nossa fonte; a fonte a ser utilizada (font) e, lógico, a string a ser exibida. Os demais parâmetros ficam iguais ao da nossa outra layer mask.

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

Por último, vamos atribuir nossa masks à seus respectivos backgrouds.

backLayer.mask = maskLayer
backTextLayer.mask = maskTextLayer

Para garantir que nossas layers estarão sempre atualizadas, com seus tamanhos corretos, independente de mudarmos a orientação do nosso device, vamos atualizá-las no layoutSubviews da nossa View.

override func layoutSubviews() {
    updateLayers()
}

Nossa classe final ficará semelhante à classe abaixo:

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
        
    }
    
}

O pulo do gato: o UITextField

O pulo do gato da nossa solução está no UITextField. Vamos utilizar um UITextField sobreposto às nossas views de exibição para ser o real input do usuário.

Mas por que um UITextField? Temos alguns motivos para considerar:

  • Trataremos o One Time Code como um único campo
  • Os usuários do VoiceOver terão os conceitos do campo de texto preservados, como por exemplo, os sons que o teclado emite ao digitar ou apagar um caractere.
  • No iOS 12, podemos utilizar o recurso do One Time Code Auto Fill, que permite inputar o código direto do SMS recebido.

O recurso do One Time Code Auto-Fill está presente apenas no iOS 12 e posteriores. Na documentação da Apple, fica claro que apenas os componentes do UIKit podem receber o código recebido pelo SMS.

If you use a custom input view for a security code input text field, iOS cannot display the necessary AutoFill UI. (Se você usa uma view de input customizada para um campo de texto de código de segurança, o iOS não pode exibir a Interface de Usuário de AutoFill necessária).

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

Vamos então prepara o nosso UITextField para “não” exibir nosso código. Ao criarmos nosso Field, deixamos todas as características visuais transparentes, como no código abaixo.

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

Para escondermos definitivamente o indicador de ponto de texto do nosso Field, vamos sobrescrever o método caretRect.

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

Vamos também preparar o nosso teclado. Vamos colocar a propriedade keyboardType para numberPad e, se o iOS 12 estiver disponível, o textContentType para oneTimeCode.

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

Outro ponto importante da nossa classe TextField será a tratativa ao texto inputado que iremos dar. Para isso, nossa classe também implementará o Delegate do UITextField, permitindo manipular o que o usuário está “digitando” no campo.

E falando em Delegate, vamos criar um Protocolo para ser nosso delegate, e informar a nossa View principal os eventos que desejamos mapear dentro da nossa TextField. No geral, precisaremos de 3 métodos: um para notificar a alteração do texto, um para notificar que o campo entrou em edição, e outra pra notificar que a edição acabou.

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

Vamos implementar o método shouldChangeCharactersIn do UITextFieldDelegate, para que possamos tratar os inputs. Dessa forma, vamos verificar no nosso método se a string resultante do input é uma adição ou uma exclusão, pra então atualizarmos nossos campos.

Mas por que isso? No nosso caso, não haveria uma necessidade real dessa distinção, mas há um caso em que essa tratativa é válida. Pense no caso do campo de texto seguro, em que os caracteres são substituídos por símbolos e o VoiceOver não realiza a leitura desses caracteres.

Caso haja necessidade de transformá-lo num secureText, enfrentamos uma condição do iOS. Por padrão, ao perdermos o foco de um campo de texto seguro e retomarmos, ao excluir um caractere, o TextField apaga todos os caracteres. Para evitarmos essa situação, iremos manter o controle de apagar caracteres do TextField e deixaremos a adição à cargo dele.

    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
    }

Reparou também numa verificação extra que fizemos na nossa adição?

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

Essa verificação vai nos permitir que apenas números sejam inputados no textField, impedindo que outros caracteres sejam informados, quebrando nosso OTC.

Nossa classe final ficará como a classe abaixo:

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()
    }
    
}

Ligando tudo

Criadas nossas classes, chegou a hora de ligar tudo. Vamos criar alguns atributos que irão permitir customizarmos nossa view. São eles:

  • closeKeyboardWhenDone: irá informar a classe se desejamos fechar o teclado quando o usuário preencher todos os caracteres.
  • startColor: a cor inicial do degradê que sera exibido no contorno dos campos.
  • endColor: a cor final do degradê que sera exibido no contorno dos campos.
  • numberOfFields: a quantidade de dígitos que nosso código terá.

Para que possamos utilizá-lo com o Interface Builder, vamos marcar nossa classe como IBDesignable e nossos atributos como IBInspectable.

Além disso, vamos implementar o didSet de nossas propriedades para que possamos replicar os valores para nossas subViews onde for necessário.

@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
        }
    }

.
.
.

Uma coisa que não podemos esquecer quando estivermos ligando nossas views, é que precisamos bloquear o usuário de clicar diretamente no UITextField, pq desejamos que ele realize o input apenas no final do TextField. Para isso, vamos colocar uma View transparente sobre todo o componente, e endereçar o touchUpInside dela para o becomeFirstResponder do nosso TextField, tornando-o ativo, sempre com o input no final da 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)
    }

Nossa solução final

Os principais pontos da nossa solução foram tratados, e os conceitos envolvidos foram abordados, agora entramos com os pormenores. Veja abaixo como ficará nossa solução final:

//
//  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()
    }
    
}

O iOS faz a sua parte, mas você pode fazer a sua também

O iOS cuidará do VoiceOver para você, não é necessário fazer nenhum outro ajuste para a leitura ser feita da maneira correta.

No entanto, vale sempre a pena lembrar que podemos melhorar a experiência de usuário que faz uso do VoiceOver, adicionando Hints para nossos campos, ou melhorando as Labels de acessibilidade deles.

No caso dos Hints, vale a pena ressaltar que nem todos os usuários do VoiceOver usam os Hints ativos. Usuários mais experientes tendem a desativar essa opção para encurtar o que é dito pelo leitor. Cuidado para não colocar nenhuma informação importante nesse campo.

Espero que esse tutorial tenha sido útil, e que eu tenha conseguido ser o mais claro possível; mas se ficou qualquer dúvida ou sugestão, diz aí!! Compartilhar ideias e conhecimento é sempre um prazer. 😁

Categories: Tutoriais

Tagged as:

André Salla