入力制限付きEditTextへのペーストとうまく付き合う

## editTextとコピペの関係
まず前提として、数値6桁制限のあるEditTextがあります。
※以降もこの前提で話を進めます。

``` hoge.xml
android:maxLength="6"
android:inputType="number"
```

この時ユーザ入力が1文字ずつなら問題ないのですが、コピペによる入力をおこなうと想定外の挙動になることがあります。



### 桁数オーバーの数値をペーストするパターン
「1234567」をペースト。
<img width="200dp" alt="Screenshot_1622476514.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/fa877470-9fc4-9213-fcca-b7192503214e.png">


画像の通りですが、あぶれた桁は切り捨てられます。

### 入力されている状態で、桁数オーバーになるパターン
画像左の状態でさらに「1234]をペースト。
<img width="200dp" alt="Screenshot_1622476976.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/c394fd75-5066-0fcc-0b23-9ae65abcbd11.png"> <img width="200dp" alt="Screenshot_1622476980.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/6ea84037-6ac2-190b-6aa6-1e63b9e631b6.png">

7桁以降は切り捨てられます。


### 想定外の文字がペーストされるパターン
「12dd345」をペースト。
<img width="200dp" alt="Screenshot_1622477309" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/33fe7bdc-524c-1d88-b1bb-e607b60377f7.png">

切り捨て後に文字のみ削除されます。


###　上記の問題点？

・iOSとの挙動が合わなくなる。※前提のみの条件で自然に実装した場合。
（iOSの場合、いずれの場合もOS標準でペースト不能。）

・ユーザの期待通りでない可能性が高い
（3つ目はまさにこれ）

個人的にiOSと完璧に挙動を合わせるのはナンセンスに感じます。
OSから見て不自然な動きを無理に作るとバグが出やすくなるためです。それゆえこちらはある程度許容したほうが良いように思えます。

ということで、「ユーザの期待通りの挙動」を目標とし、実装を考えたいと思います。

##　実装パターン

