Pantrix UI

Two Pane

Two panes in one room: beside each other or one above the other, split at the window's fold where one divides the room.

import com.pantrixui.components.container.twopane.PantrixTwoPane

Examples

A list and what is opened, side by side

An inbox and the message opened, side by side: the list 300 dp, the message the rest. Press a message: the pane beside it shows it.

Pick "Book" in the card's window selector: the fold is in the middle and the panes part exactly there. The list is no longer 300 dp but reaches the fold; the gap is the 20 dp strip on each side of the fold. The faint strip and the line over the example are where the fold is. In the other windows the list is 300 dp and the layout's gutter stands between them. In "Narrow" there is no room left for the message: the next example shows one pane there.

var opened by rememberSaveable { mutableStateOf(MAILS.first().id) }
val scroll = rememberScrollState()
PantrixTwoPane(
    first = { Inbox(opened = opened, onOpen = { opened = it }, scroll = scroll, tagKey = "inbox") },
    second = { Message(mail = MAILS.first { it.id == opened }) },
    modifier = Modifier.fillMaxWidth().height(INBOX_HEIGHT),
    firstSize = PaneSize.Fixed(300.dp),
    tagKey = "inbox"
)

@Composable
private fun Inbox(
    opened: String?,
    onOpen: (String) -> Unit,
    scroll: ScrollState,
    tagKey: String
) {
    val colors = PantrixTheme.colors
    PantrixSurface(modifier = Modifier.fillMaxSize(), border = colors.border) {
        Column(modifier = Modifier.verticalScroll(scroll)) {
            MAILS.forEachIndexed { index, mail ->
                if (index > 0) PantrixHorizontalDivider()
                val selected = mail.id == opened
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .selectable(selected = selected, onClick = { onOpen(mail.id) })
                        .background(if (selected) colors.primary.container else Color.Transparent)
                        .padding(PantrixTheme.spacing.md),
                    verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.xs)
                ) {
                    val text = if (selected) colors.primary.onContainer else Color.Unspecified
                    PantrixText(text = mail.from, style = PantrixTheme.typography.callout, color = text, maxLines = 1)
                    PantrixText(
                        text = mail.subject,
                        style = PantrixTheme.typography.footnote,
                        color = if (selected) text else colors.textSecondary,
                        maxLines = 1,
                        tagKey = "${tagKey}_mail_${mail.id}"
                    )
                }
            }
        }
    }
}

@Composable
private fun Message(
    mail: Mail,
    onBack: (() -> Unit)? = null
) {
    val spacing = PantrixTheme.spacing
    PantrixSurface(modifier = Modifier.fillMaxSize(), border = PantrixTheme.colors.border) {
        Column(
            modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(spacing.lg),
            verticalArrangement = Arrangement.spacedBy(spacing.sm)
        ) {
            if (onBack != null) {
                PantrixButton(
                    text = "Inbox",
                    onClick = onBack,
                    variant = ButtonVariant.Text,
                    leadingIcon = { PantrixIcon(PantrixIcons.ArrowBack, contentDescription = null) },
                    tagKey = "back"
                )
            }
            PantrixText(text = mail.subject, style = PantrixTheme.typography.headline, tagKey = "message_subject")
            PantrixText(
                text = mail.from,
                style = PantrixTheme.typography.footnote,
                color = PantrixTheme.colors.textSecondary
            )
            PantrixText(text = mail.body)
        }
    }
}

