Skip to Content

Form và Validation

Hướng dẫn xây dựng form với validation đầy đủ trong Compose Multiplatform.

Form State

// FormState.kt data class FormState( val fields: Map<String, FieldState> = emptyMap(), val isValid: Boolean = false, val isSubmitting: Boolean = false ) data class FieldState( val value: String = "", val error: String? = null, val isTouched: Boolean = false ) // Validation rules sealed class ValidationRule { object Required : ValidationRule() data class MinLength(val length: Int) : ValidationRule() data class MaxLength(val length: Int) : ValidationRule() object Email : ValidationRule() data class Pattern(val regex: Regex, val message: String) : ValidationRule() data class Match(val otherField: String) : ValidationRule() }

Form ViewModel

class FormViewModel : ScreenModel { private val _formState = MutableStateFlow(FormState()) val formState: StateFlow<FormState> = _formState.asStateFlow() private val validationRules = mapOf( "name" to listOf(ValidationRule.Required, ValidationRule.MinLength(2)), "email" to listOf(ValidationRule.Required, ValidationRule.Email), "password" to listOf(ValidationRule.Required, ValidationRule.MinLength(8)), "confirmPassword" to listOf(ValidationRule.Required, ValidationRule.Match("password")), "phone" to listOf( ValidationRule.Pattern( Regex("^\\+?[0-9]{10,14}$"), "Invalid phone number" ) ) ) fun updateField(fieldName: String, value: String) { _formState.update { state -> val fields = state.fields.toMutableMap() val currentField = fields[fieldName] ?: FieldState() // Clear error on change fields[fieldName] = currentField.copy( value = value, error = null, isTouched = true ) state.copy( fields = fields, isValid = validateAll(fields) ) } } fun validateField(fieldName: String) { val currentState = _formState.value val fieldValue = currentState.fields[fieldName]?.value ?: "" val rules = validationRules[fieldName] ?: emptyList() val error = validateValue(fieldValue, rules, currentState.fields) _formState.update { state -> val fields = state.fields.toMutableMap() val currentField = fields[fieldName] ?: FieldState() fields[fieldName] = currentField.copy(error = error, isTouched = true) state.copy(fields = fields, isValid = validateAll(fields)) } } fun validateAllFields(): Boolean { val currentState = _formState.value val fields = currentState.fields.toMutableMap() validationRules.forEach { (fieldName, rules) -> val fieldValue = fields[fieldName]?.value ?: "" val error = validateValue(fieldValue, rules, fields) val currentField = fields[fieldName] ?: FieldState() fields[fieldName] = currentField.copy(error = error, isTouched = true) } val isValid = fields.values.all { it.error == null } _formState.value = currentState.copy(fields = fields, isValid = isValid) return isValid } private fun validateValue( value: String, rules: List<ValidationRule>, allFields: Map<String, FieldState> ): String? { for (rule in rules) { val error = when (rule) { is ValidationRule.Required -> { if (value.isBlank()) "This field is required" else null } is ValidationRule.MinLength -> { if (value.length < rule.length) { "Must be at least ${rule.length} characters" } else null } is ValidationRule.MaxLength -> { if (value.length > rule.length) { "Must be at most ${rule.length} characters" } else null } is ValidationRule.Email -> { if (!value.contains("@") || !value.contains(".")) { "Invalid email address" } else null } is ValidationRule.Pattern -> { if (!rule.regex.matches(value) && value.isNotBlank()) { rule.message } else null } is ValidationRule.Match -> { val otherValue = allFields[rule.otherField]?.value ?: "" if (value != otherValue) "Fields do not match" else null } } if (error != null) return error } return null } private fun validateAll(fields: Map<String, FieldState>): Boolean { return fields.values.all { it.error == null } && validationRules.keys.all { fields[it]?.value?.isNotBlank() == true } } fun submit(onSuccess: () -> Unit) { if (!validateAllFields()) return screenModelScope.launch { _formState.update { it.copy(isSubmitting = true) } try { // API call delay(2000) onSuccess() } catch (e: Exception) { // Handle error } finally { _formState.update { it.copy(isSubmitting = false) } } } } }

Registration Form

@Composable fun RegistrationForm( viewModel: FormViewModel, onSuccess: () -> Unit ) { val state by viewModel.formState.collectAsState() Column( modifier = Modifier .fillMaxSize() .padding(24.dp) .verticalScroll(rememberScrollState()) ) { Text( "Create Account", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold ) Spacer(Modifier.height(8.dp)) Text( "Fill in the form below to register", color = Color.Gray ) Spacer(Modifier.height(32.dp)) // Name FormField( value = state.fields["name"]?.value ?: "", onValueChange = { viewModel.updateField("name", it) }, onFocusLost = { viewModel.validateField("name") }, label = "Full Name", error = state.fields["name"]?.error, leadingIcon = Icons.Default.Person ) Spacer(Modifier.height(16.dp)) // Email FormField( value = state.fields["email"]?.value ?: "", onValueChange = { viewModel.updateField("email", it) }, onFocusLost = { viewModel.validateField("email") }, label = "Email", error = state.fields["email"]?.error, leadingIcon = Icons.Default.Email, keyboardType = KeyboardType.Email ) Spacer(Modifier.height(16.dp)) // Phone (optional) FormField( value = state.fields["phone"]?.value ?: "", onValueChange = { viewModel.updateField("phone", it) }, onFocusLost = { viewModel.validateField("phone") }, label = "Phone (optional)", error = state.fields["phone"]?.error, leadingIcon = Icons.Default.Phone, keyboardType = KeyboardType.Phone ) Spacer(Modifier.height(16.dp)) // Password FormField( value = state.fields["password"]?.value ?: "", onValueChange = { viewModel.updateField("password", it) }, onFocusLost = { viewModel.validateField("password") }, label = "Password", error = state.fields["password"]?.error, leadingIcon = Icons.Default.Lock, isPassword = true ) // Password strength indicator PasswordStrengthIndicator( password = state.fields["password"]?.value ?: "" ) Spacer(Modifier.height(16.dp)) // Confirm Password FormField( value = state.fields["confirmPassword"]?.value ?: "", onValueChange = { viewModel.updateField("confirmPassword", it) }, onFocusLost = { viewModel.validateField("confirmPassword") }, label = "Confirm Password", error = state.fields["confirmPassword"]?.error, leadingIcon = Icons.Default.Lock, isPassword = true ) Spacer(Modifier.height(32.dp)) // Submit button Button( onClick = { viewModel.submit(onSuccess) }, modifier = Modifier .fillMaxWidth() .height(56.dp), enabled = !state.isSubmitting, shape = RoundedCornerShape(12.dp) ) { if (state.isSubmitting) { CircularProgressIndicator( modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp ) } else { Text("Create Account") } } } }

Reusable Form Field

@Composable fun FormField( value: String, onValueChange: (String) -> Unit, onFocusLost: () -> Unit, label: String, error: String?, modifier: Modifier = Modifier, leadingIcon: ImageVector? = null, isPassword: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text ) { var passwordVisible by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) } OutlinedTextField( value = value, onValueChange = onValueChange, label = { Text(label) }, leadingIcon = leadingIcon?.let { { Icon(it, contentDescription = null) } }, trailingIcon = if (isPassword) { { IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = null ) } } } else if (error != null) { { Icon( Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error ) } } else null, visualTransformation = if (isPassword && !passwordVisible) { PasswordVisualTransformation() } else { VisualTransformation.None }, isError = error != null, supportingText = error?.let { { Text(it) } }, keyboardOptions = KeyboardOptions(keyboardType = keyboardType), singleLine = true, modifier = modifier .fillMaxWidth() .onFocusChanged { if (isFocused && !it.isFocused) { onFocusLost() } isFocused = it.isFocused }, shape = RoundedCornerShape(12.dp) ) }

Password Strength Indicator

@Composable fun PasswordStrengthIndicator(password: String) { val strength = calculatePasswordStrength(password) if (password.isNotEmpty()) { Column(modifier = Modifier.padding(top = 8.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { repeat(4) { index -> Box( modifier = Modifier .weight(1f) .height(4.dp) .background( color = if (index < strength.level) { strength.color } else { Color.LightGray }, shape = RoundedCornerShape(2.dp) ) ) } } Spacer(Modifier.height(4.dp)) Text( strength.label, style = MaterialTheme.typography.bodySmall, color = strength.color ) } } } data class PasswordStrength( val level: Int, val label: String, val color: Color ) fun calculatePasswordStrength(password: String): PasswordStrength { var score = 0 if (password.length >= 8) score++ if (password.any { it.isUpperCase() }) score++ if (password.any { it.isDigit() }) score++ if (password.any { !it.isLetterOrDigit() }) score++ return when (score) { 0, 1 -> PasswordStrength(1, "Weak", Color.Red) 2 -> PasswordStrength(2, "Fair", Color(0xFFFF9800)) 3 -> PasswordStrength(3, "Good", Color(0xFF4CAF50)) else -> PasswordStrength(4, "Strong", Color(0xFF2E7D32)) } }

Checkbox và Terms

@Composable fun TermsCheckbox( checked: Boolean, onCheckedChange: (Boolean) -> Unit, error: String? ) { Column { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onCheckedChange(!checked) } ) { Checkbox( checked = checked, onCheckedChange = onCheckedChange ) Text( buildAnnotatedString { append("I agree to the ") withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { append("Terms of Service") } append(" and ") withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { append("Privacy Policy") } }, style = MaterialTheme.typography.bodySmall ) } error?.let { Text( it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 12.dp) ) } } }

📝 Tóm tắt

PatternMục đích
FormStateQuản lý state của form
FieldStateState của từng field
ValidationRuleQuy tắc validation
FormFieldReusable input field

Best Practices

  1. Validate on blur - Không validate liên tục
  2. Clear errors on change - UX tốt hơn
  3. Show all errors on submit - Để user biết cần sửa gì
  4. Disable submit khi loading - Tránh double submit
  5. Password strength - Giúp user chọn password mạnh

Tiếp theo

Quay lại Lộ trình KMP để xem các topic khác.

Last updated on