Form và Validation
Hướng dẫn xây dựng form với validation đầy đủ trong Compose Multiplatform.
Form State
Phần tiêu đề “Form State”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 rulessealed 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
Phần tiêu đề “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
Phần tiêu đề “Registration Form”@Composablefun 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
Phần tiêu đề “Reusable Form Field”@Composablefun 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
Phần tiêu đề “Password Strength Indicator”@Composablefun 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
Phần tiêu đề “Checkbox và Terms”@Composablefun 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
Phần tiêu đề “📝 Tóm tắt”| Pattern | Mục đích |
|---|---|
| FormState | Quản lý state của form |
| FieldState | State của từng field |
| ValidationRule | Quy tắc validation |
| FormField | Reusable input field |
Best Practices
Phần tiêu đề “Best Practices”- Validate on blur - Không validate liên tục
- Clear errors on change - UX tốt hơn
- Show all errors on submit - Để user biết cần sửa gì
- Disable submit khi loading - Tránh double submit
- Password strength - Giúp user chọn password mạnh
Tiếp theo
Phần tiêu đề “Tiếp theo”Quay lại Lộ trình KMP để xem các topic khác.