private val MAILS = listOf(
    Mail("1", "Elif Kaya", "Tomorrow's meeting", "I moved the meeting to 10 o'clock. Could you share the slides a day before?"),
    Mail("2", "Can Demir", "Release notes", "I added the notes for release 1.4. Write if you see anything missing."),
    Mail("3", "Ayşe Yılmaz", "Design review", "The drafts of the new settings screen are ready; shall we look on Thursday?"),
    Mail("4", "Mert Şahin", "Invoice", "The October invoice is attached. It can be paid until the 30th."),
    Mail("5", "Zeynep Arslan", "Test results", "All the nightly tests passed; there is a warning on the tablet screen."),
    Mail("6", "Deniz Aydın", "Lunch", "Shall we eat out as a team on Friday? Anyone with a place in mind, write."),
    Mail("7", "Burak Çelik", "Accessibility", "I tried the whole flow with a screen reader; two buttons have no name."),
    Mail("8", "Selin Koç", "New joiner", "A new developer joins the team on Monday; let's plan their first week."),
    Mail("9", "Emre Öztürk", "Server maintenance", "The servers will be under maintenance on Saturday night from 02:00 to 04:00."),
    Mail("10", "Ece Polat", "User interviews", "I talked to five users this week; the notes are in the shared folder.")
)

private val INBOX_HEIGHT = 420.dp

private class Mail(
    val id: String,
    val from: String,
    val subject: String,
    val body: String
)

One pane or two: paneCount

The window says how many panes to show: PantrixTheme.window.paneCount(). Where there is room for two, the list and the message stand side by side. Where there is not, one shows: the list first, the message once one is pressed, and the list again with the "Inbox" button.

When the pane count changes, the list is called from another place and composed again. Its scroll position is not lost: the example remembers it, not the list, rememberScrollState(), and hands it to the list. Scroll the list, open a message, then switch between "Narrow" and "Wide" in the card's window selector: the opened message and the list's place both stay.

"Narrow" and "Medium" say one pane, "Short", "Wide" and "Book" two. The 951 dp of "Book" is Expanded anyway; a narrower foldable held like a book takes two panes as well. In "Table" the fold lies across and does not part panes side by side: the 669 dp window is Medium and shows one pane.

var opened by rememberSaveable { mutableStateOf<String?>(null) }
val scroll = rememberScrollState()
val panes = PantrixTheme.window.paneCount()
val mail = MAILS.firstOrNull { it.id == opened }
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
    PantrixText(
        text = "paneCount(): $panes",
        style = PantrixTheme.typography.footnote,
        color = PantrixTheme.colors.textSecondary,
        tagKey = "pane_count"
    )
    Box(modifier = Modifier.fillMaxWidth().height(INBOX_HEIGHT)) {
        when {
            panes >= 2 -> {
                PantrixTwoPane(
                    first = { Inbox(opened = opened, onOpen = { opened = it }, scroll = scroll, tagKey = "count") },
                    second = { if (mail != null) Message(mail = mail) else NothingOpened() },
                    modifier = Modifier.fillMaxSize(),
                    firstSize = PaneSize.Fixed(300.dp),
                    tagKey = "count"
                )
            }

            // In one pane an open message takes the list's place; the list's scroll position stays while it is gone.
            mail != null -> {
                Message(mail = mail, onBack = { opened = null })
            }

            else -> {
                Inbox(opened = null, onOpen = { opened = it }, scroll = scroll, tagKey = "count")
            }
        }
    }
}

@Composable
private fun Inbox(
    opened: String?,
    onOpen: (String) -> Unit,
    scroll: ScrollState,
    tagKey: String
) {
    val colors = PantrixTheme.colors
    PantrixSurface(modifier = Modifier.fillMaxSize(), border = colors.border) {
        Column(modifier = Modifier.verticalScroll(scroll)) {
            MAILS.forEachIndexed { index, mail ->
                if (index > 0) PantrixHorizontalDivider()
                val selected = mail.id == opened
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .selectable(selected = selected, onClick = { onOpen(mail.id) })
                        .background(if (selected) colors.primary.container else Color.Transparent)
                        .padding(PantrixTheme.spacing.md),
                    verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.xs)
                ) {
                    val text = if (selected) colors.primary.onContainer else Color.Unspecified
                    PantrixText(text = mail.from, style = PantrixTheme.typography.callout, color = text, maxLines = 1)
                    PantrixText(
                        text = mail.subject,
                        style = PantrixTheme.typography.footnote,
                        color = if (selected) text else colors.textSecondary,
                        maxLines = 1,
                        tagKey = "${tagKey}_mail_${mail.id}"
                    )
                }
            }
        }
    }
}

