Kyash Androidアプリのカード情報入力部分のちょっとした改善

KyashでAndroidアプリを開発している@konifarです。

本日からKyashリアルカードの申し込みが始まりました。

prtimes.jp

簡単に説明すると、 今までバーチャルVISAカードでネット決済しかできなかったKyashが、現実(リアル)の店舗で使えるプラスチックカードを配り始めたということです。

このリアルカード申し込みをいただいた方から順に発送していきます。手元に届いたカードの情報をアプリから入力して有効化すると使えるようになります。

f:id:konifar:20180607193555p:plain

今回は、このカード情報を入力する部分の実装について紹介したいと思います。

カメラでの読み取りの廃止

もともとKyashではcard.io-Android-SDKというライブラリを使ってカメラでのカード読み取りを実装していましたが、このライブラリの読み取りの精度はあまりよくありませんでした。

特に、青背景に白字のKyashリアルカードの場合はほぼ100%読み取りに失敗していて、逆にストレスが溜まってしまうのでいっそ外してしまおうということになりました。

カード情報をユーザーに手動で入力してもらうのであれば、せめて気持ちよく入力できるよう改善しておこうということで少しコードに手を入れました*1

カード番号入力と4桁ごとの区切り文字

16桁のカード番号は4桁ごとに区切られているので、次のように入力時も区切って表示した方が確認しやすくなります。

f:id:konifar:20180607170426g:plain

この部分はEditTextを拡張したCustomViewを作って対応しています。

addTextChangedListener() でテキストの変更を検知して、 afterTextChanged() の中で、入力された内容をもとに区切り文字 - をつけていきます。

companion object {
  // 番号と区切り文字を合わせた桁数。0000-0000-0000-0000
  private const val TOTAL_SYMBOLS = 19
  // 番号の桁数。4 * 4 = 16
  private const val TOTAL_DIGITS = 16
  // 区切り文字と数字のブロックの桁数。4 + 1 = 5
  private const val DIVIDER_MODULO = 5
  // 区切り文字の位置のインデックス
  private const val DIVIDER_POSITION
  // 区切り文字
  private const val DIVIDER = '-'
}

...
addTextChangedListener(object : TextWatcher {
  override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}

  override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}

  override fun afterTextChanged(s: Editable) {
    if (!isInputCorrect(s)) {
      s.replace(0, s.length, buildCorrectString(getDigitArray(s)))
    }
  }

  private fun isInputCorrect(s: Editable): Boolean {
    var isCorrect = s.length <= TOTAL_SYMBOLS // check size of entered string
    for (i in 0 until s.length) { // check that every element is right
      isCorrect = if (i > 0 && (i + 1) % DIVIDER_MODULO == 0) {
        isCorrect and (DIVIDER == s[i])
      } else {
        isCorrect and Character.isDigit(s[i])
      }
    }
    return isCorrect
  }

  private fun buildCorrectString(digits: CharArray): String {
    val formatted = StringBuilder()

    for (i in digits.indices) {
      if (digits[i].toInt() != 0) {
        formatted.append(digits[i])
        if (i > 0 && i < digits.size - 1 && (i + 1) % DIVIDER_POSITION == 0) {
          formatted.append(DIVIDER)
        }
      }
    }

    return formatted.toString()
  }

  private fun getDigitArray(s: Editable): CharArray {
    val digits = CharArray(TOTAL_DIGITS)
    var index = 0
    var i = 0
    while (i < s.length && index < TOTAL_DIGITS) {
      val current = s[i]
      if (Character.isDigit(current)) {
        digits[index] = current
        index++
      }
      i++
    }
    return digits
  }
})

見てわかるとおり結構ゴリゴリと実装していますが、この実装だと次のように途中でフォーカスを移動して文字を削除した場合でも正しい位置に区切り文字を表示できます。

f:id:konifar:20180607170920g:plain

また、数字以外に区切り文字も入力するので、 inputType には phone を指定しています。

入力後のフォーカス移動

アルカードを有効化する時に必要なカード番号、有効期限、セキュリティコードは全て桁数が決まっているので、所定の桁を入力したら自動的に次のフォームにフォーカスを移動させるようにしています。

f:id:konifar:20180607171242g:plain

KyashではViewModelとViewのつなぎにDataBindingをフル活用していて、フォーカス移動もDataBindingで実装しています。

例として、有効期限の月から年にフォーカスが移る部分のコードを見てみましょう。

