【JetpackCompose】テキスト入力された最後の文字だけマスクを外したい

# はじめに
パスワードを入力する時に表示した文字をマスクして隠したいというのはよくあります。

それをJetpackComposeで実現する場合は、TextFieldのvisualTransformationに[PasswordVisualTransformation](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/input/PasswordVisualTransformation)を指定すれば入力した文字をマスクすることができます。
ただこちらの動きがViewシステムのEditTextの`android:inputType="textPasssord"`とは微妙に異なっていました。

EditText | TextField
--- | ---
![edittext-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953466/6fe353e8-b6f4-d4c7-95bf-b7be6cf77487.gif) | ![textfield-before.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953466/b31cdd8a-af30-2b39-60d7-89f3b6212f14.gif)



両者を比較するとEditTextは入力して約1秒後にマスクされるのに対して、TextFieldの方は入力した直後にマスクされてしまいます。そのためTextFieldは何を入力したかをリアルタイムで確認することができません。

表示切替ボタンを設置してボタンの押下でVisualTransformationを切り替える方法もあり、既にボタンがある場合はそちらを使う形にして良いと思います。
ただ切替ボタンを設置していない場合はデザインの変更となるので、デザイナーへの確認やアイコンの準備などの作業も必要になります。

そのため今回は切替ボタンを設置せず、VisualTransformationをカスタムして既存の動きに近づけるようにしました。

# 実現したいこと
EditTextの動きに近づけるためには下記3つがポイントになります。
1. 入力した最後の文字のみ表示（それ以外はマスク表示）
2. 1秒後に残った最後の文字もマスク表示になる
3. 削除の時はマスクしたままにする

まずは1から実装していきます。

# 1. 入力した最後の文字のみ表示
まずPasswordVisualTransformationの処理がどうなっているのかを見てみます。

```kotlin
class PasswordVisualTransformation(val mask: Char = '\u2022') : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        return TransformedText(
            AnnotatedString(mask.toString().repeat(text.text.length)),
            OffsetMapping.Identity
        )
    }

    override fun equals(other: Any?): Boolean {
        ....
    }

    override fun hashCode(): Int {
        ....
    }
}
```

やっていることは非常にシンプルで、入力された文字の数だけマスクを繰り返し出力しています。
なので最後の文字を保持→文字数から1を引いた数だけマスク出力→保持しておいた文字を繋げることで実現できます。


```kotlin
class PasswordVisualTransformationExcludesLast(val mask: Char = '\u2022') : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val masked = buildAnnotatedString {
            val last = text.text.lastOrNull() // 最後の文字を確保しておく
            if (last != null) {
                if (text.text.length >= 2) {  // 入力された文字が1文字だけの場合はマスクしない
                    append(mask.toString().repeat(text.text.length - 1))
                }
                append(last)
            }
        }
        return TransformedText(
            masked,
            OffsetMapping.Identity,
        )
    }
....
}
```

# 2. 入力した1秒後にマスク表示にする
最後の文字以外をマスク表示にするVisualTransformationができたので、次はこれを適用させます。

文字列全体をマスク表示にする場合は、標準で用意されているPasswordVisualTransformationを使用します。

なので入力から1秒間は先ほど作成した`PasswordVisualTransformationExcludesLast`を、それ以降は`PasswordVisualTransformation`を使用するようにします。


```kotlin
var inputText by remember { mutableStateOf("") }
var visualTransformation: VisualTransformation by remember {
    mutableStateOf(PasswordVisualTransformationInstance)
}

LaunchedEffect(Unit) {
    var job: Job? = null
    snapshotFlow { inputText }.onEach {
    job?.cancel()
    job = launch {
            visualTransformation = PasswordVisualTransformationExcludesLastInstance
            delay(1.seconds)
            visualTransformation = PasswordVisualTransformationInstance
        }
    }.collect()
}
```

文字列とVisualTransformationは変更があるためMutableStateとして持っておきます。
またdelayを使うのでLaunchedEffectで囲み、Flowで文字列を監視します。

入力直後は最後以外をマスクする`PasswordVisualTransformationExcludesLast`を使い、1秒後に`PasswordVisualTransformationInstance`へ切り替えます。

連続で入力されることもあるので、Jobとして持っておいて次の入力がきた場合は前のJobをキャンセルした上で、再度1秒間待つ処理を入れています。


こうすることで1秒後にマスク表示に切り替わることができました。

![textfield-before-delete.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953466/e178012f-e4db-d27a-bbec-36d847550471.gif)


# 3. 削除の時はマスクしたままにする
このままだと削除する時も最後の一文字が表示されてしまいます。

それを阻止するために、変更前の文字数と変更後の文字数を比較して、変更後の方が文字数が少ない（=削除された）場合はマスク表示のままにします。
ただ標準のonEachでは変更前の値を保持できないので、新たに変更前の値を保持できる拡張関数を作成します。


```kotlin
private fun <T> Flow<T>.onEach(initial: T, action: suspend (old: T, new: T) -> Unit): Flow<T> = flow {
    var prev: T = initial
    collect { value ->
        action(prev, value)
        prev = value
        emit(value)
    }
}
```
```kotlin
LaunchedEffect(Unit) {
    var job: Job? = null
    snapshotFlow { inputText }.onEach(initial = "") { old, new ->
    job?.cancel()
    job = launch {
            if (old.length < new.length) {
                visualTransformation = PasswordVisualTransformationExcludesLastInstance
                delay(1.seconds)
                visualTransformation = PasswordVisualTransformationInstance
            } else {
                visualTransformation = PasswordVisualTransformationInstance
            }
        }
    }.collect()
}
```

これでEditTextの方と同じ挙動にすることができました！

![textfield-complete.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953466/504830b6-0d2a-6e76-f0cb-97d9d740c99c.gif)


最後に全体のコードを貼っておきます。

```kotlin
@OptIn(ExperimentalTime::class)
@Composable
fun TextFieldTestScreen() {
    var inputText by remember { mutableStateOf("") }
    var visualTransformation: VisualTransformation by remember {
        mutableStateOf(PasswordVisualTransformationInstance)
    }
    LaunchedEffect(Unit) {
        var job: Job? = null
        snapshotFlow { inputText }.onEach(initial = "") { old, new ->
            job?.cancel()
            job = launch {
                if (old.length < new.length) {
                    visualTransformation = PasswordVisualTransformationExcludesLastInstance
                    delay(1.seconds)
                    visualTransformation = PasswordVisualTransformationInstance
                } else {
                    visualTransformation = PasswordVisualTransformationInstance
                }
            }
        }.collect()
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 40.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        TextField(
            value = inputText,
            onValueChange = { inputText = it },
            textStyle = TextStyle(
                fontSize = 12.sp,
            ),

            shape = RectangleShape,
            singleLine = true,
            visualTransformation = visualTransformation,
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Password
            ),
            modifier = Modifier
                .height(48.dp)
                .fillMaxWidth(),
        )
    }
}
```


# まとめ
今回はカスタマイズしたVisualTransformationを使用して、手動でvisualTransformationを切り替えるようにしました。
意外とViewシステムの方との動作の差があるので、その辺りはチームで相談する必要がありそうですね。

今回みたいに最後の文字だけマスクを外したい場合は結構あると思うので、公式で出てくれたらそれが一番良さそうだなと思いました。