@Composable
private fun Message(
    mail: Mail,
    onBack: (() -> Unit)? = null
) {
    val spacing = PantrixTheme.spacing
    PantrixSurface(modifier = Modifier.fillMaxSize(), border = PantrixTheme.colors.border) {
        Column(
            modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(spacing.lg),
            verticalArrangement = Arrangement.spacedBy(spacing.sm)
        ) {
            if (onBack != null) {
                PantrixButton(
                    text = "Inbox",
                    onClick = onBack,
                    variant = ButtonVariant.Text,
                    leadingIcon = { PantrixIcon(PantrixIcons.ArrowBack, contentDescription = null) },
                    tagKey = "back"
                )
            }
            PantrixText(text = mail.subject, style = PantrixTheme.typography.headline, tagKey = "message_subject")
            PantrixText(
                text = mail.from,
                style = PantrixTheme.typography.footnote,
                color = PantrixTheme.colors.textSecondary
            )
            PantrixText(text = mail.body)
        }
    }
}

@Composable
private fun NothingOpened() {
    PantrixSurface(modifier = Modifier.fillMaxSize(), border = PantrixTheme.colors.border) {
        PantrixText(
            text = "Pick a message to read",
            modifier = Modifier.align(Alignment.Center),
            color = PantrixTheme.colors.textSecondary,
            tagKey = "nothing_opened"
        )
    }
}

private val MAILS = listOf(
    Mail("1", "Elif Kaya", "Tomorrow's meeting", "I moved the meeting to 10 o'clock. Could you share the slides a day before?"),
    Mail("2", "Can Demir", "Release notes", "I added the notes for release 1.4. Write if you see anything missing."),
    Mail("3", "Ayşe Yılmaz", "Design review", "The drafts of the new settings screen are ready; shall we look on Thursday?"),
    Mail("4", "Mert Şahin", "Invoice", "The October invoice is attached. It can be paid until the 30th."),
    Mail("5", "Zeynep Arslan", "Test results", "All the nightly tests passed; there is a warning on the tablet screen."),
    Mail("6", "Deniz Aydın", "Lunch", "Shall we eat out as a team on Friday? Anyone with a place in mind, write."),
    Mail("7", "Burak Çelik", "Accessibility", "I tried the whole flow with a screen reader; two buttons have no name."),
    Mail("8", "Selin Koç", "New joiner", "A new developer joins the team on Monday; let's plan their first week."),
    Mail("9", "Emre Öztürk", "Server maintenance", "The servers will be under maintenance on Saturday night from 02:00 to 04:00."),
    Mail("10", "Ece Polat", "User interviews", "I talked to five users this week; the notes are in the shared folder.")
)

private val INBOX_HEIGHT = 420.dp

private class Mail(
    val id: String,
    val from: String,
    val subject: String,
    val body: String
)

On a table: the video above, the controls below

For a foldable put on a table: what is looked at above, the controls below. TwoPaneOrientation.Stacked puts the first pane above; here the upper one takes 60% of the room.

Pick "Table" in the card's window selector: the window is 669 × 951 dp and the fold lies across its middle. In the tabletop posture the layout takes the window's whole height, byPosture(flat = 360.dp, tabletop = window.size.height), and the panes part exactly at the fold: the video in the upper half, the controls in the lower. In the other windows the layout is 360 dp tall and is split by the share. In "Book" the fold is upright and does not part panes one above the other: the share holds.

