Create intuitive forms with validation, gestures, and platform-specific polish for Android, iOS, desktop, and web
Forms are the unsung heroes of app development – they’re everywhere, yet we rarely give them much thought until something goes wrong. You know the feeling: you tap “Submit” and nothing happens, or worse, the keyboard covers half the fields. With Compose Multiplatform, we can create forms that work beautifully across Android, iOS, desktop, and web, all while sharing most of our code.
I’ve built dozens of forms in my career, from simple login screens to complex multi-step wizards, and I’ve learned that good form handling is like good plumbing – when it works, no one notices, but when it fails, everyone complains. Let’s explore how to build robust, cross-platform forms in Compose Multiplatform that will make your users smile (or at least not throw their phones).
Let’s start with the most fundamental form element – the text field. Here’s how to create a basic but fully functional text input in Compose Multiplatform:
@Composable
fun SimpleTextField() {
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Enter your name") },
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
)
)
}
This works across all platforms, but there are some subtle differences in behavior:
Platform | Keyboard Behavior | Notes |
---|---|---|
Android | Shows software keyboard automatically | Works out of the box |
iOS | Requires focus manager | Needs extra handling |
Desktop | Uses hardware keyboard | Tab navigation needed |
Web | Browser-dependent | May need polyfills |
For iOS, we need to add some platform-specific handling:
@Composable
fun IosAwareTextField() {
var text by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
TextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Enter your name") },
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() }
)
)
}
Now let’s create a complete login form with validation. This is where things get interesting:
@Composable
fun LoginForm(onLogin: (String, String) -> Unit) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var emailError by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = email,
onValueChange = {
email = it
emailError = false
},
modifier = Modifier.fillMaxWidth(),
label = { Text("Email") },
isError = emailError,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
if (emailError) {
Text(
text = "Please enter a valid email",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall
)
}
OutlinedTextField(
value = password,
onValueChange = {
password = it
passwordError = false
},
modifier = Modifier.fillMaxWidth(),
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
isError = passwordError,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() }
)
)
if (passwordError) {
Text(
text = "Password must be at least 6 characters",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall
)
}
Button(
onClick = {
val isValidEmail = email.isNotBlank() && email.contains("@")
val isValidPassword = password.length >= 6
emailError = !isValidEmail
passwordError = !isValidPassword
if (isValidEmail && isValidPassword) {
onLogin(email, password)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
) {
Text("Login")
}
}
}
This form includes:
Each platform has its own input quirks that we need to handle gracefully. Here’s a comparison of common challenges:
Challenge | Android | iOS | Desktop | Web |
---|---|---|---|---|
Keyboard appearance | Automatic | Needs focus | N/A | Browser-dependent |
Keyboard type | Easy to specify | Easy to specify | N/A | Limited control |
Tab navigation | Optional | N/A | Essential | Mixed support |
Mouse hover | N/A | N/A | Should support | Should support |
Touch vs mouse | Touch first | Touch first | Mouse first | Both |
Here’s how we can enhance our form to handle these differences:
@Composable
fun PlatformAwareForm() {
// ... existing state variables ...
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
Column {
// Email field with hover effects for desktop/web
OutlinedTextField(
// ... other parameters ...
interactionSource = interactionSource,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = if (isHovered) {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.surface
}
)
)
// Platform-specific submit button
if (Platform.current == Platform.Desktop) {
Button(
onClick = { /* handle submit */ },
modifier = Modifier.onKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
// Handle Enter key
true
} else {
false
}
}
) {
Text("Submit (Press Enter)")
}
} else {
Button(onClick = { /* handle submit */ }) {
Text("Submit")
}
}
}
}
For complex forms, breaking them into multiple steps can improve the user experience:
enum class FormStep { PersonalInfo, ContactDetails, Preferences }
@Composable
fun MultiStepForm() {
var currentStep by remember { mutableStateOf(FormStep.PersonalInfo) }
AnimatedContent(
targetState = currentStep,
transitionSpec = {
slideIntoContainer(
towards = if (targetState > initialState) AnimatedContentTransitionScope.SlideDirection.Left
else AnimatedContentTransitionScope.SlideDirection.Right
) with fadeOut()
}
) { step ->
when (step) {
FormStep.PersonalInfo -> PersonalInfoStep(
onNext = { currentStep = FormStep.ContactDetails }
)
FormStep.ContactDetails -> ContactDetailsStep(
onBack = { currentStep = FormStep.PersonalInfo },
onNext = { currentStep = FormStep.Preferences }
)
FormStep.Preferences -> PreferencesStep(
onBack = { currentStep = FormStep.ContactDetails },
onSubmit = { /* Handle final submission */ }
)
}
}
}
Sometimes you need fields that appear based on previous selections:
@Composable
fun SurveyForm() {
var hasAllergies by remember { mutableStateOf(false) }
Column {
// ... other fields ...
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = hasAllergies,
onCheckedChange = { hasAllergies = it }
)
Text("Do you have any food allergies?")
}
AnimatedVisibility(visible = hasAllergies) {
Column {
Text("Please list your allergies:")
TextField(
value = "",
onValueChange = { /* Handle input */ },
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
Testing is crucial for forms. Here’s a simple test for our login form:
@Test
fun loginForm_validatesInput() = runComposeTest {
var loggedIn = false
setContent {
LoginForm { _, _ -> loggedIn = true }
}
// Test empty submission
onNodeWithText("Login").performClick()
onNodeWithText("Please enter a valid email").assertIsDisplayed()
// Test invalid email
onNodeWithText("Email").performTextInput("invalid")
onNodeWithText("Login").performClick()
onNodeWithText("Please enter a valid email").assertIsDisplayed()
// Test valid submission
onNodeWithText("Email").performTextInput("test@example.com")
onNodeWithText("Password").performTextInput("password123")
onNodeWithText("Login").performClick()
assertTrue(loggedIn)
}
Building great forms in Compose Multiplatform isn’t just about making fields appear on screen – it’s about creating an intuitive, accessible experience that works seamlessly across all platforms. The key is to:
Remember, a good form is like a good conversation – it guides the user, provides helpful feedback, and never leaves them wondering what to do next. With these techniques, you’ll be building cross-platform forms that feel right at home on any device.
Jetpack Compose 1.8 rolls out handy features like Autofill integration, slick Text enhancements including auto-sizing… Read More
Reified Keyword in Kotlin: Simplify Your Generic Functions Kotlin's reified keyword lets your generic functions know the… Read More
Android Studio Cloud: Ditch the Setup, Code Anywhere (Seriously!) Alright, fellow Android devs, gather 'round… Read More
Why Clean Code Matters in KMP Poorly structured Kotlin Multiplatform projects often face: 80% longer… Read More