iOSでPIN入力とかSMS入力とかどう作ろうかという話
本記事はRecruit Engineers Advent Calendar 2019 - Adventar 15日目の記事です。
アプリをいじってるとまま遭遇する桁数指定の入力画面。
標準のViewだとこういうViewは提供されていない。
では、どうやって実現すればいいのかなぁというのをちょっとやってみる。
これは完全に予防線なんだけど、やってみてるだけなのでベストプラクティスとは基本言えないです。
多分より良いやり方は存在するはずなので、ご存知の方がいたら是非教えていただきたいです。
あくまで、「俺の考えたView」なのであしからず。
PIN入力
一番簡単なのは、OSのキーボードを使わせず、入力ボタンを自前で用意してやることだと思う。
まず、大雑把にUIStackViewを並べる。
次に、入力桁数を示す「●」を上部の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 } }
これで、割と等間隔でそれっぽい見た目が簡単に作れる
下部には各数字とデリートのキーを配置していく
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() }
ただ、このやり方はコピーアンドペーストや、oneTimeCode
などのオートフィルに対応していないという欠点がある。
オートフィルに対応するためには TextInput
プロトコルに準拠しなければならないためだ。
一方、この TextInput
プロトコルはなかなか準拠するために実装しなければならない物が多く、あまり自分でやりたくない。
なので、既存のViewを利用しつつ、コピペやオートフィルに対応したViewを作ってみる。
コピペ対応SMS入力View
まず、UIStackView
と、その上に UITextField
を置いたViewを作る。
指定した桁数分の、数字を表示するラベルを UIStackView
に突っ込ませるようにする。
また、キーボードの設定も numberPad
にしてやる。
textContentType
を oneTimeCode
にすると、認証用コードのテキストフィールドであることを明示できる。
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) }
これでそれっぽい見た目が出来上がる。
あとは、キーボードの入力と見た目を同期させてやる処理を書く。
UITextFieldDelegate
の func 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() }
こうすることで、上部のラベルに入力内容が反映される。
これでコピペにも対応したSMS入力画面ができた!
なお、本当はオートフィルにも対応できてるはずなのだが、なぜか出てこない……。
これについては課題とさせていただきたい。
前に組んだときはできていたので、今回できないのが謎。
ただ、前回組んだときは一番親のViewを UIKeyInput
に準拠させた方法でやっていた気がするので、なんかその辺りにヒントがあるかもしれない。
サンプルコードはこちら github.com