Skip to content

roywatson/cmp_custom_theme

Repository files navigation

Compose Multiplatform Custom Theme

A focused, production-quality example of building a custom theme system for Compose Multiplatform that runs unchanged on Android, iOS, and JVM desktop — a complete, standalone replacement for MaterialTheme, not an extension of it, not a wrapper around it, and not dependent on it in any way.

This project is the multiplatform companion to Android Custom Theme, which builds the same theme system targeting Android alone. The architecture is identical: CompositionLocal distribution, @Immutable color interfaces, @Stable singletons for metrics and typography, remember-keyed caching, and MutableState theme switching. All of that lives in commonMain and is shared without modification across all three platforms. The only platform-specific code is dark mode detection — a single expect fun isSystemInDarkMode() with three actual implementations, one per platform.

The demo app is intentionally minimal. It omits ViewModels, Repositories, navigation, and dependency injection not because production apps don't need them, but because they are orthogonal to what is being demonstrated. The theme system has no opinion about how the rest of your application is structured, and integrates cleanly alongside any architecture you choose.

A companion article walks through the design decisions in detail. (link forthcoming)


What this demonstrates

Most theming tutorials stop at swapping a few color values. This project shows the complete picture across three platforms:

  • A CompositionLocal-based theme that distributes colors, metrics, and typography through the composition tree without passing parameters everywhere — the same code on all platforms
  • Three-mode theme switching (System / Light / Dark) with a single MutableState — no ViewModel, no state management library required
  • Reactive system dark-mode detection on all three platforms: event-driven on Android via isSystemInDarkTheme(), polled via produceState on iOS and JVM desktop
  • The expect/actual pattern used at precisely one point of divergence — leaving the vast majority of the codebase in commonMain, shared and unmodified
  • OsType detection on JVM desktop, covering macOS, Windows, and Linux, used to invoke the right OS-level query for dark mode state
  • @Immutable and @Stable annotations applied correctly so the Compose compiler can skip unnecessary recompositions
  • remember(key) used to cache color objects so new instances are not allocated on every recomposition
  • Singleton object declarations for stateless theme components (metrics and typography) that have no reason to be instantiated more than once
  • Color utility extensions (darken, lighten) with consistent parameter semantics and correct alpha preservation
  • A three-tier test structure: common tests that compile and run on every platform, Android instrumented tests, and JVM-specific unit tests

Project structure

commonMain/
  theme/
    CustomTheme.kt          # CompositionLocals, CurrentTheme enum, CustomTheme composable and accessor object
    CustomThemeColors.kt    # @Immutable color interface + Dark and Light implementations
    CustomThemeMetrics.kt   # @Stable singleton — spacing and corner radii
    CustomTypeStyles.kt     # @Stable singleton — text styles
  utils/
    SystemMode.kt           # expect fun isSystemInDarkMode() — declared here, implemented per platform
    extensions.kt           # Color.darken() / Color.lighten()
  App.kt                    # Demo composable — shared across all platforms

androidMain/
  utils/
    SystemMode.kt           # actual — isSystemInDarkTheme() (reactive via LocalConfiguration)
  MainActivity.kt           # Entry point; sets up edge-to-edge

iosMain/
  utils/
    SystemMode.kt           # actual — UITraitCollection polling via produceState
  MainViewController.kt     # Entry point for UIKit

jvmMain/
  system/
    OsType.kt               # Enum — macOS / Windows / Linux detection
  utils/
    SystemMode.kt           # actual — OS process polling via produceState
  main.kt                   # Entry point; opens the desktop window

commonTest/
  ComposeAppCommonTest.kt   # CurrentTheme.next() cycling, color extension math

androidInstrumentedTest/
  ThemeInstrumentedTest.kt  # Compose UI test: verifies CustomTheme initialises to System mode

jvmTest/
  system/
    OsTypeTest.kt           # OS detection logic and enum completeness

How the theme system works

Distribution

CustomTheme is a composable wrapper at the top of App() in commonMain. Because App() is the root composable on all three platforms, the theme is applied once and shared everywhere — no platform-specific wiring required.

Anywhere inside that tree, access theme values through the CustomTheme object:

Text(
    text = "Hello",
    color = CustomTheme.colorScheme.textOne,
    style = CustomTheme.typeScheme.textOne,
)

No prop-drilling. No ambient singletons. The values flow through CompositionLocal — the standard Compose mechanism for implicit tree-scoped data. This works identically on all three platforms because CompositionLocal is part of the shared Compose runtime in commonMain.

Theme switching

The current theme mode is held in a MutableState<CurrentTheme> exposed via CustomTheme.themeMode. Changing it is one line:

val mode = CustomTheme.themeMode
Button(onClick = { mode.value = mode.value.next() }) { ... }

next() cycles through System → Light → Dark → System. No when blocks at the call site — the enum owns its own transition logic. This code is in commonMain and runs identically on all three platforms.

System dark mode

CurrentTheme.System defers to the OS setting at runtime. How that setting is read differs by platform, but the interface is identical: a single @Composable function isSystemInDarkMode() declared as expect in commonMain with three actual implementations.

Android uses Compose's isSystemInDarkTheme(), which reads from LocalConfiguration.current. Because it is a composition local, Compose automatically recomposes affected subtrees when the system theme changes — no broadcast receivers, no lifecycle observers required.