val window = PantrixTheme.window
var playing by rememberSaveable { mutableStateOf(false) }
var second by rememberSaveable { mutableIntStateOf(VIDEO_START) }
PantrixTwoPane(
    first = { Player(playing = playing, second = second) },
    second = {
        PlayerControls(
            playing = playing,
            onPlay = { playing = !playing },
            onBack = { second = (second - SKIP).coerceAtLeast(0) },
            onForward = { second = (second + SKIP).coerceAtMost(VIDEO_LENGTH) }
        )
    },
    modifier = Modifier.fillMaxWidth().height(window.byPosture(flat = 360.dp, tabletop = window.size.height)),
    orientation = TwoPaneOrientation.Stacked,
    firstSize = PaneSize.Fraction(0.6f),
    tagKey = "player"
)

@Composable
private fun Player(
    playing: Boolean,
    second: Int
) {
    PantrixSurface(modifier = Modifier.fillMaxSize(), color = PantrixTheme.colors.textPrimary) {
        Column(
            modifier = Modifier.align(Alignment.Center),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
        ) {
            PantrixIcon(
                imageVector = if (playing) PantrixIcons.Pause else PantrixIcons.Play,
                contentDescription = null,
                size = 48.dp,
                tint = PantrixTheme.colors.background
            )
            PantrixText(
                text = "${clock(second)} / ${clock(VIDEO_LENGTH)}",
                color = PantrixTheme.colors.background,
                tagKey = "video_time"
            )
        }
    }
}

@Composable
private fun PlayerControls(
    playing: Boolean,
    onPlay: () -> Unit,
    onBack: () -> Unit,
    onForward: () -> Unit
) {
    PantrixSurface(modifier = Modifier.fillMaxSize(), border = PantrixTheme.colors.border) {
        Row(
            modifier = Modifier.align(Alignment.Center),
            horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm),
            verticalAlignment = Alignment.CenterVertically
        ) {
            PantrixButton(text = "Back 10 s", onClick = onBack, variant = ButtonVariant.Outline, tagKey = "rewind")
            PantrixButton(text = if (playing) "Pause" else "Play", onClick = onPlay, tagKey = "play")
            PantrixButton(text = "Forward 10 s", onClick = onForward, variant = ButtonVariant.Outline, tagKey = "skip")
        }
    }
}

private const val VIDEO_START = 83

private const val VIDEO_LENGTH = 225

private const val SKIP = 10

private fun clock(seconds: Int): String =
    "${(seconds / MINUTE).toString().padStart(2, '0')}:${(seconds % MINUTE).toString().padStart(2, '0')}"

private const val MINUTE = 60

Share, size of its own, orientation and reading direction

The panes' size and orientation. With the selectors, change the first pane's share, the way the panes stand and the reading direction: each pane writes its own size.

var size by rememberSaveable { mutableIntStateOf(0) }
var stacked by rememberSaveable { mutableStateOf(false) }
var rtl by rememberSaveable { mutableStateOf(false) }
val spacing = PantrixTheme.spacing
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
    PantrixSegmentButton(
        options = SIZES.mapIndexed { index, (label, _) -> SegmentOption(index, label, tagKey = "size_$index") },
        selected = size,
        onSelect = { size = it },
        tagKey = "size"
    )
    PantrixSegmentButton(
        options = listOf(
            SegmentOption(false, "Side by side", tagKey = "side_by_side"),
            SegmentOption(true, "One above the other", tagKey = "stacked")
        ),
        selected = stacked,
        onSelect = { stacked = it },
        tagKey = "orientation"
    )
    PantrixSegmentButton(
        options = listOf(
            SegmentOption(false, "Left to right", tagKey = "ltr"),
            SegmentOption(true, "Right to left", tagKey = "rtl")
        ),
        selected = rtl,
        onSelect = { rtl = it },
        tagKey = "direction"
    )
}
val direction = if (rtl) LayoutDirection.Rtl else LayoutDirection.Ltr
CompositionLocalProvider(LocalLayoutDirection provides direction) {
    PantrixTwoPane(
        first = { MeasuredPane(name = "First", color = PantrixTheme.colors.primary.container) },
        second = { MeasuredPane(name = "Second", color = PantrixTheme.colors.surface) },
        modifier = Modifier.fillMaxWidth().height(240.dp),
        orientation = if (stacked) TwoPaneOrientation.Stacked else TwoPaneOrientation.SideBySide,
        firstSize = SIZES[size].second,
        tagKey = "sizes"
    )
}

