Compose Multiplatform: App Theming with Jetpack Compose

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, and background 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.