はじめに
パスワードを入力する時に表示した文字をマスクして隠したいというのはよくあります。
それをJetpackComposeで実現する場合は、TextFieldのvisualTransformationにPasswordVisualTransformationを指定すれば入力した文字をマスクすることができます。
ただこちらの動きがViewシステムのEditTextのandroid:inputType="textPasssord"
とは微妙に異なっていました。
EditText | TextField |
---|---|
両者を比較するとEditTextは入力して約1秒後にマスクされるのに対して、TextFieldの方は入力した直後にマスクされてしまいます。そのためTextFieldは何を入力したかをリアルタイムで確認することができません。
表示切替ボタンを設置してボタンの押下でVisualTransformationを切り替える方法もあり、既にボタンがある場合はそちらを使う形にして良いと思います。
ただ切替ボタンを設置していない場合はデザインの変更となるので、デザイナーへの確認やアイコンの準備などの作業も必要になります。
そのため今回は切替ボタンを設置せず、VisualTransformationをカスタムして既存の動きに近づけるようにしました。
実現したいこと
EditTextの動きに近づけるためには下記3つがポイントになります。
- 入力した最後の文字のみ表示(それ以外はマスク表示)
- 1秒後に残った最後の文字もマスク表示になる
- 削除の時はマスクしたままにする
まずは1から実装していきます。
1. 入力した最後の文字のみ表示
まずPasswordVisualTransformationの処理がどうなっているのかを見てみます。
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を引いた数だけマスク出力→保持しておいた文字を繋げることで実現できます。
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
を使用するようにします。
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秒後にマスク表示に切り替わることができました。
3. 削除の時はマスクしたままにする
このままだと削除する時も最後の一文字が表示されてしまいます。
それを阻止するために、変更前の文字数と変更後の文字数を比較して、変更後の方が文字数が少ない(=削除された)場合はマスク表示のままにします。
ただ標準のonEachでは変更前の値を保持できないので、新たに変更前の値を保持できる拡張関数を作成します。
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)
}
}
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の方と同じ挙動にすることができました!
最後に全体のコードを貼っておきます。
@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システムの方との動作の差があるので、その辺りはチームで相談する必要がありそうですね。
今回みたいに最後の文字だけマスクを外したい場合は結構あると思うので、公式で出てくれたらそれが一番良さそうだなと思いました。