private val SIZES = listOf(
    "Half and half" to PaneSize.Fraction(0.5f),
    "%30" to PaneSize.Fraction(0.3f),
    "200 dp" to PaneSize.Fixed(200.dp)
)

@Composable
private fun MeasuredPane(
    name: String,
    color: Color
) {
    var size by remember { mutableStateOf(IntSize.Zero) }
    val density = LocalDensity.current
    val words = with(density) { "${size.width.toDp().value.roundToInt()} × ${size.height.toDp().value.roundToInt()}" }
    PantrixSurface(
        modifier = Modifier.fillMaxSize().onSizeChanged { size = it },
        color = color,
        border = PantrixTheme.colors.border
    ) {
        // The sizes are written left to right: in a right-to-left paragraph the width and the height would read swapped.
        CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
            Column(modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
                PantrixText(text = name, style = PantrixTheme.typography.callout)
                PantrixText(text = "$words dp", style = PantrixTheme.typography.footnote)
            }
        }
    }
}

Your own layout: rememberFoldsInNode

To write a layout of your own that is laid out by the fold: rememberFoldsInNode() gives the folds in a node's own coordinates, in pixels. Put its modifier on the node and read folds as you measure: as the node moves, the window changes and the device is folded, the layout is measured again. A fold that does not cross the node is not in the list.

The toolbar here lines its buttons up in the middle. Where an upright fold crosses it, half of the buttons go before the fold and half after it: no button falls on the fold. Pick "Book" in the card's window selector: the buttons part on both sides of the fold, and the line below says where the fold is in the bar.

val folds = rememberFoldsInNode()
val density = LocalDensity.current
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
    FoldAwareToolbar(modifier = Modifier.fillMaxWidth().then(folds.modifier)) {
        TOOLS.forEachIndexed { index, tool ->
            PantrixButton(text = tool, onClick = {}, variant = ButtonVariant.Soft, tagKey = "tool_$index")
        }
    }
    val fold = folds.separatingFold
    val words = if (fold == null) {
        "No fold crosses this bar"
    } else {
        with(density) {
            val start = fold.clearance.left.toDp().value.roundToInt()
            val end = fold.clearance.right.toDp().value.roundToInt()
            "The fold is at $start–$end dp of the bar, ${fold.orientation.name}"
        }
    }
    PantrixText(
        text = words,
        style = PantrixTheme.typography.footnote,
        color = PantrixTheme.colors.textSecondary,
        tagKey = "fold_in_node"
    )
}

@Composable
private fun FoldAwareToolbar(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    val folds = rememberFoldsInNode()
    val gap = PantrixTheme.spacing.sm
    Layout(content = content, modifier = modifier.then(folds.modifier)) { measurables, constraints ->
        val buttons = measurables.map { it.measure(constraints.copy(minWidth = 0)) }
        val space = gap.roundToPx()
        val width = constraints.maxWidth
        val height = buttons.maxOf { it.height }
        val fold = folds.separatingFold?.takeIf { it.orientation == FoldOrientation.Vertical }
        layout(width, height) {
            if (fold == null) {
                // No fold: all of them in the middle.
                val row = buttons.sumOf { it.width } + space * (buttons.size - 1)
                var x = (width - row) / 2
                buttons.forEach { button ->
                    button.placeRelative(x, (height - button.height) / 2)
                    x += button.width + space
                }
            } else {
                // A fold: half of them before the strip, against it, half after it. The fold stays where it is on the screen,
                // so the buttons are put where they are on the screen.
                val before = buttons.take(buttons.size / 2)
                var x = fold.clearance.left.roundToInt() - before.sumOf { it.width + space }
                (before + buttons.drop(before.size)).forEachIndexed { index, button ->
                    if (index == before.size) x = fold.clearance.right.roundToInt() + space
                    button.place(x, (height - button.height) / 2)
                    x += button.width + space
                }
            }
        }
    }
}