本記事では、PayPay・Zaimを参考にAndroidにおけるペーストとの向き合い方を考えます。
全コードは[こちら](https://github.com/kkhouse/EditTextSamples)
##### ※注意点
・アプリの動きから実装を考えているため、実際の実装とは異なる部分が多いかと思います。
。iOSの動きに関しては、実際に試せる環境がないため調べたことを書いています。
・ややこしくなるので、カンマ処理は省きます。
・動きを再現できればよいレベルの実装なため、判定条件は最小限・コードも荒いです。

### PayPayの動きを実装してみる。
PayPayの入力エリアの特徴は下記になります。

- 有効桁数6桁だがユーザは7桁入力可能。
- 7桁目および0桁目の時はOkボタンを非活性化し次画面への導線を削除。
- ペーストはOS依存のため前述のペースト仕様は変更していない。だが、上記仕様により6桁までの入力制限をしている。

7桁以上のペーストの場合。（0桁も同様の非活性）
<img width="200dp" alt="Screenshot_1622479261.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/6af9f26a-8049-5544-1bcf-3517237349be.png">

正常入力の場合。
<img width="200dp" alt="Screenshot_1622479501.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/a5b65b7d-7c77-72ba-7c8c-4b28b2acd578.png">

```PayPayCustomWatcher.kt
~
~

override fun afterTextChanged(p0: Editable?) {
        if(p0.toString().isEmpty() || p0.toString().length == 7) {
            callBack.invalidButton()
        } else {
            callBack.enableButton()
        }
    }

~
~
```
TextWatcherのafterTextChangedで入力値チェックを行い結果に応じてボタンの状態をコールバックでViewに反映します。
callback.hogehoge()はView側でボタンの状態を変更しているだけです。
（TextWatcherについては[こちら](https://qiita.com/masaki_shoji/items/dd6043e5c4d512815f5e)を参照ください）

#### 実装の特徴

上記コードは直書きですが、入力値の判定条件のUTが非常にシンプルなものになる印象です。
ボタンの非活性でユーザに入力制限を伝えるＵＩの仕組みもMaterial design の考え方が生きていると感じます。
前述の問題点はすべて対応していないので、文字含む数値の場合は数値のみペーストされます。これは許容しているようです。


### Zaim の動きを実装してみる。
Zaimの入力エリアの特徴は下記になります。

- LongClick時にユーザが貼り付けようとしているクリップボードの値をダイアログで確認。
- クリップボードの値が想定外の値の場合は、入力不可の旨をダイアログで伝える。
- 正常入力可能なペーストの値だった場合、ダイアログでOK押下後、ペースト値を現在のEditTextに上書き。
- 長押しListenerによって、OSの貼り付けを制御する。

「888」がクリップボードの先頭にある状態で入力エリアを長押し。
<img width="200dp" alt="Screenshot_1622481877.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/4abcca74-2a48-d9f0-b7ea-b86237ec2c23.png">


想定外数値がクリップボードの先頭にある状態で入力エリアを長押し。
<img width="200dp" alt="Screenshot_1622481933.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/34657a12-2068-3bce-0e29-5c6e7b461ea6.png">

```ZaimFragment.kt
~
~
// 長押しで貼り付けるテキストをダイアログ表示。
        zaimInputArea.setOnLongClickListener {
            //クリップボードのサービスのインスタンスを取得する
            val mManager: ClipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
            val mText = mManager.primaryClip?.getItemAt(0)?.text.toString()

            //張り付けるテキストによって、表示するダイアログを分岐
            if(mText.isEmpty() ||  mText.length > 6){
                // 桁数が6桁を超える場合
                dialog?.setTitle("以下の入力は桁数オーバーです")
                dialog?.setMessage("入力値：　$mText")
                dialog?.getButton(DialogInterface.BUTTON_POSITIVE)?.setTextColor(Color.WHITE)
                dialog?.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = false
                dialog?.show()
            }else {
                // 正常にペーストが可能な場合
                dialog?.setTitle("以下を入力します。")
                dialog?.setMessage("入力値：　$mText")
                dialog?.getButton(DialogInterface.BUTTON_POSITIVE)?.setOnClickListener{
                    zaimInputArea.setText(mText)
                    dialog.dismiss()
                }
                dialog?.show()

            }
            true
        }

~
~

```
本実装では判定条件に加えていませんが、クリップボードのテキストから入力値を判定できるため、文字が混入しているパターンも検知が可能です。
ただこの実装だと、セキュリティ的にどうなのかという問題が残りますね。実際に使うにはセキュアな実装が求められる。。
参考：[セキュアコーディングガイド](https://www.jssec.org/dl/android_securecoding_20201101/6_difficult_problems.html)

※あくまでZaimの動きから実装を考えたため、実際のアプリはクリップボードの値を見に行っていないかもしれませんが。

#### 実装の特徴
上でも書きましたが、判定条件に必要な要素はクリップボードから持ってこれるため前述の問題点すべてに対応が可能な実装です。
メリデメあるので、実装は要検討ですかね。


### TextWatcherのみで実装を考える。
・paypayにならって、有効桁数は6桁。maxLengthは7桁指定。
・7桁入力をTextWatcherで検知して無効化。

```CustomTextWatcher.kt
~
~
// 貼り付け前のテキスト
    var beforeText = ""

    override fun afterTextChanged(p0: Editable?) {
        val afterText = p0.toString()
        if(afterText.length == 7) {
            // 最大6桁を超えている場合、変更前に保持した値を表示する
            callBack.showInputText(beforeText)
        } else {
            // 正常
            callBack.showInputText(afterText)
        }
    }

    /**
     * p0 = 変更前のEditTextの文字列
     */
    override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        // 変更前のテキストを保持しておく
        beforeText = p0.toString()
    }
~
~

```

別解。
EditTextのmaxは7桁だが、表示されるのは6桁になるように制御したパターンです。
beforeTextChangedの第一引数には変更前のEdittextがあるので、それをグローバルに保持しておきます。

aftertextChangedはEditTextのMax7桁の仕様にのっとり、最大も7桁の数値文字列がafterTextに格納されます。afterText.lengthが7でだった場合、取得しておいたbeforeTextを使えば、ペースト前に戻すことが可能です。



## 終わり

EditTextのペースト対策、結構調整が面倒なところ。（やってしまえば大したことないのですが）
知識として持っておきたい部分ですね。他にもEditTextをCustomする方法もあるかと思ってます。探せばもっといい方法あるのでしょうね。


ひっさびさの記事なので、なにか誤解誤植ご指摘あればいただけるとありがたく思います。