PCSalt
YouTube GitHub
Back to Jetpack Compose
Jetpack Compose · 1 min read

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.

  1. Your First Screen
  2. State Management
  3. Navigation
  4. Lists
  5. Theming
  6. Forms (this post)
  7. Side Effects
  8. Custom Layouts
  9. API Integration
  10. 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
)
KeyboardTypeShows
TextStandard keyboard
EmailKeyboard with @ and .com
NumberNumber pad
PhonePhone dialer
PasswordText with obscure option
UriKeyboard with / and .com
ImeActionKeyboard button
NextMoves focus to next field
DoneCloses keyboard
SearchSearch icon
SendSend icon
GoGo 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.