タマネギプログラマーの雑記

たまねぎ剣士的なニュアンス

iOSでPIN入力とかSMS入力とかどう作ろうかという話

本記事はRecruit Engineers Advent Calendar 2019 - Adventar 15日目の記事です。

アプリをいじってるとまま遭遇する桁数指定の入力画面。
標準のViewだとこういうViewは提供されていない。
では、どうやって実現すればいいのかなぁというのをちょっとやってみる。

これは完全に予防線なんだけど、やってみてるだけなのでベストプラクティスとは基本言えないです。
多分より良いやり方は存在するはずなので、ご存知の方がいたら是非教えていただきたいです。
あくまで、「俺の考えたView」なのであしからず。

PIN入力

一番簡単なのは、OSのキーボードを使わせず、入力ボタンを自前で用意してやることだと思う。
まず、大雑把にUIStackViewを並べる。
f:id:gaoxin-xixxix:20191207172619p:plain

次に、入力桁数を示す「●」を上部のUIStackViewに突っ込む

override func viewDidLoad() {
    super.viewDidLoad()

    for _ in 1...disit {
        let indicator = UILabel.circle
        inputIndicators.append(indicator)
        upperStackView.addArrangedSubview(indicator)
    }
}

ちなみに、UILabel.circle はこんな感じで定義した雑なViewである。

fileprivate extension UILabel {
    static var circle: UILabel {
        let uilabel = UILabel()
        uilabel.text = "●"
        uilabel.font =.systemFont(ofSize: 60)
        uilabel.textColor = .gray
        uilabel.textAlignment = .center
        return uilabel
    }
}

これで、割と等間隔でそれっぽい見た目が簡単に作れる f:id:gaoxin-xixxix:20191207213753p:plain

下部には各数字とデリートのキーを配置していく

        let firstLow = makeLow(from: 1, to: 3)
        let secondLow = makeLow(from: 4, to: 6)
        let thirdLow = makeLow(from: 7, to: 9)
        
        let fourthLow = UIStackView.horizontal
        let zeroPad = NumPadLikeUILabel(of: 0)
        zeroPad.registerTap(target: self, action: #selector(numPadDidTapped(_:)))
        let deleteButton = UILabel()
        deleteButton.text = "←"
        deleteButton.bottunify()
        
        let deleteTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(deleteDidTapped))
        deleteTapRecognizer.delegate = self
        deleteButton.addGestureRecognizer(deleteTapRecognizer)
        [zeroPad, deleteButton].forEach { fourthLow.addArrangedSubview($0) }
        
        [firstLow, secondLow, thirdLow, fourthLow].forEach { lowerStackView.addArrangedSubview($0) }

UIGestureRecognizer は、各インスタンスで一つのViewとしか紐付けられないため、各Viewごとにインスタンスを作ってやる必要がある。 そのため、.registerTap というファンクションを実装してそれぞれのViewに自分で作らせるようにすると楽。

NumPadLikeUILabel はタップアクションと数字の紐付けを明確にするため、UILabelを拡張子、そのUILabelが担う数字を保持できるようにしたものである。
こうすることで、ViewController側で各Viewがどの数字のボタンなのかを管理する必要がなくなる。

fileprivate class NumPadLikeUILabel: UILabel {
    let number: Int
    init(of number: Int) {
        self.number = number
        super.init(frame: .zero)
        text = String(number)
        bottunify()
    }

    func registerTap(target: UIGestureRecognizerDelegate, action: Selector) {
        let gestureRecognizer = UITapGestureRecognizer(target: target, action: action)
        gestureRecognizer.cancelsTouchesInView = false
        gestureRecognizer.delegate = target
        addGestureRecognizer(gestureRecognizer)
    }
}

あとは、各タップのアクションごとに入力された数字を取得、それに合わせて●のViewの色を変えてやれば意図したUIが出来上がる。

   @objc func numPadDidTapped(_ sender: UITapGestureRecognizer) {
        guard let pad = sender.view as? NumPadLikeUILabel else {
            return
        }
        if number.count >= digit {
            return
        }
        
        number.append(pad.number)
        syncViewAndNum()
        
        if number.count == digit {
            guard let nextVC = UIStoryboard.init(name: "SMSStoryboard", bundle: nil).instantiateInitialViewController() else {
                return
            }
            present(nextVC, animated: true, completion: nil)
        }
    }
    
    @objc func deleteDidTapped() {
        number.removeLast()
        syncViewAndNum()
     }