<!-- 月 -->
<EditText
  ...
  android:nextFocusDown="@+id/expiry_year"
  android:nextFocusRight="@+id/expiry_year"
  android:onFocusChange="@{viewModel::onMonthFocusChanged}"
  android:onTextChanged="@{viewModel::onMonthChanged}"
  android:text="@={viewModel.expMonth}"
  app:forceRequestFocus="@{viewModel.expMonthFocus}"
  ...
/>

...

<!-- 年 -->
<EditText
  ...
  android:nextFocusDown="@+id/cvv"
  android:nextFocusRight="@+id/cvv"
  android:onFocusChange="@{viewModel::onYearFocusChanged}"
  android:onTextChanged="@{viewModel::onYearChanged}"
  android:text="@={viewModel.expYear}"
  app:forceRequestFocus="@{viewModel.expYearFocus}"
  ...
 />

nextFocusDown nextFocusRight で次のフォーカス先のEditTextのidを指定しています。こうしておくと、キーボードのEnterボタンで次のフォーカスに移動できるようになります。

f:id:konifar:20180607171957p:plain

forceRequestFocus というあまり見覚えのない属性は、Custom BindingAdapterです*2。 booleanを渡すとフォーカスを変更できるようにしています。

@BindingAdapter("forceRequestFocus")
fun EditText.setForceRequestFocus(requestFocus: Boolean) {
    if (requestFocus) {
        requestFocus()
    } else {
        clearFocus()
    }
}

android:text にバインドしている値をViewModelで監視して、フォーカスの値を変更しています。

val expMonth = NonNullObservableField("")
val expYear = NonNullObservableField("")

val expMonthFocus = NonNullObservableField(false)
val expYearFocus = NonNullObservableField(false)
...

init {
  expMonth.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
    override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
      ...
      // 2桁入力されていたらフォーカスを移動する
      if (expMonth.get().length == 2) {
        expMonthFocus.set(false)
        expYearFocus.set(true)
      }
    }
  })
  ...

DataBindingで実装することで、フォーカス移動もViewModelのユニットテストとして書くことができます。

また、最後のセキュリティコードを入力し終わった時はフォーカス移動ではなく送信処理が走るようにしています。

月のゼロ埋め

有効期限の月に 2 と入力した時は 023 と入力した時は 03のようにゼロ埋めされるようにしています。ここもDataBindingを使ってViewModelの中で変更しています。

f:id:konifar:20180607180224g:plain

expMonth.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
  override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
    val month = expMonth.get()
    if (month.length == 1) {
      // 0と1以外のときはゼロ埋め
      if (!month.startsWith("0") && !month.startsWith("1")) {
        expMonth.set("0$month")
      }
    }
    ...

月の最小・最大入力値の制限

月は1〜12以外は入力できないように制御しています。入力後にバリデーションで弾くよりも入力できないようにしてしまった方が親切なので、ここは次のようなInputFilterをEditTextにセットすることで実装しています。

class MinMaxInputFilter(
        val min: Int = 0,
        val max: Int = 0
) : InputFilter {

    override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? {
        try {
            val input = "$dest$source".toInt()
            if (isInRange(min, max, input)) {
                return null
            }
        } catch (ignore: NumberFormatException) {
            //
        }

        return ""
    }

    private fun isInRange(min: Int, max: Int, input: Int): Boolean {
        return if (max > min) {
            input in min..max
        } else {
            input in max..min
        }
    }
}
binding.expiryMonth.filters = arrayOf(MinMaxInputFilter(1, 12))

コンストラクタに最小値、最大値を渡して、入力された数字がその範囲内でなければ入力されません。

ちなみにこのInputFilterもDataBindingでセットできるようにしてもいいのですが、今はできていません。


DataBindingの多用に対しては賛否両論あると思いますが、実際にコードを書いている身としては特に問題なくViewとロジックを分離できていい感じです。

他にも、入力された番号をリアルタイムで表示したり、カメラによるカード読み取りにリベンジしたりできるといいですね。このサンプルのように、実際のカードを埋めていくようなレイアウトにするのも面白いかもしれません*3

今後もこういうちょっとした改善を続けていこうと思います。リアルカードを申し込んだAndroidユーザーの方は、有効化する時にこの記事の内容を思い出しながら入力してみると楽しいかもしれません。

*1:iOSではCaishenというライブラリを使っていて、すでに同じような挙動が実現できていました。 https://github.com/prolificinteractive/Caishen

*2:BindingAdapterは、EditTextExt.ktのようにViewごとにクラスを分けて管理しています。

*3:このサンプルのコード自体は走り書きっぽいのであまり参考にしない方がいいかもしれません