Pantrix UI

A modal layer of the app’s own

Why, in Compose Multiplatform, an app's building a modal layer of its own (a scrim over the whole window, and over it content the app draws) is another job on each platform, 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 project that wants a layer a dialog or a sheet does not draw (a search with filters of its own, a picture shown large, an editor that fills a phone).

Everything said here of Compose was read in the sources of Compose Multiplatform 1.12.2, or seen in a test that runs in this repository; which was seen where is written in sections 7 and 8.

1. The problem

Symptom

Why

There are things a Dialog decides itself on each platform (Compose Multiplatform 1.12.2, the ui module, DialogProperties):

PlatformWhat Dialog decides itselfTo turn it off
AndroidThe window's dimming (FLAG_DIM_BEHIND), the opening animation, a default width, keeping inside the system bars, the bars' iconsusePlatformDefaultWidth = false, decorFitsSystemWindows = false; on the window clearFlags(FLAG_DIM_BEHIND), setWindowAnimations(0) and the bars' appearance (WindowCompat.getInsetsController)
iOS, desktop, webA default width, the window's insets and the software keyboard's inset, the scrim's colour, the opening and closing transitionusePlatformDefaultWidth = false, usePlatformInsets = false, useSoftwareKeyboardInset = false, scrimColor = Color.Transparent, animateTransition = false (the experimental constructor)

With these turned off, the layer is an empty surface: the app builds the scrim, the content's place, the motion and the focus itself, the same way on every platform.

2. The solution

In the components (com.pantrixui.components.overlay.modal):

  1. PantrixModal(visible, onDismissRequest, …) { content }: a scrim over the whole window, and over it the app's content. It is the layer PantrixDialog, PantrixSheet and PantrixCommandPalette stand on.

  2. ModalPosition: where the content sits, and how it enters and leaves. Center is the window's middle, Top near its top: a search whose field stays where it is.

The modal gives the content no fill, no shape and no width: the content draws itself. The rest is the modal's:

3. With Pantrix

3.1. A search at the top

var searching by rememberSaveable { mutableStateOf(false) }
PantrixModal(
    visible = searching,
    onDismissRequest = { searching = false },
    position = ModalPosition.Top,
    paneTitle = stringResource(Res.string.search),
    tagKey = "search"
) {
    PantrixSurface(Modifier.widthIn(max = 640.dp), shape = PantrixTheme.shapes.lg, elevation = 8.dp) {
        SearchPanel(onOpen = { searching = false })
    }
}

3.2. A page on a phone

PantrixModal(
    visible = editing,
    onDismissRequest = { editing = false },
    margin = PaddingValues(),
    paneTitle = stringResource(Res.string.edit_note),
    scrimColor = PantrixTheme.colors.background,
    tagKey = "editor"
) {
    NoteEditor(Modifier.fillMaxSize(), onDone = { editing = false })
}

A margin of none, content that fills itself and a scrim that is the theme's background make the modal a page: the content fills the safe area, and behind the system bars is the background's colour too.

3.3. One that asks for a decision

dismissOnScrimClick = false and dismissOnBack = false make the modal close only by a button in it; the scrim is then a fill a screen reader passes over.

4. Without Pantrix

Below is the shortest form that can be copied into a project without the library; it gives the scrim, Escape and the motion, not the focus trap and the fold. Pantrix's code (ui/components/src/*Main/kotlin/com/pantrixui/components/overlay/modal/) does the same with its tests and edge cases.

@Composable
fun AppModal(visible: Boolean, onDismiss: () -> Unit, content: @Composable () -> Unit) {
    val shown = remember { MutableTransitionState(false) }
    shown.targetState = visible
    if (!visible && shown.isIdle && !shown.currentState) return
    Dialog(
        onDismissRequest = onDismiss,
        // On Android turn the window's dimming and animation off too (the table in section 1).
        properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnClickOutside = false)
    ) {
        AnimatedVisibility(shown, enter = fadeIn(), exit = fadeOut()) {
            Box(
                Modifier
                    .fillMaxSize()
                    .background(Color.Black.copy(alpha = 0.4f))
                    .pointerInput(onDismiss) { detectTapGestures { onDismiss() } },
                contentAlignment = Alignment.Center
            ) {
                Box(Modifier.pointerInput(Unit) {}) { content() }
            }
        }
    }
}

5. What the helper does not do

6. Testing

7. Pitfalls

8. Verification

Tests

WhatWhere
Nothing composed while closed, the window's title, sitting in the middle, the margin, the scrim's colour and a press on it, the "Close" for a screen reader, Escape, the first focus, Tab going round inside, the focus coming back, the ring of the app under it, the exit motion, reduced motionModalTest (JVM, iOS, JS, Wasm)
The public modal's tag, its events, sitting at the top and in the middle, the default margin and a page with none, the scrim's colour in TOLD_APARTPantrixModalTest (JVM, iOS, JS, Wasm)
Android's window: no dimming and animation of its own, waiting for the first frame, as large as the screen, back, the scrim, the bars' iconsDialogOnAndroidTest (Robolectric; the same shell)
The look: content at the top and in the middle, the light and the dark themeComponentScreenshotTest.modal (JVM, Android, iOS)

Looked at by hand

Not verified

9. Where in this repository

10. What is left open

None.