Pantrix UI

The Material 3 bridge

How an app that uses Material 3 can move to Pantrix, or use the two together: the two themes speaking the same colours, type and shapes. The problem is not Pantrix's own.

The library itself stays free of Material: the bridge is the one module that uses Material, and no library module depends on it. Everything said here about Material 3 was read in the sources of Material 3 1.9.0 and 1.11.0-alpha07, or seen in a test that runs in this repository (section 11).

1. The problem

2. Setting up

commonMain.dependencies {
    implementation("com.pantrixui:material3:0.1.0-alpha03")
}

The module is compiled against Material 3's latest stable version, 1.9.0. This is a lower bound: if the app has a newer Material 3, Gradle picks it and the bridge works with it. After 1.9.0, Material 3's versions for CMP came out only as alphas and betas; the bridge depends on the stable one so as not to force an alpha on its user. The theme API is the same in 1.9.0 and 1.11.0-alpha07: 48 colours, 15 text styles, 5 shapes.

3. Two directions

Pantrix in a Material app

MaterialTheme(colorScheme = appScheme) {
    Scaffold { padding ->
        PantrixThemeFromMaterial {
            // Pantrix's components in the colours, type and shapes of the app's Material theme
            PantrixButton(text = "Save", onClick = {}, modifier = Modifier.padding(padding))
        }
    }
}

PantrixThemeFromMaterial reads the MaterialTheme around it and sets up a PantrixTheme. The colours are the scheme's toPantrixColors(), and the theme is as dark as the scheme. The type is built from Material's scale, on top of Pantrix's own scale; the shapes are taken by their radii. Everything else (spacing, motion, language, analytics) is Pantrix's own, or what a PantrixTheme inside gives.

As it is the screen's outermost Pantrix theme, it makes Android's bars' icons and iOS's status bar, keyboard and controls follow the scheme's dark (the system bars guide). A Material app that sets its bars itself, or whose bars are coloured, writes PantrixThemeFromMaterial(followSystemBars = false) { … }.

Material in a Pantrix app

PantrixTheme {
    MaterialThemeFromPantrix {
        // A Material component Pantrix does not have, in Pantrix's theme
        Slider(value = volume, onValueChange = { volume = it })
    }
}

MaterialThemeFromPantrix reads the Pantrix theme around it and sets up a MaterialTheme: the scheme toColorScheme(), the type scale toMaterialTypography(), the shapes toMaterialShapes().

Plain conversions

Under the two wrappers there are six functions; an app that sets up its theme itself uses them:

Material → PantrixPantrix → Material
ColorScheme.toPantrixColors(base)PantrixColors.toColorScheme()
Typography.toPantrixTypography(base)PantrixTypography.toMaterialTypography()
Shapes.toPantrixShapes(base)PantrixShapes.toMaterialShapes(fallback)

The sample app

