Build Cross-Platform Forms in Compose Multiplatform

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
        )
    )
}

This works across all platforms, but there are some subtle differences in behavior:

PlatformKeyboard BehaviorNotes
AndroidShows software keyboard automaticallyWorks out of the box
iOSRequires focus managerNeeds extra handling
DesktopUses hardware keyboardTab navigation needed
WebBrowser-dependentMay 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() }
        )
    )
}

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")
        }
    }
}

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:

ChallengeAndroidiOSDesktopWeb
Keyboard appearanceAutomaticNeeds focusN/ABrowser-dependent
Keyboard typeEasy to specifyEasy to specifyN/ALimited control
Tab navigationOptionalN/AEssentialMixed support
Mouse hoverN/AN/AShould supportShould support
Touch vs mouseTouch firstTouch firstMouse firstBoth

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")
            }
        }
    }
}

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 */ }
            )
        }
    }
}

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()
                )
            }
        }
    }
}

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)
}

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:

  1. Handle platform differences gracefully
  2. Provide clear feedback
  3. Make navigation intuitive
  4. Validate input early and often
  5. 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.

Saiful Alam Rifan

Mobile Application Developer with over 12 years of experience crafting exceptional digital experiences. I specialize in delivering high-quality, user-friendly mobile applications across diverse domains including EdTech, Ride Sharing, Telemedicine, Blockchain Wallets, and Payment Gateway integration. My approach combines technical expertise with collaborative leadership, working seamlessly with analysts, QA teams, and engineers to create scalable, bug-free solutions that exceed expectations. Let's connect and transform your ideas into remarkable mobile experiences.

Recent Posts

What’s New in Jetpack Compose 1.8: Autofill, Text, Visibility & More (2025)

Jetpack Compose 1.8 rolls out handy features like Autofill integration, slick Text enhancements including auto-sizing… Read More

2 weeks ago

Reified Keyword in Kotlin Explained: Unlock Type Safety

 Reified Keyword in Kotlin: Simplify Your Generic Functions Kotlin's reified keyword lets your generic functions know the… Read More

2 weeks ago

Android Studio Cloud: Develop Android Apps Anywhere (2025)

Android Studio Cloud: Ditch the Setup, Code Anywhere (Seriously!) Alright, fellow Android devs, gather 'round… Read More

2 weeks ago

Firebase Studio & Google’s AI Dev Tools Guide

Firebase Studio is a new cloud-based platform for building AI-powered apps, launched at Google Cloud… Read More

3 weeks ago

Kotlin Multiplatform Future: Trends, Use Cases & Ecosystem Growth

1. Emerging Trends in Kotlin Multiplatform 1.1 Expansion to New Platforms KMP is branching beyond… Read More

3 weeks ago

Clean Kotlin Multiplatform Code: Best Practices for Maintainable Apps

Why Clean Code Matters in KMP Poorly structured Kotlin Multiplatform projects often face: 80% longer… Read More

4 weeks ago