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).
The Basics: Text Input Done Right
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
)
)
}
Code language: PHP (php)
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() }
)
)
}
Code language: PHP (php)
Building a Complete Login Form
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")
}
}
}
Code language: JavaScript (javascript)
This form includes:
- Email and password fields
- Input validation
- Error messages
- Keyboard navigation
- Platform-aware behavior
Handling Platform-Specific Input Quirks
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")
}
}
}
}
Code language: JavaScript (javascript)
Advanced Form Patterns
Multi-Step Forms
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 */ }
)
}
}
}
Code language: JavaScript (javascript)
Dynamic Form Fields
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()
)
}
}
}
}
Code language: JavaScript (javascript)
Testing Your Forms
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)
}
Code language: JavaScript (javascript)
Final Thoughts
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:
- Handle platform differences gracefully
- Provide clear feedback
- Make navigation intuitive
- Validate input early and often
- Test thoroughly on all target platforms
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.