samples/pantrix-material3 shows the two directions on one screen. The same form is drawn once with Material's components and once with Pantrix's. The app starts as a Material app (with its brand's scheme, PantrixThemeFromMaterial). With a button it turns into an app written with Pantrix's own theme (MaterialThemeFromPantrix). The screen is carried to the other theme with its values: only the look changes. Its README says how to run it.

4. Mapping tables

Colours: Material → Pantrix

PantrixMaterial
The primary role (color, onColor, container, onContainer)primary, onPrimary, primaryContainer, onPrimaryContainer
The danger roleerror, onError, errorContainer, onErrorContainer
The neutral rolesecondary, onSecondary, secondaryContainer, onSecondaryContainer
backgroundbackground
surfacesurfaceContainerLow
onSurfaceonSurface
textPrimaryonBackground
textSecondaryonSurfaceVariant
textInverseinverseOnSurface
border, borderStrongoutline
divideroutlineVariant
scrimThe colour of scrim, with the opacity of base's
success, info, warning, borderHover, focusRing, disabledContainer, disabledContent, textDisabled, chartNo counterpart in Material: from base (section 5)

Two of the mappings were chosen on purpose:

Colours: Pantrix → Material

MaterialPantrix
primary, onPrimary, primaryContainer, onPrimaryContainerThe primary role
secondary and its partnersThe neutral role
tertiary and its partnersThe info role
error and its partnersThe danger role
background, surfacebackground (the two the same, as in Material's own schemes)
onBackgroundtextPrimary
onSurfaceonSurface
onSurfaceVarianttextSecondary
surfaceContainerLowsurface
surfaceContainer, surfaceContainerHigh, surfaceContainerHighest4%, 8% and 12% of textPrimary over surface
surfaceContainerLowestThe ground farther from the text: surface in the light theme, background in the dark one
surfaceDim, surfaceBrightThe two ends: the one towards the text at 16%, the other the farthest ground
surfaceVariantsurfaceContainerHighest
surfaceTintprimary.color
inverseSurface, inverseOnSurfaceneutral.color, textInverse
inversePrimaryprimary.container
outline, outlineVariantborderStrong, border
scrimscrim, fully opaque (a Material component brings it down to 32% itself when it uses it)
primaryFixed, primaryFixedDim, onPrimaryFixed, onPrimaryFixedVariant, and their partners for secondary and tertiaryThe role's container and onContainer

outlineVariant comes from the box's edge, not from Pantrix's divider. Material 3 1.9.0 draws the outlined button, the unselected chip and the outlined card in this colour. With Pantrix's divider this edge stayed at 1.24:1 against the surface in both light and dark, and was hard to make out. With Pantrix's own field and chip edge (border) it is 1.47:1 in light and 1.62:1 in dark; in Material's baseline scheme the same edge is 1.57:1 and 1.82:1. The price: Material's dividers (HorizontalDivider uses this colour too) are drawn a shade darker than Pantrix's.

Type

Each style takes its counterpart by size. Pantrix's scale: display 36, headline 28, title 22, callout 18, body 16, label 14, footnote 12, caption 11, and the monospaced code 14. Material's baseline scale: display 57/45/36, headline 32/28/24, title 22/16/14, body 16/14/12, label 14/12/11.

PantrixMaterial → PantrixPantrix → Material
displaydisplaySmalldisplayLarge, displayMedium, displaySmall
headlineheadlineMediumheadlineLarge, headlineMedium
titletitleLargeheadlineSmall, titleLarge
calloutThe typeface and weight of bodyLarge, at the callout size of base—
bodybodyLargetitleMedium, bodyLarge
labellabelLargetitleSmall, bodyMedium, labelLarge
footnotebodySmallbodySmall, labelMedium
captionlabelSmalllabelSmall
codeThe code of base—

Material's emphasized styles (…Emphasized) are the same as their plain styles. In 1.9.0 the constructor that gives the emphasized styles separately is internal; the public constructor sets up an app's scale this way.

Shapes

The steps are mapped by their radii, not by their names. Material's scale is one step rounder than Pantrix's: Material's small step is 8 dp, Pantrix's 4 dp. Had they been mapped by name, every Pantrix component in a Material theme would be twice as round; an 18 dp checkbox would turn into a circle with 8 dp corners and look like a radio button. Mapped by radius, Material's own scale gives Pantrix's own scale; a component is as round in the other system's theme as in its own.

PantrixMaterial → PantrixPantrix → Material
xs (2 dp)The xs of base—
sm (4 dp)extraSmall (4 dp)extraSmall
md (8 dp)small (8 dp)small
lg (12 dp)medium (12 dp)medium
xl (16 dp)large (16 dp)large and extraLarge

Pantrix's xs is less round than any of Material's steps; it comes from base. Material's extraLarge (28 dp) is rounder than any of Pantrix's steps: in the Material → Pantrix direction it has no counterpart, and in the other direction it takes the roundest step, xl. So in a Pantrix brand with sharp corners, Material's dialogs and bottom sheets have sharp corners too.

none (square) and full (fully round) are the same in both systems. Material takes only shapes made of corners (CornerBasedShape): a Pantrix shape not made of corners takes the shape of fallback at that step (by default, Material's own scale). Material's three experimental shapes (largeIncreased, extraLargeIncreased, extraExtraLarge) are not mapped.

5. What has no counterpart, and base

Material's scheme has no success, info or warning role, no hovered edge, no focus ring, no disabled colours and no chart colours. toPantrixColors(base) takes them from base. The default base is Pantrix's own colours, as dark as the scheme: PantrixColors.Dark for a dark scheme. An app that wants to give its own success green gives its own base:

val colors = appScheme.toPantrixColors(base = PantrixColors.Light.copy(success = brandGreen))

In type, too, the callout's size and code come from base; the scale given to toPantrixTypography(base) is usually rememberPantrixTypography(). In shapes xs comes from base; the default of toPantrixShapes(base) is Pantrix's own scale.

6. Round trips

7. A mixed screen: content colour and text style

The two wrappers fill in the other system's values at the root too:

For a boundary deep in the screen there are two more small wrappers:

Surface(color = MaterialTheme.colorScheme.primaryContainer) {
    // Pantrix content on Material's surface takes the surface's content colour
    PantrixContentFromMaterial { PantrixText("Draft saved") }
}

PantrixSurface(color = PantrixTheme.colors.primary.color, contentColor = PantrixTheme.colors.primary.onColor) {
    // Material content on Pantrix's fill takes the fill's content colour
    MaterialContentFromPantrix { Text("New") }
}

8. Material's baseline scheme by Pantrix's rules

Material's baseline schemes (lightColorScheme(), darkColorScheme()) were converted into Pantrix colours and measured by the rules Pantrix applies to its own colours (WCAG 2.2: text 4.5:1, a control's mark and the focus ring 3:1):

MaterialContrastTest holds this result: if the mapping or Material's scheme changes, the test fails.

9. What is not in the bridge

10. Testing

The bridge's tests are in ui/material3/src/commonTest, and run on five targets: the mappings field by field, the round trips, the two wrappers and the mixed screen's content colour, and the contrast measurement of Material's baseline scheme. Since the common tests do not run in Android's host tree, a test of its own draws the two wrappers in Robolectric (MaterialThemesOnAndroidTest).

An app tests its own theme like this:

@Test
fun pantrix_s_screen_takes_the_app_s_colours() = runComposeUiTest {
    lateinit var colors: PantrixColors
    setContent { MaterialTheme(colorScheme = appScheme) { PantrixThemeFromMaterial { colors = PantrixTheme.colors } } }
    waitForIdle()
    assertEquals(appScheme.primary, colors.primary.color)
}

11. What was verified and what was not

Verified

Not verified