Pantrix UI

System bars that follow the theme

Why, in a Compose Multiplatform app with a dark theme switch of its own, the parts the system draws, Android's status and navigation bars and iOS's status bar, keyboard and controls, stay in the system's dark mode, what the library gives for it, and how it is tested. The problem is not Pantrix's own: it shows the same way in every Compose Multiplatform app that lets its reader choose its theme.

What is said here of Compose and the platforms was read in androidx.activity's EdgeToEdge.kt, androidx.core's WindowInsetsControllerCompat and UIKit's documentation of UIViewController.overrideUserInterfaceStyle. What could not be verified is counted in section 7.

1. The problem

Symptom

Cause

How the bars and the system's parts look is not in the app's theme but in an object of the platform:

PlatformWhat sets itBy default
AndroidWindowInsetsControllerCompat.isAppearanceLightStatusBars and isAppearanceLightNavigationBars (a bar with dark icons is called a light bar)enableEdgeToEdge() sets them by the system's night mode (SystemBarStyle.auto)
iOSThe view controller's traitCollection.userInterfaceStyle; the status bar, the keyboard and UIKit's controls follow itThe system's mode
DesktopThe window's frame is the operating system'sThe system's theme
WebThe browser's own barsThe browser's theme

No API in Compose's common code reaches any of them: isSystemInDarkTheme() only reads the system's mode, and tells the system nothing of the app's choice.

2. The solution

The outermost theme is the one place that knows whether the screen is dark or light. After every composition it tells the platform: on Android it sets the window's bar icons, on iOS the interface style of the app's view controller. A theme inside it (a dark card on a light screen) leaves the bars alone: the window's bars belong to the screen as a whole.

3. With Pantrix

PantrixTheme does it by itself; the app writes nothing for it. The dark its reader chose is given to the theme, and the bars follow:

val dark = settings.dark ?: isSystemInDarkTheme()
PantrixTheme(preset = Brand, colors = rememberPantrixColors(Brand, darkTheme = dark)) {
    App()
}

3.1. An app that sets its bars itself

An app that paints its bars in colours of its own (and does not draw edge to edge), or chooses their icons itself, tells the theme to leave them:

PantrixTheme(preset = Brand, followSystemBars = false) {
    App()
}

Pantrix's own layers (a sheet, a dialog) still read their window's icons from the app's window: the app's choice reaches them too.

4. Without Pantrix

4.1. Common code (commonMain)

/** Makes the parts the system draws follow the [dark] theme; called once, inside the outermost theme. */
@Composable
expect fun FollowSystemBars(dark: Boolean)

At the app's root, right inside its theme:

MyTheme(dark = dark) {
    FollowSystemBars(dark)
    App()
}

4.2. The platforms' counterparts

// androidMain
@Composable
actual fun FollowSystemBars(dark: Boolean) {
    val view = LocalView.current
    if (view.isInEditMode) return
    SideEffect {
        val window = view.context.activity()?.window ?: return@SideEffect
        WindowCompat.getInsetsController(window, view).apply {
            // Android calls a bar with dark icons a light bar.
            isAppearanceLightStatusBars = !dark
            isAppearanceLightNavigationBars = !dark
        }
    }
}

private tailrec fun Context.activity(): Activity? = when (this) {
    is Activity -> this
    is ContextWrapper -> baseContext.activity()
    else -> null
}
// iosMain
@Composable
actual fun FollowSystemBars(dark: Boolean) {
    val controller = LocalUIViewController.current
    SideEffect {
        controller.overrideUserInterfaceStyle =
            if (dark) UIUserInterfaceStyle.UIUserInterfaceStyleDark else UIUserInterfaceStyle.UIUserInterfaceStyleLight
    }
}
// jvmMain and webMain
@Composable
actual fun FollowSystemBars(dark: Boolean) = Unit

5. Testing

On Android the bars' state is read under Robolectric: a host test sets the theme dark and light and asks the activity's window for its icons.

@Test
fun the_bars_icons_follow_the_theme_as_it_changes() = runComposeUiTest {
    var dark by mutableStateOf(true)
    var activity: Activity? = null
    setContent {
        activity = LocalActivity.current
        PantrixTheme(darkTheme = dark) { Box(Modifier.size(8.dp)) }
    }
    val window = checkNotNull(activity).window
    val bars = WindowCompat.getInsetsController(window, window.decorView)
    assertFalse(bars.isAppearanceLightStatusBars)
    dark = false
    waitForIdle()
    assertTrue(bars.isAppearanceLightStatusBars)
}

iOS's tests run without an app, in a plain Compose scene: there is no view controller there, and reading LocalUIViewController fails the test. Pantrix therefore first asks whether there is an app; the behaviour on iOS is tried on the simulator.

6. Pitfalls

7. Verification

Tests

Seen on the simulator

Behaviours broken by hand

Not verified

8. Where it is in this repository