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
| 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
- 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
Quay lại Lộ trình KMP để xem các topic khác.
Last updated on