🌟

iOSアプリでユーザー体験の良いSMSコード入力画面を目指す

2023/03/31に公開

2要素認証/多要素認証に対応しまして、そのときSMSコード入力画面って意外と知見ないなと思ったので書きます。
セキュリティの話は扱わず、純粋にiOSアプリの画面をつくる話になります。
コードはUIKitベースです。

SMSコードだけじゃなく、Google Authenticatorなどの認証アプリ使った連携にも応用できると思います。

サンプルアプリ

こんな画面です。

最低限で動くようにしたサンプルアプリのリポジトリつくったので、必要な方は手元で動かしてください。

https://github.com/0si43/SmsCodeInputSample

問題意識

SMSコードは6文字の数字で送信されてきます。
ユーザーはその6文字を手入力するか、iOSの補完を使うか、コピペするかなどで入力して、認証を通します。
実装するにあたって、「よくある入力画面」をつくろうとしたんですが、実際アプリによって挙動は細かく違うことに気づきました。
列挙すると、

  • 1つの入力欄か、6文字ごとに分割されているか
  • 数字以外が入力できるか
  • 6文字入力したときに自動で送信になるか
  • コピペが効くか

などです。

正直ミニマムでやるなら、入力欄1つにして、そんなにバリデーションも厳しくなくても許されはするかと思います。
が、認証系の画面は一度つくると、そのリスク故に、リファクタしづらいというのは経験上あったので、それなりにちゃんとつくろうと思いました。

理想のSMSコード入力画面を考えた

自分がユーザーとしてアプリを使ってるときに、どういう画面が良かったのかを思い返してみました。

  • 6文字ごとに分割されている
  • 数字しか入力できない
  • 1字入れたらカーソルが次に移る
  • 間違えたときに削除すると前の入力欄に戻る
  • 6文字入力したときに自動で送信になる
  • SMS受信したら補完が効く
  • コピペが効く

こんな要件が思い浮かびました。
実装的には全然マストではないんですが、↑これが全部できてると体験がいいですよね?
最終的にはここに箇条書きにした要件は、全部実現できましたので、1つ1つどう実装したか書いていきます。

コードについては抜粋して説明するので、わかりづらい場合はサンプルプロジェクトをcloneして手元で見ていただけたらと思います。

6文字ごとに分割されている

iOSアプリで、SMSを入力する画面は、よく6文字で分割されてるのを見たことがあるので、
なんかそれ用の仕組みがあるのかと思ってましたが、公式SDKみるとそんなことはなく、
(なんかOSS入れればできるのかもですが)
自前でやるのならUITextFieldを6個生成して、横方向のUIStackViewに突っこんでいくことになるかと思います。

今回のサンプルでは、ちょっとやりたいことがあって、UITextFieldを継承したTextFieldというカスタムクラスをつくってます。

private lazy var horizontalStackView: UIStackView = {
    let stackView = UIStackView()
    // …
    return stackView
}()

private lazy var textFields: [TextField] = (0 ..< 6).map { _ in
    let textField = TextField()
    // …
    return textField
}

private func addTextFields() {
    textFields.forEach { view in
        horizontalStackView.addArrangedSubview(view)
    }
}

数字しか入力できない

色々と入力文字列に関する要件があるんですが、下記のUITextFieldのDelegateメソッドの中でハンドリングします。

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool

公式ドキュメント

このDelegateメソッドは、UITextFieldの中に何らかの入力があったときに発火します。
今回のサンプルだとtextFieldが6つあるので、まず何番目の入力欄かを検査した後で、入力された文字をハンドリングしていきます。

このDelegateメソッドは、trueを返すと文字列が更新されて、falseを返すと入力を弾くことができます。
なので、デフォルトをfalse返すようにして、下記のメソッドで数字を返ってきた場合だけ更新に行くようにします。

public func firstNumber(_ input: String) -> Character? {
    input.filter { "0123456789".contains($0) }.first
}

return trueで更新、と書いたんですが、その前にview.endEditing(true)とか呼んじゃうと、true返しても更新されないっぽいので、
その対策としてtextField.text = String(number)で更新してます。

数字の判定メソッド

数字の判定を"0123456789"を使ってやっています。
Character型にisNumberというプロパティがあるんですが、
これは漢数字やローマ数字など、開発者としては数字判定して欲しくないものも数字扱いする仕様でした。

https://developer.apple.com/documentation/swift/character/isnumber

厳密に言えば数字判定が正しいので、バグではないんですが、望んでた動きではありませんでした。

他にも、Int型に変換して、変換失敗したら数字じゃないという判定もできますが、
今回の処理だとString->Int->Stringという型変換になるので、ちょっと微妙でした。

キーボードを数字のみにする

textField.keyboardType = .numberPad を指定すると、電話番号入力用のキーボードになるから、インプットは数字のみになる、と思ってたんですが、
iPadだとどうも普通にキーボードになる上に、iPhoneでもコピペ使えば数字以外を入力できるので、何らかの対策が必要です。

1字入れたらカーソルが次に移る

カーソル移動ですが、Delegateメソッド内の↓のロジックでやってます。

// 1つ次の入力欄に移動
if index < textFields.count - 1 {
    textField.text = String(number)
    textFields[index + 1].becomeFirstResponder()
// 6文字目だったらSMSコード送信
} else {
    // …
}

becomeFirstResponder()を使うと、カーソル移動できます。

間違えたときに削除すると前の入力欄に戻る