f:id:gaoxin-xixxix:20191207192740p:plain
ただ、このやり方はコピーアンドペーストや、oneTimeCode などのオートフィルに対応していないという欠点がある。
オートフィルに対応するためには TextInput プロトコルに準拠しなければならないためだ。
一方、この TextInput プロトコルはなかなか準拠するために実装しなければならない物が多く、あまり自分でやりたくない。
なので、既存のViewを利用しつつ、コピペやオートフィルに対応したViewを作ってみる。

コピペ対応SMS入力View

まず、UIStackView と、その上に UITextField を置いたViewを作る。
f:id:gaoxin-xixxix:20191207221306p:plain
指定した桁数分の、数字を表示するラベルを UIStackView に突っ込ませるようにする。
また、キーボードの設定も numberPad にしてやる。 textContentTypeoneTimeCode にすると、認証用コードのテキストフィールドであることを明示できる。

    func configure(with digit: Int) {
        self.digit = digit
        for _ in 1...digit {
            let (labelView, label) = codeLabel()
            codeLabels.append(label)
            stack.addArrangedSubview(labelView)
        }
    }
    
    private func setup() {
        Bundle.main.loadNibNamed("SMSView", owner: self, options: nil)
        addSubview(contentView)
        contentView.frame = self.bounds
        contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        virtualTextField.tintColor = .clear
        virtualTextField.keyboardType = .numberPad
        virtualTextField.textContentType = .oneTimeCode
        virtualTextField.becomeFirstResponder()
        virtualTextField.delegate = self
        virtualTextField.borderStyle = .none
        virtualTextField.backgroundColor = .clear
    }

    private func codeLabel() -> (UIView, UILabel) {
        let container = UIView()
        let label = UILabel()
        let bottomLine = UIView()
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 40)
        
        container.addSubview(label)
        container.addSubview(bottomLine)
        
        label.translatesAutoresizingMaskIntoConstraints = false
        label.leftAnchor.constraint(equalToSystemSpacingAfter: container.leftAnchor, multiplier: 0).isActive = true
        label.rightAnchor.constraint(equalToSystemSpacingAfter: container.rightAnchor, multiplier: 0).isActive = true
        label.centerYAnchor.constraint(equalTo: container.centerYAnchor).isActive = true
        label.heightAnchor.constraint(equalToConstant: 50).isActive = true
        
        bottomLine.backgroundColor = .lightGray
        bottomLine.translatesAutoresizingMaskIntoConstraints = false
        bottomLine.leftAnchor.constraint(equalTo: container.leftAnchor, constant: 10).isActive = true
        bottomLine.rightAnchor.constraint(equalTo: container.rightAnchor, constant: -10).isActive = true
        bottomLine.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 5).isActive = true
        bottomLine.heightAnchor.constraint(equalToConstant: 2.0).isActive = true
        return (container, label)
    }

これでそれっぽい見た目が出来上がる。
f:id:gaoxin-xixxix:20191207224618p:plain
あとは、キーボードの入力と見た目を同期させてやる処理を書く。
UITextFieldDelegatefunc textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) でキーボードの入力を受け取れるため、

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        if string.isEmpty && !codes.isEmpty {
            codes.removeLast()
            syncView()
            return true
        }
        if codes.count >= digit {
            return false
        }
        if string.count > 1 {
            for char in string.compactMap({ $0 }) {
                guard let i = Int(String(char)), codes.count < digit else {
                    return true
                }
                codes.append(i)
            }
            
        } else {
            guard let code = Int(string) else {
                return false
            }
            codes.append(code)
        }
        syncView()
        return true
    }

このようにロジックを書く。
return値で true を返すとstringの文字が UITextField に反映され、falseを押すと無視される。
ちなみに、デリートキーを押したときには string に空文字が入る。最上部はその処理である。
指定桁数を超えた場合は無視する処理を書き、
コピペやオートフィルの際の処理のため、 string.count > 1 の内部処理を書く。
最後に、Viewと同期させる syncView を呼ぶ。
syncView の中身はこうなっている。

    private func syncView() {
        for (i, label) in codeLabels.enumerated() {
            if codes.count <= i || codes.count == 0 {
                label.text = nil
                return
            }
            label.text = String(codes[i])
        }
        virtualTextField.text = codes.map {String($0)}.joined()
    }

こうすることで、上部のラベルに入力内容が反映される。
f:id:gaoxin-xixxix:20191207224631p:plain
これでコピペにも対応したSMS入力画面ができた!

f:id:gaoxin-xixxix:20191207224709p:plain
コピペにも対応

なお、本当はオートフィルにも対応できてるはずなのだが、なぜか出てこない……。
これについては課題とさせていただきたい。
前に組んだときはできていたので、今回できないのが謎。 ただ、前回組んだときは一番親のViewを UIKeyInput に準拠させた方法でやっていた気がするので、なんかその辺りにヒントがあるかもしれない。

サンプルコードはこちら github.com