private val TOOLS = listOf("Cut", "Copy", "Paste", "Delete")

A window that folds, in a preview

A window that folds, in a preview or a test: PantrixWindowFrame makes a box a window of its own. What it holds reads the window it is given with PantrixTheme.window and measures the fold from the box's corner. Here a window of 280 × 160 dp was made, with a fold held half open like a book in its middle: the two panes in it part exactly at the fold, and the narrow window says two panes in the book posture.

Where the fold is, is in pixels. The bounds of a WindowFold are the fold itself: on a phone that folds, a line with no thickness. clearance is the strip what is touched keeps clear of: on Android the fold itself, on iOS 20 pt more on each side. In this window the strip is 12 dp on each side.

val density = LocalDensity.current
val window = remember(density) { bookWindow(DpSize(280.dp, 160.dp), margin = 12.dp, density = density) }
PantrixWindowFrame(
    window = window,
    modifier = Modifier.size(280.dp, 160.dp).border(1.dp, PantrixTheme.colors.border)
) {
    PantrixTwoPane(
        first = { MeasuredPane(name = "Left page", color = PantrixTheme.colors.surface) },
        second = { MeasuredPane(name = "Right page", color = PantrixTheme.colors.surface) },
        modifier = Modifier.fillMaxSize(),
        tagKey = "framed"
    )
}
PantrixText(
    text = "${window.widthClass.name} width · paneCount(): ${window.paneCount()} · " +
        "isBook: ${if (window.posture.isBook) "yes" else "no"}",
    style = PantrixTheme.typography.footnote,
    color = PantrixTheme.colors.textSecondary,
    tagKey = "framed_window"
)

internal fun bookWindow(
    size: DpSize,
    margin: Dp,
    density: Density
): PantrixWindow = with(density) {
    val middle = size.width.toPx() / 2
    val height = size.height.toPx()
    val fold = WindowFold(
        bounds = Rect(left = middle, top = 0f, right = middle, bottom = height),
        orientation = FoldOrientation.Vertical,
        isHalfOpened = true,
        isSeparating = true,
        isOccluding = false,
        clearance = Rect(left = middle - margin.toPx(), top = 0f, right = middle + margin.toPx(), bottom = height)
    )
    PantrixWindow(size, posture = WindowPosture(listOf(fold)))
}

@Composable
private fun MeasuredPane(
    name: String,
    color: Color
) {
    var size by remember { mutableStateOf(IntSize.Zero) }
    val density = LocalDensity.current
    val words = with(density) { "${size.width.toDp().value.roundToInt()} × ${size.height.toDp().value.roundToInt()}" }
    PantrixSurface(
        modifier = Modifier.fillMaxSize().onSizeChanged { size = it },
        color = color,
        border = PantrixTheme.colors.border
    ) {
        // The sizes are written left to right: in a right-to-left paragraph the width and the height would read swapped.
        CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
            Column(modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
                PantrixText(text = name, style = PantrixTheme.typography.callout)
                PantrixText(text = "$words dp", style = PantrixTheme.typography.footnote)
            }
        }
    }
}

API

first

Two panes in one room: beside each other or one above the other, split at the window's fold where one divides the room.

ParameterTypeDefaultWhat it is
first@Composable () -> Unitthe first pane: at the start beside the second, or above it.
second@Composable () -> Unitthe second pane.
modifierModifierModifierthe room the two panes have.
orientationTwoPaneOrientationTwoPaneOrientation.SideBySidewhich way the panes stand.
firstSizePaneSizePaneSize.Fraction(0.5f)how much the first pane takes where no fold divides the room: half by default.
gapDpPantrixTheme.layout.gutterthe room between the panes where no fold divides it: the layout's gutter by default.
tagKeyString?nullthe key of the pair's automation tag.