こんにちは、Androidエンジニアの@morux2です。スタディサプリ小学・中学講座アプリでは、この半年間 EFO (Entry Form Optimization) に注力してきました。今回はその中で、メールアドレスの認証コード入力画面の実装についてご紹介します。

これまで実施してきた EFO 施策
(このセクションでは、EFO 施策の歩みとメールアドレスの認証コード入力画面を刷新した経緯を説明します。画面実装に関心がある方はスキップして問題ありません。)
従来はすべての入力フォームが1画面に詰め込まれており、登録完了までに必要なアクションがわかりづらかったため、会員登録に対する心理的ハードルが高くなっていました。そこで次の3つの改善を行い、「気持ち良く」登録を進められる体験を目指しました。
- 入力項目の削減
- 項目ごとに入力画面を分割
- プログレスバーで登録の進捗を可視化

これらの改善によりユーザー体験が向上しました。さらに、分割された画面ごとにスクリーンログを仕込むことで、登録完了までのファネル分析も可能になりました。*1
分析の結果、各画面でユーザーの離脱が発生していることが明らかになり、画面ごとの操作性をさらに磨き込むことで、登録完了率の向上が見込めることがわかりました。この結果を踏まえ、Compose 1.8 で導入された Autofill の適用や KeyboardOptions の見直しなど、devs 主導での改善も実施しました。

その中で、特に離脱率が高かったメールアドレスの認証コード入力画面については、1桁ずつコードを入力する形で刷新することになりました。

メールアドレスの認証コード入力画面の要件
画面は devs と designer で実装の難易度を考慮しながら要件を整理しました。決定した要件は以下の通りです。
- 数字入力後、自動で次のボックスへフォーカスが移動する
- バックキーを押すと数字が削除され、前のボックスにフォーカスが戻る
- 数字の編集は末尾のみ可能
- フォーカス中のボックスには、カーソルを表示する
- 認証コードのペーストおよび Autofill に対応 (Autofill は iOS のみ)
- 数字以外の文字は入力不可

実装方針
このUIを見た時に最初に思い浮かんだのは、桁数分のテキストフィールドを並べるやり方でした。しかしこの方法では、以下のような複雑な処理が必要で、保守コストが高くなってしまいます。
- 数字が編集されるたびに、フォーカスを明示的に切り替える
- バックキー・長押し(ペーストなど)の入力を自前でハンドリングする
- 入力完了時に文字列の連結操作をする
- E2Eテストツール (MagicPod) での入力操作対応
そこで、テキストフィールドは1つにしてフィールドに6桁のボックスの装飾をつける方針を採用しました。下記の記事を参考にさせていただきましたが、OTP (One-Time Password) のキーワードで検索すると、様々な実装記事を見つけることができます。
実装内容・サンプルコード
ここからは具体的なコードを紹介します。最終的なサンプルコードはこちらからご確認いただけます。
まずは BasicTextField の decorationBox 引数の仕様について確認していきます。decorationBox では、装飾を Composable 関数として渡すことができ、内部で innerTextField を呼び出すことが可能です。例えば innerTextField と decorationBox を縦に並べると、次のようになります。
@Composable fun Sample() { var emailCode by rememberSaveable { mutableStateOf("") } BasicTextField( value = emailCode, onValueChange = { emailCode = it }, decorationBox = { innerTextField -> Column { innerTextField() EmailVerificationCodeDecorationBox( emailCode = emailCode ) } } ) }
EmailVerificationCodeDecorationBox の実装はこちら
private const val VERIFICATION_CODE_LENGTH = 6 @Composable private fun EmailVerificationCodeDecorationBox( emailCode: String, ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { repeat(VERIFICATION_CODE_LENGTH) { index -> val digit = emailCode.getOrNull(index)?.toString() ?: "" Box( modifier = Modifier .size(48.dp) .border( width = 1.dp, color = Color.Gray, shape = RoundedCornerShape(8.dp) ), contentAlignment = Alignment.Center ) { Text(text = digit) } } } }

decorationBox の中で innerTextField は必ず1度だけ呼び出す必要があり、呼び出さないとペーストや範囲選択のような操作を試みた際にクラッシュが発生するので注意が必要です。
ペーストなどの文字列操作の機能を残しつつ、独自の装飾を施すために innerTextField を透明にして配置することにしました。また、Box を用いてinnerTextField の上に装飾を重ねることで、コンテキストメニューが装飾の側に出るようにしています。
decorationBox = { innerTextField ->
Box {
// コンテキストメニューが装飾の側で表示されるように重ねる
Box(
// 透明度を0にして見えないようにする
modifier = Modifier.alpha(0f)
) {
innerTextField()
}
EmailVerificationCodeDecorationBox(
emailCode = emailCode,
)
}
}

ここまでの実装でペーストの入力を受け付けつつ、自前の装飾を表示することができました。次にカーソルのハンドルを透明にして見えないようにします。
var emailCode by rememberSaveable { mutableStateOf("") } // カーソルのハンドルおよびテキスト範囲選択時の背景色を非表示にする val noHandleColors = TextSelectionColors( handleColor = Color.Transparent, backgroundColor = Color.Transparent, ) CompositionLocalProvider(LocalTextSelectionColors provides noHandleColors) { BasicTextField() // 省略 }

innerTextField を非表示にしたことで消えてしまったカーソルを自前で描画していきます。InfiniteTransition を用いることで点滅するカーソルを作成できます。
@Composable private fun EmailVerificationCodeDecorationBox( emailCode: String, ) { val cursorAnimationAlpha by rememberInfiniteTransition().animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween(500), repeatMode = RepeatMode.Reverse ) ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { repeat(VERIFICATION_CODE_LENGTH) { index -> val digit = emailCode.getOrNull(index)?.toString() ?: "" Box( modifier = Modifier .size(48.dp) .border( width = 1.dp, color = Color.Gray, shape = RoundedCornerShape(8.dp) ), contentAlignment = Alignment.Center ) { val showCursor = cursorAnimationAlpha > 0.5f && emailCode.length == index Row { Text(text = digit) if (showCursor) { VerticalDivider( modifier = Modifier .height(16.dp) .padding(start = 2.dp), thickness = 1.dp, color = Color.Gray ) } } } } } }

ここからは入力処理を調整します。6桁の数字のみを受け付けるために、キーボードは NumberPassword にし、BasicTextField の onValueChange の処理を調整します。
onValueChange = {
val filteredInput = it.text.filter { char -> char.isDigit() }
emailCode = filteredInput.take(VERIFICATION_CODE_LENGTH)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.NumberPassword
),
最後に末尾入力です。先ほどの自前のカーソル実装は末尾入力を前提にしているので、この設定は欠かせません。末尾入力は BasicTextField の value に TextFieldValue を渡すことで実現できます。
value = TextFieldValue(
text = emailCode,
// カーソルは常に末尾に配置する
selection = TextRange(emailCode.length)
),
フォーカス時の装飾などを加えた、最終的な完成形は次のようになります。詳しくはサンプルコードを参照ください。

さいごに
一見複雑そうに見えるメールアドレスの認証コード入力画面ですが、1つの TextField のコンポーネントで手軽に実装することができました。EFO は devs が主体となって改善をしやすい分野かと思いますので、ぜひお試しください。
*1:本ログには個人を特定できる情報は含まれておらず、操作性改善を目的とした画面遷移等の分析用途でのみ使用しています。