Jetpack Compose is revolutionizing UI development across multiple platforms, allowing developers to write UI code once and deploy it on Android, iOS, desktop, and web. Consistent theming is vital for maintaining a unified user experience across these platforms. This blog post delves into theming in Compose Multiplatform apps, covering how to define themes, apply them consistently, and address platform-specific nuances.
Understanding the Basics of Theming in Jetpack Compose
In Jetpack Compose, theming primarily revolves around the MaterialTheme
composable, which encapsulates color, typography, and shapes. Here’s a basic example:
import androidx.compose.material.MaterialTheme
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Define a custom color palette
val CustomColors = lightColors(
primary = Color(0xFF1E88E5),
secondary = Color(0xFF64B5F6),
background = Color(0xFFE3F2FD)
)
@Composable
fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colors = CustomColors,
content = content
)
}
This defines a basic theme with custom primary, secondary, and background colors. The AppTheme
composable wraps your UI, applying these styles.
Setting Up a Multiplatform Project
Before diving into theming, ensure your project is configured for Compose Multiplatform. This typically involves creating a project with the Kotlin Multiplatform plugin and setting up common, Android, iOS, desktop, and web modules.
plugins {
id("org.jetbrains.kotlin.multiplatform") version "1.9.0"
id("org.jetbrains.compose") version "1.5.1"
}
kotlin {
jvm() // Desktop and Android
iosX64()
iosArm64()
iosSimulatorArm64()
js(IR) { // Web
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
}
}
val androidMain by getting {
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.core:core-ktx:1.11.0")
implementation(compose.uiToolingPreview)
debugImplementation(compose.uiTooling)
}
}
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting {
dependsOn(iosX64Main)
}
val iosMain by getting {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
}
val jvmMain by getting {
dependencies {
implementation(compose.desktop.ui)
}
}
val jsMain by getting {
dependencies {
implementation(compose.html.core)
}
}
}
}
Implementing Multiplatform Theming
To implement theming across multiple platforms, you’ll need to define themes in the commonMain
source set and apply them in platform-specific modules. Here’s how:
Step 1: Define Themes in commonMain
Create theme definitions (colors, typography, shapes) in the commonMain
source set. This ensures these definitions are accessible across all platforms.
// commonMain/kotlin/AppTheme.kt
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Shapes
import androidx.compose.material.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Common color definitions
object AppColors {
val Primary = Color(0xFF1E88E5)
val Secondary = Color(0xFF64B5F6)
val Background = Color(0xFFE3F2FD)
val Surface = Color.White
val Error = Color(0xFFB00020)
}
// Common typography
val AppTypography = Typography()
// Common shapes
val AppShapes = Shapes()
// Common theme definition
@Composable
fun AppTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
colors = Colors(
primary = AppColors.Primary,
primaryVariant = AppColors.Primary,
secondary = AppColors.Secondary,
background = AppColors.Background,
surface = AppColors.Surface,
error = AppColors.Error,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.Black,
onSurface = Color.Black,
onError = Color.White,
isLight = true
),
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
Step 2: Apply Themes in Platform-Specific Modules
In each platform-specific module (e.g., androidMain
, iosMain
, jvmMain
, jsMain
), wrap the root composable of your application with the AppTheme
.
Android
// androidMain/kotlin/MainActivity.kt
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import com.example.myapp.App
import com.example.myapp.AppTheme // Assuming package name
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
App() // Root composable
}
}
}
}
iOS
For iOS, you’ll typically use Compose within a SwiftUI view. Apply the theme to the root Compose view.
// iosApp/iOSApp.swift
import SwiftUI
import Compose
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ComposeUIViewControllerRepresentable()
.edgesIgnoringSafeArea(.all)
}
}
}
struct ComposeUIViewControllerRepresentable: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
Main_iosKt.MainViewController() // Replace MainViewController with your actual entry point
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
Inside the Kotlin code for Main_iosKt
:
// iosMain/kotlin/Main.kt
import androidx.compose.ui.window.ComposeUIViewController
import com.example.myapp.App
import com.example.myapp.AppTheme // Assuming package name
fun MainViewController() = ComposeUIViewController {
AppTheme {
App()
}
}
Desktop
// jvmMain/kotlin/Main.kt
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import com.example.myapp.App
import com.example.myapp.AppTheme // Assuming package name
fun main() = application {
Window(title = "My App") {
AppTheme {
App()
}
}
}
Web
// jsMain/kotlin/Main.kt
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.CanvasBasedWindow
import com.example.myapp.App
import com.example.myapp.AppTheme // Assuming package name
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
CanvasBasedWindow("My App") {
AppTheme {
App()
}
}
}
Handling Platform-Specific Theme Variations
Sometimes, minor adjustments are needed to align with platform-specific design conventions. You can achieve this by introducing platform-specific theme variations.
Conditional Theming
Use Kotlin’s expect/actual
mechanism to define platform-specific theme properties.
// commonMain/kotlin/ThemeUtils.kt
expect fun platformSpecificColor(): Color
// androidMain/kotlin/ThemeUtils.kt
actual fun platformSpecificColor(): Color = Color(0xFF00FF00) // Android-specific color
// iosMain/kotlin/ThemeUtils.kt
actual fun platformSpecificColor(): Color = Color(0xFF0000FF) // iOS-specific color
In your theme:
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colors = MaterialTheme.colors.copy(primary = platformSpecificColor()),
content = content
)
}
Using Platform-Specific Resources
Access platform-specific resources (like fonts or icons) within your theme definitions using resource loading mechanisms available on each platform.
Best Practices for Multiplatform Theming
- Centralize Theme Definitions: Keep theme definitions in the
commonMain
source set to ensure consistency. - Use Semantic Color Names: Use names like
primary
,secondary
, andbackground
rather than hardcoding color values directly in your UI. - Provide Dark and Light Themes: Implement both light and dark themes and allow users to switch between them.
- Leverage System Theme: Detect and adapt to the system-level theme settings (e.g., dark mode on Android and iOS).
- Test Thoroughly: Test your themes on all target platforms to ensure they render correctly and look consistent.
Advanced Theming Techniques
Dynamic Theming
Dynamically adjust your theme based on user preferences or external data. Use MutableState
to hold theme properties and update them at runtime.
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
fun MyApp() {
val primaryColor = remember { mutableStateOf(Color(0xFF1E88E5)) }
val dynamicTheme = MaterialTheme.colors.copy(primary = primaryColor.value)
MaterialTheme(colors = dynamicTheme) {
// UI content
}
}
Custom Theme Attributes
Define custom theme attributes to encapsulate platform-specific or application-specific styling.
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
data class ExtendedColors(
val success: Color,
val warning: Color
)
val LocalExtendedColors = staticCompositionLocalOf {
ExtendedColors(success = Color.Green, warning = Color.Yellow)
}
object AppTheme {
val extendedColors: ExtendedColors
@Composable
@ReadOnlyComposable
get() = LocalExtendedColors.current
}
@Composable
fun ProvideAppTheme(
content: @Composable () -> Unit
) {
val extendedColors = remember { ExtendedColors(success = Color.Green, warning = Color.Yellow) }
CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
content()
}
}
@Composable
fun MyComposable() {
val successColor = AppTheme.extendedColors.success
Text("Success", color = successColor)
}
Conclusion
Theming in Compose Multiplatform apps requires a well-structured approach to ensure consistency and adaptability across various platforms. By centralizing theme definitions, addressing platform-specific nuances, and following best practices, you can create visually appealing and cohesive user experiences. Embrace the power of Compose to deliver high-quality, cross-platform applications with ease.