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.