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:

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

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

  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.

0 0 votes
Article Rating

Leave a Reply

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments