Compose Multiplatform: Building Platform-Aware UI Elements in Jetpack Compose

Compose Multiplatform allows developers to create cross-platform applications using Kotlin and Jetpack Compose. While the core principles of UI development remain the same, building UIs for different platforms requires careful consideration of platform-specific conventions, design paradigms, and available UI elements. This article delves into implementing platform-aware UI elements in a Compose Multiplatform application using Jetpack Compose, along with practical examples and considerations.

What is Compose Multiplatform?

Compose Multiplatform is a declarative UI framework powered by Kotlin that enables developers to build user interfaces for Android, iOS, desktop (JVM), and web platforms from a single codebase. Leveraging Jetpack Compose’s modern UI toolkit, Compose Multiplatform streamlines cross-platform development by providing a consistent and efficient approach to UI creation.

Setting Up a Compose Multiplatform Project

Before diving into UI element implementation, it’s crucial to set up a Compose Multiplatform project. You can easily create one using the Kotlin Multiplatform wizard in IntelliJ IDEA or Android Studio. This wizard configures the necessary project structure, including:

  • Shared Kotlin module for common code.
  • Platform-specific modules for Android, iOS, desktop, and web.
  • Compose dependencies for UI rendering.

Creating Platform-Aware UI Elements

The key to successful cross-platform UI development is abstracting UI elements and behavior based on the target platform. Jetpack Compose allows us to achieve this through:

  • Expected and actual declarations
  • Platform-specific implementations
  • Composable functions

1. Using expect and actual declarations

To create platform-specific abstractions, utilize Kotlin’s expect and actual declarations. expect is defined in the common code, while actual implementations are provided in platform-specific modules.

// Common code
expect fun platformTextField(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier
)

// Android implementation
actual fun platformTextField(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier
) {
    TextField(
        value = value,
        onValueChange = onValueChange,
        modifier = modifier
    )
}

// iOS implementation
actual fun platformTextField(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier
) {
    UIKitView(
        factory = {
            val textField = UITextField()
            textField.delegate = object : NSObject(), UITextFieldDelegateProtocol {
                override fun textField(
                    textField: UITextField,
                    shouldChangeCharactersInRange: NSRange,
                    replacementString: String
                ): Boolean {
                    val newText = (textField.text as NSString).replacingCharacters(
                        inRange,
                        withString
                    )
                    onValueChange(newText)
                    return true
                }
            }
            textField
        },
        update = { uiTextField ->
            uiTextField.text = value
        },
        modifier = modifier
    )
}

2. Common Composables

In your shared code, create composables that utilize the expected platform-specific components:

// Common composable using the abstracted text field
@Composable
fun MyScreen() {
    var textValue by remember { mutableStateOf("") }

    Column(Modifier.padding(16.dp)) {
        Text("Enter text:")
        platformTextField(
            value = textValue,
            onValueChange = { newValue ->
                textValue = newValue
            },
            modifier = Modifier.fillMaxWidth()
        )
        Text("You entered: $textValue")
    }
}

3. Create buttons using Platform.OS

Detecting the current Platform allows customization.


import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.ApplicationScope
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.stringResource
import myapp.shared.generated.resources.Res
import platform.Platform

@OptIn(ExperimentalResourceApi::class)
@Composable
internal fun ApplicationScope.MyButton() {

    val platformName = when (Platform.osFamily) {
        Platform.OS.Android -> "Android"
        Platform.OS.IOS -> "iOS"
        Platform.OS.Windows -> "Windows"
        Platform.OS.Linux -> "Linux"
        Platform.OS.MacOSX -> "MacOS"
        else -> "Unknown"
    }
    
    Button(onClick = { println("Hello from $platformName") }) {
        Text(stringResource(Res.string.hello))
    }
}

Example Implementations of Other Platform-Aware UI Elements

a. Dialogs and Alerts

Displaying dialogs and alerts requires using platform-specific APIs:

// Common code
expect fun showAlertDialog(
    title: String,
    message: String,
    confirmButtonText: String,
    onConfirm: () -> Unit
)

// Android implementation
actual fun showAlertDialog(
    title: String,
    message: String,
    confirmButtonText: String,
    onConfirm: () -> Unit
) {
    AlertDialog(
        onDismissRequest = { },
        title = { Text(title) },
        text = { Text(message) },
        confirmButton = {
            Button(onClick = onConfirm) {
                Text(confirmButtonText)
            }
        },
        dismissButton = null // No dismiss button for simplicity
    )
}

// iOS implementation
actual fun showAlertDialog(
    title: String,
    message: String,
    confirmButtonText: String,
    onConfirm: () -> Unit
) {
    val alert = UIAlertController.alertControllerWithTitle(
        title = title,
        message = message,
        preferredStyle = UIAlertControllerStyle.UIAlertControllerStyleAlert
    )
    val action = UIAlertAction.actionWithTitle(
        title = confirmButtonText,
        style = UIAlertActionStyle.UIAlertActionStyleDefault,
        handler = { _ -> onConfirm() }
    )
    alert.addAction(action)

    val currentViewController = UIApplication.sharedApplication.keyWindow?.rootViewController
    currentViewController?.presentViewController(alert, animated = true, completion = null)
}

b. Date Pickers

Date pickers also rely on platform-specific components. The shared interface simplifies integration across platforms.

// Common code
expect fun datePickerDialog(
    onDateSelected: (year: Int, month: Int, day: Int) -> Unit
)

// Android implementation
@Composable
actual fun datePickerDialog(
    onDateSelected: (year: Int, month: Int, day: Int) -> Unit
) {
    val context = LocalContext.current
    val calendar = Calendar.getInstance()
    val year = calendar.get(Calendar.YEAR)
    val month = calendar.get(Calendar.MONTH)
    val day = calendar.get(Calendar.DAY_OF_MONTH)
    val datePickerDialog = DatePickerDialog(
        context,
        { _, selectedYear, selectedMonth, selectedDay ->
            onDateSelected(selectedYear, selectedMonth, selectedDay)
        },
        year,
        month,
        day
    )

    datePickerDialog.show()
}

// iOS implementation
actual fun datePickerDialog(
    onDateSelected: (year: Int, month: Int, day: Int) -> Unit
) {
    // iOS Date Picker implementation with UIDatePicker and callbacks
}

Layout Considerations

Different platforms have unique layout conventions:

  • Android: Typically utilizes Material Design components and flexible layouts.
  • iOS: Employs UIKit elements with constraints and auto layout.
  • Desktop: Focuses on responsive layouts optimized for larger screens.

Handling Platform-Specific Behaviors

In addition to UI elements, handling platform-specific behaviors is vital. Consider:

  • Navigation: Implement platform-appropriate navigation patterns.
  • Input Methods: Support keyboard and touch inputs efficiently.
  • Theming: Adapt color schemes and styling based on platform guidelines.

Conclusion

Creating UI elements in a Compose Multiplatform app involves abstracting components and utilizing platform-specific implementations. By employing techniques like expect/actual declarations and custom composables, developers can build a unified UI codebase while catering to platform conventions. This ensures a seamless and intuitive user experience across various platforms.