When building dark mode and dynamic theming with Kotlin and Jetpack Compose, seamless user experience and color management are essential. But how do you create a theming system that not only adapts beautifully between light/dark modes, but also leverages Android 12+'s Material You dynamic colors and advanced Kotlin features like Flows, DataStore, and type-safe theme management ?
In this comprehensive guide, I'll walk you through a production-ready theming system that showcases Kotlin's reactive programming with Compose's Material Design 3. We'll explore patterns like dynamic color extraction, theme persistence with DataStore, Flow-based theme switching, platform-specific feature detection, and font scalingβall while maintaining perfect UX across different Android versions.
By the end of this article, you'll master building sophisticated theming systems that seamlessly switch between light/dark modes, integrate Material You dynamic colors, and provide users with complete control over their app's visual experience.
ποΈ Architecture: MVVM + Flow Reactive Excellence
Our Settings screen demonstrates complete MVVM architecture with reactive data persistence using Kotlin's most advanced features:
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = SettingsRepository(app)
val ui = repo.settings.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = UiSettings()
)
fun setTheme(mode: ThemeMode) = viewModelScope.launch { repo.setThemeMode(mode) }
fun setDynamic(enabled: Boolean) = viewModelScope.launch { repo.setDynamicColor(enabled) }
fun setFontScale(scale: Float) = viewModelScope.launch { repo.setFontScale(scale) }
}
Why This Kotlin-First Architecture Excels:
π Reactive Flow Architecture:
val ui = repo.settings.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = UiSettings()
)
-
Flow to State:
stateIn
converts cold Flow to hot StateFlow -
Lifecycle Awareness:
viewModelScope
ensures proper cleanup -
Lazy Loading:
SharingStarted.Lazily
optimizes resource usage - Initial Value: Type-safe default prevents null states
β‘ Coroutine-Based Actions:
fun setTheme(mode: ThemeMode) = viewModelScope.launch { repo.setThemeMode(mode) }
- Single Expression Functions: Kotlin's concise syntax
-
Scope Management:
viewModelScope
handles cancellation automatically - Suspend Delegation: Repository handles async persistence
-
Type Safety:
ThemeMode
enum prevents invalid values
π Repository Pattern with DataStore:
- Clean Separation: ViewModel doesn't know about DataStore implementation
- Testable: Repository can be mocked with fake implementations
- Type-Safe: Kotlin's type system prevents preference key mistakes
π DataStore Integration: Kotlin's Modern Persistence
Our settings demonstrate DataStore Preferences with advanced Kotlin patterns:
// Extension property for clean DataStore access
private val Context.userPrefsDataStore by preferencesDataStore(name = "user_prefs")
class SettingsRepository(private val context: Context) {
val settings: Flow<UiSettings> = context.userPrefsDataStore.data.map { preferences ->
UiSettings(
themeMode = ThemeMode.values()
.getOrElse(preferences[Keys.THEME_MODE] ?: 0) { ThemeMode.System },
dynamicColor = preferences[Keys.DYNAMIC_COLOR] ?: true,
fontScale = preferences[Keys.FONT_SCALE] ?: 1.0f
)
}
suspend fun setThemeMode(mode: ThemeMode) {
context.userPrefsDataStore.edit { preferences ->
preferences[Keys.THEME_MODE] = mode.ordinal
}
}
}
Advanced Kotlin DataStore Patterns:
π― Extension Properties for Clean Access:
private val Context.userPrefsDataStore by preferencesDataStore(name = "user_prefs")
-
Property Delegation:
by preferencesDataStore
creates singleton instance - Extension Properties: Clean API without static factory methods
- Lazy Initialization: DataStore created only when first accessed
- Thread Safety: Built-in synchronization across app components
π§ Type-Safe Preference Keys:
private object Keys {
val THEME_MODE = intPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val FONT_SCALE = floatPreferencesKey("font_scale")
}
- Object Declaration: Singleton pattern for preference keys
-
Typed Keys:
intPreferencesKey
prevents type mismatches - Compile-Time Safety: Wrong types caught at compile time
- Private Keys: Encapsulated implementation details
π Flow Transformation Excellence:
val settings: Flow<UiSettings> = context.userPrefsDataStore.data.map { preferences ->
UiSettings(
themeMode = ThemeMode.values().getOrElse(preferences[Keys.THEME_MODE] ?: 0) { ThemeMode.System },
dynamicColor = preferences[Keys.DYNAMIC_COLOR] ?: true,
fontScale = preferences[Keys.FONT_SCALE] ?: 1.0f
)
}
π Kotlin Features in Action:
-
Flow Operators:
map
transforms raw preferences to typed data -
Safe Navigation:
getOrElse
provides fallbacks for invalid enum values -
Elvis Operator:
?:
provides defaults for missing preferences -
Data Classes:
UiSettings
auto-generates useful methods
π¨ State Management: Enum Classes & Type Safety
Our theme system showcases sealed classes and enum excellence:
enum class ThemeMode { System, Light, Dark }
data class UiSettings(
val themeMode: ThemeMode = ThemeMode.System,
val dynamicColor: Boolean = true,
val fontScale: Float = 1.0f
)
Kotlin Type Safety Advantages:
π― Enum for Discrete States:
enum class ThemeMode { System, Light, Dark }
-
Exhaustive Checking:
when
expressions must handle all cases - Type Safety: Impossible to pass invalid theme modes
-
Ordinal Persistence:
mode.ordinal
for efficient DataStore storage -
String Conversion: Automatic
toString()
for debugging
π Immutable Data Classes:
data class UiSettings(
val themeMode: ThemeMode = ThemeMode.System,
val dynamicColor: Boolean = true,
val fontScale: Float = 1.0f
)
- Default Values: Sensible defaults reduce boilerplate
-
Immutability:
val
properties prevent accidental mutations - Copy Function: Auto-generated for state updates
-
Structural Equality: Automatic
equals()
andhashCode()
β‘ Safe Enum Conversion:
ThemeMode.values().getOrElse(preferences[Keys.THEME_MODE] ?: 0) { ThemeMode.System }
-
Array Access:
ThemeMode.values()[index]
with safety -
Bounds Checking:
getOrElse
prevents index out of bounds - Fallback Logic: Returns default when invalid ordinal found
π Dynamic UI: Platform Detection & Conditional Logic
Our settings screen demonstrates platform-aware features with Kotlin's expressive conditionals:
// Top-level property for platform detection
val dynamicSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@Composable
fun SettingsScreen(vm: SettingsViewModel) {
val ui by vm.ui.collectAsStateWithLifecycle()
// Dynamic color section with platform awareness
Row {
Column(Modifier.weight(1f)) {
Text("Dynamic Colors", style = MaterialTheme.typography.titleMedium)
Text(
"Adapts palette to wallpaper (Android 12+)",
style = MaterialTheme.typography.bodySmall
)
}
Switch(
checked = ui.dynamicColor && dynamicSupported,
onCheckedChange = { vm.setDynamic(it) },
enabled = dynamicSupported
)
}
}
Advanced Kotlin UI Patterns:
π― Smart Platform Detection:
val dynamicSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
- Top-Level Properties: Computed once, reused throughout app
- Boolean Logic: Simple comparison for feature availability
- SDK Constants: Type-safe API level checking
π§ Conditional State Logic:
checked = ui.dynamicColor && dynamicSupported,
enabled = dynamicSupported
- Boolean Combinations: Logical AND for complex conditions
- State Dependency: Switch reflects both user preference and platform capability
- UX Consistency: Disabled state when feature unavailable
β‘ Reactive State Collection:
val ui by vm.ui.collectAsStateWithLifecycle()
-
Property Delegation:
by
creates reactive property - Lifecycle Awareness: Collection stops when screen hidden
- Automatic Recomposition: UI updates when settings change
ποΈ Filter Chips: Kotlin's Declarative UI Excellence
Our theme selector demonstrates declarative UI patterns with Kotlin's expressive syntax:
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilterChip(
selected = ui.themeMode == ThemeMode.System,
onClick = { vm.setTheme(ThemeMode.System) },
label = { Text("System") }
)
FilterChip(
selected = ui.themeMode == ThemeMode.Light,
onClick = { vm.setTheme(ThemeMode.Light) },
label = { Text("Light") }
)
FilterChip(
selected = ui.themeMode == ThemeMode.Dark,
onClick = { vm.setTheme(ThemeMode.Dark) },
label = { Text("Dark") }
)
}
Declarative UI Patterns:
π― Enum-Driven Selection Logic:
selected = ui.themeMode == ThemeMode.System,
onClick = { vm.setTheme(ThemeMode.System) }
- Equality Comparison: Direct enum comparison for selection state
- Lambda Expressions: Clean event handling
- Type Safety: Impossible to pass wrong enum values
- Consistent Pattern: Same structure for all theme options
β‘ Potential Kotlin Enhancement:
// Could be refactored with Kotlin collections
@Composable
fun ThemeSelector(currentTheme: ThemeMode, onThemeChange: (ThemeMode) -> Unit) {
val themes = listOf(
ThemeMode.System to "System",
ThemeMode.Light to "Light",
ThemeMode.Dark to "Dark"
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
themes.forEach { (mode, label) ->
FilterChip(
selected = currentTheme == mode,
onClick = { onThemeChange(mode) },
label = { Text(label) }
)
}
}
}
ποΈ Advanced Slider: Value Formatting & Bounds Checking
Our font scale slider showcases numerical precision and Kotlin formatting:
Column {
Slider(
value = ui.fontScale,
onValueChange = vm::setFontScale, // Method reference syntax
valueRange = 0.85f..1.30f, // Range operator
steps = 8,
modifier = Modifier.fillMaxWidth()
)
Text("Scale: ${"%.2f".format(ui.fontScale)}Γ") // String formatting
// Live preview
Text(
"Sample paragraph with current text size.",
style = MaterialTheme.typography.bodyLarge
)
}
Kotlin Numerical Excellence:
π― Method Reference Syntax:
onValueChange = vm::setFontScale
-
Method References:
::setFontScale
cleaner than lambda - Type Safety: Compiler ensures correct signature
- Performance: Slightly more efficient than lambda creation
- Readability: Intent is crystal clear
π’ Range Operators for Bounds:
valueRange = 0.85f..1.30f
-
Range Syntax: Inclusive range with
..
operator - Float Precision: Explicit float literals prevent double conversion
-
Type Inference: Compiler infers
ClosedFloatingPointRange<Float>
π‘ String Formatting with Templates:
Text("Γchelle: ${"%.2f".format(ui.fontScale)}Γ")
-
String Templates:
${}
for expressions in strings - Format Strings: Java-style formatting with Kotlin syntax
-
Precision Control:
%.2f
for 2 decimal places
π Repository-Level Bounds Checking:
suspend fun setFontScale(scale: Float) {
context.userPrefsDataStore.edit {
it[Keys.FONT_SCALE] = scale.coerceIn(0.85f, 1.30f)
}
}
-
Extension Function:
coerceIn
ensures valid range - Data Integrity: Invalid values clamped at persistence layer
- Defense in Depth: UI and repository both validate bounds
π§ͺ Testing Strategies: Flow Testing & Coroutine Excellence
While our current settings don't include tests, here's how Kotlin would make testing elegant:
Repository Testing with Flows
@Test
fun settingsFlow_emitsCorrectDefaults() = runTest {
val fakeContext = mockContext()
val repository = SettingsRepository(fakeContext)
val settings = repository.settings.first()
assertEquals(ThemeMode.System, settings.themeMode)
assertEquals(true, settings.dynamicColor)
assertEquals(1.0f, settings.fontScale, 0.01f)
}
@Test
fun setThemeMode_updatesFlow() = runTest {
val repository = SettingsRepository(mockContext())
repository.setThemeMode(ThemeMode.Dark)
val settings = repository.settings.first()
assertEquals(ThemeMode.Dark, settings.themeMode)
}
ViewModel Testing with TestDispatcher
@Test
fun setTheme_updatesRepository() = runTest {
val fakeRepo = FakeSettingsRepository()
val viewModel = SettingsViewModel(fakeRepo)
viewModel.setTheme(ThemeMode.Light)
advanceUntilIdle()
assertEquals(ThemeMode.Light, fakeRepo.lastThemeMode)
}
Kotlin Testing Advantages:
π§ͺ Coroutine Test Support:
-
Virtual Time:
runTest
for deterministic async testing -
Flow Testing:
first()
,toList()
for Flow assertions - Fake Repositories: Kotlin makes clean test doubles easy
β‘ Type-Safe Assertions:
- Enum Equality: Direct comparison without string matching
- Float Precision: Proper delta for floating-point assertions
- Null Safety: No null pointer exceptions in tests
π Performance & Production Considerations
DataStore Optimization Patterns
β‘ Efficient Preference Updates:
suspend fun updateMultipleSettings(theme: ThemeMode, dynamic: Boolean) {
context.userPrefsDataStore.edit { preferences ->
preferences[Keys.THEME_MODE] = theme.ordinal
preferences[Keys.DYNAMIC_COLOR] = dynamic
} // Single transaction for atomicity
}
π Flow Transformation Efficiency:
val settings: Flow<UiSettings> = context.userPrefsDataStore.data
.distinctUntilChanged() // Avoid unnecessary recomposition
.map { preferences ->
// Expensive transformations here
}
Kotlin Memory Optimization
π― Object Reuse:
// Companion object for constants
companion object {
private val DEFAULT_SETTINGS = UiSettings()
}
β‘ Lazy Initialization:
val repository by lazy { SettingsRepository(applicationContext) }
π Key Takeaways & Kotlin Insights
ποΈ Architecture Lessons
- MVVM with Flows: Reactive architecture scales beautifully with complexity
- DataStore Integration: Modern persistence with type-safe APIs
- Repository Pattern: Clean separation enables testing and flexibility
- Sealed Classes: Type-safe state management prevents runtime errors
β‘ Kotlin Language Features
-
Flow Operators:
map
,distinctUntilChanged
for reactive transformations -
Property Delegation:
by preferencesDataStore
for clean APIs - Extension Properties: Context extensions for elegant DataStore access
-
Method References:
vm::setFontScale
for concise event handling -
Range Operators:
0.85f..1.30f
for expressive value bounds
π Reactive Programming
- StateFlow: Hot streams that survive configuration changes
- collectAsStateWithLifecycle: Lifecycle-aware UI state collection
- Flow Transformation: Clean data mapping from preferences to UI models
- Coroutine Scoping: Proper async lifecycle management
π¨ UI Excellence
- Declarative Settings: State-driven UI that updates automatically
- Platform Awareness: Dynamic feature detection with clean conditionals
- Type-Safe Interactions: Enums prevent invalid state transitions
- Live Previews: Immediate feedback for user preferences
π Final Thoughts
This Settings screen demonstrates how Kotlin's reactive programming model transforms preference management from a chore into an elegant experience. By leveraging Flow APIs, DataStore integration, and MVVM architecture, we've built a settings interface that's not only beautiful and responsive but also maintainable and testable.
The combination of Kotlin's type safety, reactive streams, and Compose's declarative paradigm creates settings screens that feel native to both the platform and the development experience. From enum-driven theme selection to Flow-based persistence, every pattern scales to meet production requirements.
Top comments (0)