削除したときの動きも、Delegateメソッド内でハンドリングしてもいいんですが、ちょっと大変でした。
削除ボタンを押したときの入力値(特殊文字)を判定して、カーソルを戻すという処理になります。
詳細は、

https://stackoverflow.com/questions/29504304/detect-backspace-event-in-uitextfield

↑この辺を読んでください。
特殊文字の判定なんでちょっと嫌なのと、カーソル1文字送りしてるところと混ぜると条件が複雑化するのが嫌だなと思ったんですが、この2つは致命的ではありませんでした。

致命的な問題として、文字欄が空欄のとき、削除ボタンを押してもDelegateメソッドが呼ばれませんでした。
これ結構悩みましたが、下記の記事と出会いました。

https://culumn.hatenablog.com/entry/2018/02/23/012659

たぶんこれが一番楽だと思います。
deleteBackward()をoverrideして、ここで削除アクションを指定できるようにします。

final class TextField: UITextField {
    public var deletionDelegate: ((TextField) -> Void)?
    override public func deleteBackward() {
        super.deleteBackward()
        deletionDelegate?(self)
    }
}

これがやりたいので、UITextFieldのカスタムクラスをつくっています。

private func deleteAction(_ textField: TextField) {
    guard let index = textFields.firstIndex(of: textField) else { return }
    if index > 0 {
        textFields[index - 1].becomeFirstResponder()
    }
}

こんなメソッドをTextFielddeletionDelegateに指定して、削除時の1字戻しを実現しています。

6文字入力したときに自動で送信になる

6文字目を入力したとき、自動送信するロジックも、textField(_:shouldChangeCharactersIn:replacementString:)の中に仕込みます。

} else if let number = firstNumber(string) {
    if index < textFields.count - 1 {
        // …
    // 6文字目だったらSMSコード送信
    } else {
        textField.text = String(number)
        view.endEditing(true)
        verify()
    }
}

数字1文字の入力が、6文字目の入力欄に来たら、SMSコード送信にいきます。
この時点では6文字目以外がちゃんと入力されているか確認でいてませんが、そこはverify()メソッドの中でやります。

このコンピューテッドプロパティで確認が入ってます。

private var verificationCode: String? {
    let code = textFields.compactMap(\.text).joined()
    guard code.count == 6 else { return nil }
    return code
}

1〜6の入力欄を結合した文字列が6文字だったら値を返して、違ったらnilが返るようにしています。

SMS受信したら補完が効く

iPhoneでSMS受信したときに、補完が効きますよね?

いざ実装しようとなると、これ、そもそも何なんだというところからのスタートでした。
調べると、Appleのパスワード補完について網羅的に書かれたドキュメントにたどり着きました。
「QuickType bar」という名前だそうです。

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

この補完ですが、開発者がコントロールするものではなくて、iOSが上手いことやってる機能でした。
アプリ上で何らかのテキスト入力状態になっているときに、バックグランドでSMSを受信して、
その中にSMSコードが含まれていると、QuickType barを表示する仕様でした。

QuickType barからのインプットは、textField(_:shouldChangeCharactersIn:replacementString:)の中に1文字ずつ入ってきます。
なので、1字入れたら次の入力欄に移動するロジックを実装してしまえば、入力欄の1字目を選んでいた場合、気持ちよく6文字が分割されて入ります。
6文字目の自動送信まで実装できていれば、補完→送信まで一気にいけて気持ちいい動作になります。

補完が失敗するケース

ただ問題があって、この補完を2文字目以降選択した状態で行うと、ズレた状態で補完されます。
これ何とか対応したかったんですが、補完された文字が1文字ずつ入ってくる仕様上、厳しかったです。

普通のユーザーのキーボード入力も同じtextField(_:shouldChangeCharactersIn:replacementString:)の中に入ってくるので、区別ができません。
ここが何とか区別できれば、対処のしようがあったんですが……

コピペが効く

コピペに関しては当初対応しなくてもいいかと思ってたんですが、チーム内であったらいいよねという意見が出て、まあやっぱ対応するかとなりました。

ペーストアクション自体を封じる、という方向もあるかと思います。
個人的にユーザーのアクションを封じるのは、それが危険だから封じる以外はしたくないという気持ちがあります。

正直6桁なんで、手入力させるでもいいっちゃいいんですが、たまーに端末の都合上、コピペしたいときはあります。
(iPadで操作してて、iPhoneでSMS受信したので、ユニバーサルクリップボードでコピペしたい、とか。ごくたまに)

当初対応見送ったのは、判定ロジックがスパゲッティ化しそうな臭いを感じてやめたんですが、↓こんな判定メソッドをつくりました。

private func firstSixNumbers(_ input: String) -> String? {
    let numbers = input.filter { "0123456789".contains($0) }
    guard numbers.count >= 6 else { return nil }
    let sixNumbers = numbers.prefix(6)
    return String(sixNumbers)
}

文字列の中の数字をまず抽出して、そこに6文字以上の数字があったらOK、という判定にしています。
「123456aaaaaaaaa」「1a2a3a4a5a6」みたいな文字列が入ってきてもOKです。
この判定メソッドで返された6文字の数字を、↓で入力欄に入れます。

private func setSixNumbers(numbers: String) {
    guard numbers.count == 6 else { return }
    numbers.enumerated().forEach {
        textFields[$0.offset].text = String($0.element)
    }
}

まとめ

終わりです。
最終的なコード量は短くできたんですが、その中で色々やってます、というのを書きました。
この記事が何かの参考になれば幸いです。

(了)

Discussion