What You Will Build
A 6-digit OTP (One-Time Password) input where each digit has its own text field and focus automatically advances to the next field when a digit is entered. When all 6 digits are filled, a verification button becomes enabled. This is the standard pattern for SMS code verification screens.
Why This Pattern Matters
OTP entry is a critical flow in authentication. The auto-advance pattern reduces friction by eliminating manual tab/tap between fields. It teaches you FocusRequester management, keyboard type configuration, and AnimatedVisibility for conditional UI elements.
The OTP Input Screen
@Composable
fun AutoOtpScreen() {
var otpFields by remember { mutableStateOf(List(6) { "" }) }
val focusRequesters = remember { List(6) { FocusRequester() } }
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Enter OTP Code",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text("We sent a verification code to your phone",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center)
Spacer(Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
otpFields.forEachIndexed { index, value ->
OutlinedTextField(
value = value,
onValueChange = { newVal ->
if (newVal.length <= 1) {
val updated = otpFields.toMutableList()
updated[index] = newVal
otpFields = updated
if (newVal.isNotEmpty() && index < 5) {
focusRequesters[index + 1].requestFocus()
}
}
},
modifier = Modifier
.width(48.dp)
.height(56.dp)
.focusRequester(focusRequesters[index]),
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
)
)
}
}
val code = otpFields.joinToString("")
AnimatedVisibility(visible = code.length == 6) {
Text("Code: $code",
color = Color(0xFF4CAF50),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp))
}
Spacer(Modifier.height(24.dp))
Button(onClick = {}, enabled = code.length == 6,
modifier = Modifier.fillMaxWidth()) {
Text("Verify")
}
TextButton(onClick = {}) { Text("Resend Code") }
}
LaunchedEffect(Unit) {
focusRequesters[0].requestFocus()
}
}
Tips and Pitfalls
- FocusRequester per field: Create a list of 6 FocusRequesters and attach each to its corresponding OutlinedTextField.
- Auto-advance logic: On value change, if the new value is not empty and the current index is less than 5, request focus on the next field.
- LaunchedEffect(Unit) focuses the first field automatically when the screen appears.
- KeyboardType.Number ensures the numeric keyboard appears, reducing input errors.
- Handle backspace: For production, detect empty input on a previously filled field and move focus backward.