iOS reads UITraitCollection.currentTraitCollection.userInterfaceStyle inside a produceState block. The value is checked every two seconds; when it changes, produceState updates its State, which triggers recomposition in any composable that reads the result.

JVM desktop spawns a short-lived OS process to query the system dark mode setting — defaults read on macOS, a registry query on Windows, gsettings on Linux — also inside produceState with a two-second polling interval. The OsType enum selects the right command at startup using System.getProperty("os.name"), and the blocking process calls are dispatched on Dispatchers.IO to keep the main thread free.

The expect/actual pattern

The expect declaration lives in commonMain/utils/SystemMode.kt:

@Composable
expect fun isSystemInDarkMode(): Boolean

Each platform provides its actual in its own utils/SystemMode.kt. The compiler enforces that every declared platform target has an implementation. The function is @Composable on both sides, so each actual has access to the full Compose toolkit — produceState, remember, composition locals — without restriction.

This is intentionally the only expect/actual in the project. The goal is to show how little platform-specific code a multiplatform theme actually requires: one function declaration, three implementations, everything else shared.

Adding a new color

  1. Add a property to the CustomThemeColors interface.
  2. Implement it in both CustomThemeColorsDark and CustomThemeColorsLight.
  3. Access it anywhere via CustomTheme.colorScheme.yourProperty.

The compiler enforces that both implementations stay in sync. This process is identical to the Android project.

Adding new metrics or type styles

Add a property to the CustomThemeMetrics or CustomTypeStyles object. Because these are singletons with no light/dark variants, there is nothing else to update.


Extending to more than two color schemes

The when block in CustomTheme.kt is the only place that maps a CurrentTheme value to a color implementation. Adding a high-contrast scheme, for example, means:

  1. Adding HighContrast to the CurrentTheme enum
  2. Creating a CustomThemeColorsHighContrast class
  3. Adding one branch to the when block

Everything else — the CompositionLocal distribution, the state management, the next() cycling, the platform entry points — adapts automatically. Because this logic is in commonMain, the change applies to all three platforms simultaneously.


Running the tests

Common and JVM tests (no device required):

./gradlew :composeApp:jvmTest

This runs both the commonTest source set (shared logic: CurrentTheme.next() cycling, color extension math) and the jvmTest source set (OsTypeTest). The common tests compile independently for each platform; running them against the JVM is the fastest way to validate shared logic during development.

Android instrumented tests (requires a connected device or emulator):

./gradlew :composeApp:connectedAndroidTest

All non-device tests:

./gradlew :composeApp:allTests

The common tests cover CurrentTheme.next() cycling and the darken/lighten extension math. One subtlety worth noting: Compose's lerp(Color, Color, Float) converts to a linear light color space before interpolating and converts back afterward, so midpoint channel values do not follow simple sRGB fractions. The tests account for this by asserting direction (darker/lighter than the original) rather than exact channel values, and use a tolerance of 0.002f for boundary assertions to accommodate 8-bit channel quantization (maximum rounding error: 0.5 / 255 ≈ 0.00196).

The JVM-specific OsTypeTest verifies that OsType.current correctly identifies the platform running the tests and that the enum's entries are complete.


Tech stack

Language Kotlin 2.3.21
UI framework Compose Multiplatform 1.10.3
Android Gradle Plugin 8.13.2
Min SDK (Android) 24 (Android 7.0)
Target SDK (Android) 36
JVM target 11

Bonus: iOS simulator on Intel Macs

This is unrelated to the theme system, but worth noting for developers who are just getting started with Compose Multiplatform on an Intel-based Mac.

Newer KMP project templates sometimes generate an Xcode project that fails to build for the iOS simulator on Intel hardware. Two files need to be patched:

composeApp/build.gradle.kts — add iosX64() to the iOS target list:

listOf(
    iosArm64(),
    iosSimulatorArm64(),
    iosX64()            // required for Intel Mac simulators
)

iosApp/iosApp.xcodeproj/project.pbxproj — in both the Debug and Release XCBuildConfiguration blocks for the iosApp target, change:

ARCHS = arm64;

to:

ARCHS = (arm64, x86_64);

Both fixes are already applied in this project.


About

Before Roy Watson wrote a line of Android code, he was programming the propellant management system for NASA's Delta launch vehicles, building radiation detection software for Los Alamos and Sandia National Laboratories, and developing embedded vision systems for IBM's autonomous vehicle research. After 30+ years as a systems developer, he approaches Android differently than most.

Over the past 16 years he has specialized in Android, staying current with every major evolution of the platform: Kotlin, Jetpack Compose, and now Compose Multiplatform (KMP) for iOS, Desktop, and Web. Recent clients include the New York Public Library, Auddia, Bechtel, and Goodyear. When performance work requires dropping into C/C++, SIMD intrinsics, or custom memory management, he does not hand it off.

His personal published apps have accumulated 70,000+ Android downloads (4.5★) and 207,000+ iOS downloads (4.6★). He studied Physics and Mathematics at Purdue University and is a member of Mensa International.

This project is part of a series of public examples demonstrating current Android and Compose Multiplatform architecture and patterns. The series begins with Android Custom Theme, a focused look at building the same theme system for Android alone. The two projects are designed to be read together: the Android project establishes the architecture; this project shows how naturally that architecture extends to multiple platforms, and how little additional code multiplatform actually requires.

Available for contract and full-time engagements — roywatson.app

roywatson.app · linkedin.com/in/roywatson3 · github.com/roywatson

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors