Forms in Compose — TextField, Validation & Keyboard Actions
Build production-ready forms in Jetpack Compose — TextField styling, input validation, error states, keyboard actions, and form submission patterns.
This is Part 6 of a 10-part series on Jetpack Compose.
- Your First Screen
- State Management
- Navigation
- Lists
- Theming
- Forms (this post)
- Side Effects
- Custom Layouts
- API Integration
- Migration from XML
TextField basics
Compose has two TextField variants:
import androidx.compose.material3.TextField
import androidx.compose.material3.OutlinedTextField
// Filled style (default Material3)
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Email") }
)
// Outlined style
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Email") }
)
Both are controlled components — you provide the value and handle changes. No internal state.
Input types with KeyboardOptions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.ImeAction
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
singleLine = true
)
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Phone") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Done
),
singleLine = true
)
| KeyboardType | Shows |
|---|---|
Text | Standard keyboard |
Email | Keyboard with @ and .com |
Number | Number pad |
Phone | Phone dialer |
Password | Text with obscure option |
Uri | Keyboard with / and .com |
| ImeAction | Keyboard button |
|---|---|
Next | Moves focus to next field |
Done | Closes keyboard |
Search | Search icon |
Send | Send icon |
Go | Go icon |
Password field
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@Composable
fun PasswordField(
password: String,
onPasswordChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
var passwordVisible by remember { mutableStateOf(false) }
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text("Password") },
singleLine = true,
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription = if (passwordVisible) "Hide password" else "Show password"
)
}
},
modifier = modifier.fillMaxWidth()
)
}
Validation with error states
@Composable
fun ValidatedEmailField(
email: String,
onEmailChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
val isError = email.isNotEmpty() && !email.contains("@")
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text("Email") },
isError = isError,
supportingText = {
if (isError) {
Text(
text = "Invalid email format",
color = MaterialTheme.colorScheme.error
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
singleLine = true,
modifier = modifier.fillMaxWidth()
)
}
isError = true turns the field border red. supportingText shows the error message below the field.
Focus management
Move focus between fields programmatically:
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.text.KeyboardActions
@Composable
fun LoginForm() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val passwordFocusRequester = remember { FocusRequester() }
Column {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { /* submit form */ }
),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester)
)
}
}
FocusRequester lets you move focus programmatically. KeyboardActions handles IME button presses. ImeAction.Next + onNext creates a natural flow from email → password → submit.
Complete registration form
Putting it all together — a form with validation, error handling, and submission:
data class RegistrationFormState(
val name: String = "",
val email: String = "",
val password: String = "",
val nameError: String? = null,
val emailError: String? = null,
val passwordError: String? = null,
val isSubmitting: Boolean = false
)
class RegistrationViewModel : ViewModel() {
private val _state = MutableStateFlow(RegistrationFormState())
val state: StateFlow<RegistrationFormState> = _state.asStateFlow()
fun updateName(name: String) {
_state.value = _state.value.copy(name = name, nameError = null)
}
fun updateEmail(email: String) {
_state.value = _state.value.copy(email = email, emailError = null)
}
fun updatePassword(password: String) {
_state.value = _state.value.copy(password = password, passwordError = null)
}
fun submit() {
val current = _state.value
val errors = validate(current)
if (errors != null) {
_state.value = errors
return
}
viewModelScope.launch {
_state.value = _state.value.copy(isSubmitting = true)
try {
authRepository.register(current.name, current.email, current.password)
// navigate to success
} catch (e: Exception) {
_state.value = _state.value.copy(
isSubmitting = false,
emailError = e.message
)
}
}
}
private fun validate(state: RegistrationFormState): RegistrationFormState? {
var hasError = false
var result = state
if (state.name.isBlank()) {
result = result.copy(nameError = "Name is required")
hasError = true
}
if (state.email.isBlank()) {
result = result.copy(emailError = "Email is required")
hasError = true
} else if (!state.email.contains("@")) {
result = result.copy(emailError = "Invalid email format")
hasError = true
}
if (state.password.length < 8) {
result = result.copy(passwordError = "Password must be at least 8 characters")
hasError = true
}
return if (hasError) result else null
}
}
The composable:
@Composable
fun RegistrationScreen(viewModel: RegistrationViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val emailFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Create Account",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.name,
onValueChange = { viewModel.updateName(it) },
label = { Text("Name") },
isError = state.nameError != null,
supportingText = state.nameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { emailFocus.requestFocus() }),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = state.email,
onValueChange = { viewModel.updateEmail(it) },
label = { Text("Email") },
isError = state.emailError != null,
supportingText = state.emailError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(emailFocus)
)
OutlinedTextField(
value = state.password,
onValueChange = { viewModel.updatePassword(it) },
label = { Text("Password") },
isError = state.passwordError != null,
supportingText = state.passwordError?.let { { Text(it) } },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { viewModel.submit() }),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocus)
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.submit() },
enabled = !state.isSubmitting,
modifier = Modifier.fillMaxWidth()
) {
if (state.isSubmitting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Register")
}
}
}
}
The form handles:
- Field-level validation with error messages
- Focus flow (name → email → password → submit)
- Loading state during submission
- Server-side error display
- Clearing errors when the user edits a field
Summary
- OutlinedTextField for most forms, TextField for compact UIs
- KeyboardOptions for input type and IME action
- KeyboardActions for handling IME button presses
- FocusRequester for moving focus between fields
- isError + supportingText for validation error display
- ViewModel for form state, validation, and submission
Next: Side Effects in Compose — LaunchedEffect, DisposableEffect & More.