Pantrix UI

Supporting Pane Scaffold

A screen's main content and a pane that supports it: a document and its comments, a chart and its filters, a map and its results. Side by side where the room has space for both; over the main content, or in its place, where it has not.

import com.pantrixui.components.container.supportingpane.PantrixSupportingPaneScaffold

Examples

A document and its comments

A report and its comments. In a wide room the comments stand beside the report; "Hide comments" above it puts them away, and the report takes the whole room.

Choose "Narrow" in the card's window picker and press "Comments": the comments come in a sheet that opens from the bottom. Drag it down by its handle, press the scrim or press the close button: it closes. In "Medium" the sheet comes from the end.

Write something in the comment field and change the window: what you wrote stays. The supporting pane is one composition wherever it is. Beside the report the pane draws its own title and hide button (supportingPlace == Beside); in the sheet the title and the close button are the scaffold's.

val comments = rememberSupportingPaneState()
PantrixSupportingPaneScaffold(
    state = comments,
    main = {
        DocumentPane(
            commentsShown = isSupportingShown,
            onComments = { if (isSupportingShown) comments.hide() else comments.show() }
        )
    },
    supporting = {
        CommentsPane(
            // Beside, the pane draws its own title; in the sheet the title and the close button are the scaffold's.
            onHide = if (supportingPlace == SupportingPanePlace.Beside) ({ comments.hide() }) else null
        )
    },
    modifier = Modifier.fillMaxWidth().height(SUPPORTING_EXAMPLE_HEIGHT),
    mainTitle = "Quarterly report",
    supportingTitle = "Comments",
    tagKey = "document"
)

@Composable
private fun DocumentPane(
    commentsShown: Boolean,
    onComments: () -> Unit
) {
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = "Quarterly report",
                actions = {
                    PantrixButton(
                        text = if (commentsShown) "Hide comments" else "Comments",
                        onClick = onComments,
                        variant = ButtonVariant.Text,
                        tagKey = "document_comments"
                    )
                },
                tagKey = "document"
            )
            Column(
                modifier = Modifier.verticalScroll(rememberScrollState()).padding(PantrixTheme.spacing.md),
                verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.md)
            ) {
                PARAGRAPHS.forEach { PantrixText(text = it, style = PantrixTheme.typography.body) }
            }
        }
    }
}

@Composable
private fun CommentsPane(onHide: (() -> Unit)?) {
    val draft = rememberTextFieldState()
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            if (onHide != null) HideBar(title = "Comments", onHide = onHide, tagKey = "comments")
            Column(
                modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(PantrixTheme.spacing.md),
                verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.md)
            ) {
                COMMENTS.forEach { comment ->
                    Row(horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
                        PantrixAvatar(contentDescription = null, initials = comment.initials)
                        Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.xs)) {
                            PantrixText(text = comment.author, style = PantrixTheme.typography.callout)
                            PantrixText(text = comment.text, style = PantrixTheme.typography.footnote)
                        }
                    }
                }
            }
            PantrixHorizontalDivider()
            PantrixTextField(
                state = draft,
                modifier = Modifier.fillMaxWidth().padding(PantrixTheme.spacing.md),
                label = "Write a comment",
                tagKey = "comments_draft"
            )
        }
    }
}

private val SUPPORTING_EXAMPLE_HEIGHT = 460.dp

@Composable
private fun HideBar(
    title: String,
    onHide: () -> Unit,
    tagKey: String
) {
    PantrixTopBar(
        title = title,
        actions = {
            PantrixIconButton(
                imageVector = PantrixIcons.Close,
                contentDescription = "Hide $title",
                onClick = onHide,
                tagKey = "${tagKey}_hide"
            )
        },
        tagKey = tagKey
    )
}

@Composable
private fun PaneSurface(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    PantrixSurface(modifier = modifier.fillMaxSize(), border = PantrixTheme.colors.border) { content() }
}

private val PARAGRAPHS = listOf(
    "Sales rose twelve per cent in the third quarter over the one before. Most of the rise came from the internet.",
    "Sales in the stores stood still; two new stores open in October instead.",
    "New contracts came into force in the partner channel. Their effect shows in the fourth quarter.",
    "The aim for next quarter is to grow five per cent in the stores while keeping internet sales."
)

private val COMMENTS = listOf(
    DemoComment("Ayşe Yılmaz", "AY", "Can we show where the internet sales came from on their own?"),
    DemoComment("Mert Kaya", "MK", "Are the dates of the store openings settled?"),
    DemoComment("Elif Demir", "ED", "A summary of the partner contracts is attached.")
)

private class DemoComment(
    val author: String,
    val initials: String,
    val text: String
)

A chart and its filters: the width is dragged

A chart and its filters. The filters' values are kept in the example (channels, stacked): both panes read them, and the chart draws itself again as a filter changes.

resizable = true: in a wide room drag the handle between the panes, or focus it and adjust it with the arrow keys; the left arrow widens the filters. The width stays in supportingWidth, and is where it was after going to "Narrow" and back. style = SupportingPaneScaffoldStyle(minSupportingWidth = 240.dp): the handle makes the filters no narrower than 240dp.

val filters = rememberSupportingPaneState()
val channels = remember { mutableStateListOf(*CHANNELS.map { it.key }.toTypedArray()) }
var stacked by remember { mutableStateOf(false) }
PantrixSupportingPaneScaffold(
    state = filters,
    main = {
        ChartPane(
            channels = channels,
            stacked = stacked,
            filtersShown = isSupportingShown,
            onFilters = { if (isSupportingShown) filters.hide() else filters.show() }
        )
    },
    supporting = {
        FiltersPane(
            channels = channels,
            onChannel = { key, on -> if (on) channels.add(key) else channels.remove(key) },
            stacked = stacked,
            onStacked = { stacked = it },
            onHide = if (supportingPlace == SupportingPanePlace.Beside) ({ filters.hide() }) else null
        )
    },
    modifier = Modifier.fillMaxWidth().height(SUPPORTING_EXAMPLE_HEIGHT),
    mainTitle = "Sales",
    supportingTitle = "Filters",
    resizable = true,
    style = SupportingPaneScaffoldStyle(minSupportingWidth = 240.dp),
    tagKey = "sales"
)

@Composable
private fun ChartPane(
    channels: List<String>,
    stacked: Boolean,
    filtersShown: Boolean,
    onFilters: () -> Unit
) {
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = "Sales",
                actions = {
                    PantrixButton(
                        text = if (filtersShown) "Hide filters" else "Filters",
                        onClick = onFilters,
                        variant = ButtonVariant.Text,
                        tagKey = "sales_filters"
                    )
                },
                tagKey = "sales"
            )
            PantrixBarChart(
                data = MONTHS,
                x = { it.month },
                values = CHANNELS.filter { it.key in channels }.map { channel ->
                    ChartValue<MonthSales>(channel.key, channel.name) { channel.of(it) }
                },
                modifier = Modifier.weight(1f).padding(PantrixTheme.spacing.md),
                stacked = stacked,
                description = "Sales of the chosen channels by month",
                tagKey = "sales_chart"
            )
        }
    }
}

@Composable
private fun FiltersPane(
    channels: List<String>,
    onChannel: (key: String, on: Boolean) -> Unit,
    stacked: Boolean,
    onStacked: (Boolean) -> Unit,
    onHide: (() -> Unit)?
) {
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            if (onHide != null) HideBar(title = "Filters", onHide = onHide, tagKey = "filters")
            Column(
                modifier = Modifier.verticalScroll(rememberScrollState()).padding(PantrixTheme.spacing.md),
                verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
            ) {
                PantrixText(text = "Channels", style = PantrixTheme.typography.callout)
                CHANNELS.forEach { channel ->
                    PantrixCheckbox(
                        checked = channel.key in channels,
                        onCheckedChange = { onChannel(channel.key, it) },
                        label = channel.name,
                        tagKey = "filter_${channel.key}"
                    )
                }
                PantrixHorizontalDivider()
                PantrixSwitchButton(
                    checked = stacked,
                    onCheckedChange = onStacked,
                    label = "Show stacked",
                    tagKey = "filter_stacked"
                )
            }
        }
    }
}

private val CHANNELS = listOf(
    SalesChannel("online", "Internet") { it.online },
    SalesChannel("stores", "Stores") { it.stores },
    SalesChannel("partners", "Partners") { it.partners }
)

private val SUPPORTING_EXAMPLE_HEIGHT = 460.dp

@Composable
private fun HideBar(
    title: String,
    onHide: () -> Unit,
    tagKey: String
) {
    PantrixTopBar(
        title = title,
        actions = {
            PantrixIconButton(
                imageVector = PantrixIcons.Close,
                contentDescription = "Hide $title",
                onClick = onHide,
                tagKey = "${tagKey}_hide"
            )
        },
        tagKey = tagKey
    )
}

@Composable
private fun PaneSurface(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    PantrixSurface(modifier = modifier.fillMaxSize(), border = PantrixTheme.colors.border) { content() }
}

private class SalesChannel(
    val key: String,
    val name: String,
    val of: (MonthSales) -> Double
)

private class MonthSales(
    val month: String,
    val online: Double,
    val stores: Double,
    val partners: Double
)

private val MONTHS = listOf(
    MonthSales("Jul", online = 42.0, stores = 30.0, partners = 12.0),
    MonthSales("Aug", online = 48.0, stores = 29.0, partners = 14.0),
    MonthSales("Sep", online = 55.0, stores = 31.0, partners = 18.0)
)

A map and its results: in the map's place in a narrow room

A map and its results, with presentation = SupportingPanePresentation.Pane: in a narrow room the results open in the map's place, not in a sheet. The back button at their top, Android's back key, Escape and the edge swipe of iOS bring the map back; the back button stands only then (supportingPlace == InPlaceOfMain).

Press a result: its mark on the map grows. The choice is kept in the example, and both panes read it.

val results = rememberSupportingPaneState()
var chosen by remember { mutableStateOf<String?>(null) }
PantrixSupportingPaneScaffold(
    state = results,
    main = {
        MapPane(
            chosen = chosen,
            // Beside, the results are in view already: the button stands only in a narrow room.
            onResults = if (supportingPlace == SupportingPanePlace.Beside) null else ({ results.show() })
        )
    },
    supporting = {
        ResultsPane(
            chosen = chosen,
            onChoose = { chosen = it },
            onBack = if (supportingPlace == SupportingPanePlace.InPlaceOfMain) ({ results.hide() }) else null
        )
    },
    modifier = Modifier.fillMaxWidth().height(SUPPORTING_EXAMPLE_HEIGHT),
    mainTitle = "Map",
    supportingTitle = "Results",
    presentation = SupportingPanePresentation.Pane,
    tagKey = "places"
)

@Composable
private fun MapPane(
    chosen: String?,
    onResults: (() -> Unit)?
) {
    val colors = PantrixTheme.colors
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = "Map",
                actions = if (onResults != null) {
                    {
                        PantrixButton(
                            text = "Results",
                            onClick = onResults,
                            variant = ButtonVariant.Text,
                            tagKey = "places_results"
                        )
                    }
                } else {
                    null
                },
                tagKey = "places_map"
            )
            Canvas(modifier = Modifier.fillMaxWidth().weight(1f).background(colors.neutral.container)) {
                val step = 48.dp.toPx()
                var x = 0f
                while (x < size.width) {
                    drawLine(colors.border, Offset(x, 0f), Offset(x, size.height))
                    x += step
                }
                var y = 0f
                while (y < size.height) {
                    drawLine(colors.border, Offset(0f, y), Offset(size.width, y))
                    y += step
                }
                PLACES.forEach { place ->
                    val big = place.name == chosen
                    drawCircle(
                        color = if (big) colors.primary.color else colors.textSecondary,
                        radius = (if (big) 12.dp else 7.dp).toPx(),
                        center = Offset(place.x * size.width, place.y * size.height)
                    )
                }
            }
        }
    }
}

@Composable
private fun ResultsPane(
    chosen: String?,
    onChoose: (String) -> Unit,
    onBack: (() -> Unit)?
) {
    val colors = PantrixTheme.colors
    val spacing = PantrixTheme.spacing
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = "Results",
                navigationIcon = onBack?.let { back -> { PantrixBackButton(onClick = back, tagKey = "places_back") } },
                tagKey = "places_list"
            )
            Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
                PLACES.forEachIndexed { index, place ->
                    if (index > 0) PantrixHorizontalDivider()
                    val selected = place.name == chosen
                    Column(
                        modifier = Modifier
                            .fillMaxWidth()
                            .selectable(selected = selected, onClick = { onChoose(place.name) })
                            .background(if (selected) colors.primary.container else Color.Transparent)
                            .padding(spacing.md),
                        verticalArrangement = Arrangement.spacedBy(spacing.xs)
                    ) {
                        PantrixText(
                            text = place.name,
                            style = PantrixTheme.typography.callout,
                            color = if (selected) colors.primary.onContainer else Color.Unspecified,
                            tagKey = "place_$index"
                        )
                        PantrixText(
                            text = place.kind,
                            style = PantrixTheme.typography.footnote,
                            color = if (selected) colors.primary.onContainer else colors.textSecondary
                        )
                    }
                }
            }
        }
    }
}

private val SUPPORTING_EXAMPLE_HEIGHT = 460.dp

@Composable
private fun PaneSurface(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    PantrixSurface(modifier = modifier.fillMaxSize(), border = PantrixTheme.colors.border) { content() }
}

private val PLACES = listOf(
    DemoPlace("Moda Sahili", "Park", 0.22f, 0.64f),
    DemoPlace("Kadıköy Çarşısı", "Market", 0.48f, 0.38f),
    DemoPlace("Haydarpaşa", "Station", 0.36f, 0.18f),
    DemoPlace("Yeldeğirmeni", "Neighbourhood", 0.70f, 0.52f),
    DemoPlace("Fenerbahçe Parkı", "Park", 0.82f, 0.82f)
)

private class DemoPlace(
    val name: String,
    val kind: String,
    val x: Float,
    val y: Float
)

Hiding and showing it beside

The state's two halves: isShownBeside keeps what a wide room shows, isOpenOver what a narrow room shows; isShown reads the one of the room as it is. They are apart: a narrower window does not open the sheet by itself.

In "Wide" press "Hide", then go to "Narrow" and press "Show": the sheet opens. Go back to "Wide": the supporting pane stands beside, and isShownBeside is "yes" again. rememberSupportingPaneState(initiallyShownBeside = false) starts it closed in a wide room too.

val notes = rememberSupportingPaneState()
PantrixSupportingPaneScaffold(
    state = notes,
    main = {
        PaneSurface {
            Column(
                modifier = Modifier.padding(PantrixTheme.spacing.md),
                verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
            ) {
                StateLines(this@PantrixSupportingPaneScaffold)
                Row(horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
                    PantrixButton(text = "Show", onClick = { notes.show() }, tagKey = "notes_show")
                    PantrixButton(
                        text = "Hide",
                        onClick = { notes.hide() },
                        variant = ButtonVariant.Outline,
                        tagKey = "notes_hide"
                    )
                }
            }
        }
    },
    supporting = {
        PaneSurface {
            PantrixText(
                text = "Notes",
                modifier = Modifier.padding(PantrixTheme.spacing.md),
                style = PantrixTheme.typography.headline,
                tagKey = "notes_title"
            )
        }
    },
    modifier = Modifier.fillMaxWidth().height(SUPPORTING_EXAMPLE_HEIGHT),
    supportingTitle = "Notes",
    tagKey = "notes"
)

@Composable
private fun StateLines(scope: SupportingPaneScope) {
    val state = scope.state
    listOf(
        "supportingPlace: ${scope.supportingPlace.name}",
        "isShown: ${yesNo(state.isShown)}",
        "isShownBeside: ${yesNo(state.isShownBeside)}",
        "isOpenOver: ${yesNo(state.isOpenOver)}"
    ).forEach { line ->
        PantrixText(text = line, style = PantrixTheme.typography.footnote, color = PantrixTheme.colors.textSecondary)
    }
}

@Composable
private fun PaneSurface(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    PantrixSurface(modifier = modifier.fillMaxSize(), border = PantrixTheme.colors.border) { content() }
}

private val SUPPORTING_EXAMPLE_HEIGHT = 460.dp

private fun yesNo(value: Boolean): String = if (value) "yes" else "no"

On a foldable: split at the fold

On a foldable the two panes are split at the fold. Each pane writes its own size and window class.

Choose "Book": the window is 951dp wide with an upright fold; the main content and the supporting pane are on each side of it, and the gap between them is the strip of 20dp each side of the fold. Choose "Table": the fold lies across, the main content above it and the supporting pane below. A window of 669dp would be one pane on a flat phone; on a foldable laid on a table it is two.

The scaffold looks for the fold in its own room. In "Table" the fold is in the middle of the 951dp window: there the example is as tall as the window, so that the fold crosses it. On a screen the scaffold takes the whole window anyway, with Modifier.fillMaxSize().

val player = rememberSupportingPaneState()
val window = PantrixTheme.window
// On a foldable laid on a table the fold is in the middle of the window: the example is as tall as the window.
val tall = if (window.posture.isTabletop) window.size.height else SUPPORTING_EXAMPLE_HEIGHT
PantrixSupportingPaneScaffold(
    state = player,
    main = { MeasuredPane(name = "Main content", tagKey = "player_main_size") },
    supporting = { MeasuredPane(name = "Supporting pane", tagKey = "player_supporting_size") },
    modifier = Modifier.fillMaxWidth().height(tall),
    mainTitle = "Video",
    supportingTitle = "Controls",
    tagKey = "player"
)

@Composable
private fun MeasuredPane(
    name: String,
    tagKey: String
) {
    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()}" }
    PaneSurface(modifier = Modifier.onSizeChanged { size = it }) {
        // The measures are written from left to right: in a right-to-left paragraph the width and the height would read swapped.
        CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
            PantrixText(
                // The pane is its own window: its class follows the pane's width.
                text = "$name: $words dp · ${PantrixTheme.window.widthClass.name}",
                modifier = Modifier.align(Alignment.Center).padding(PantrixTheme.spacing.md),
                style = PantrixTheme.typography.footnote,
                color = PantrixTheme.colors.textSecondary,
                tagKey = tagKey
            )
        }
    }
}

private val SUPPORTING_EXAMPLE_HEIGHT = 460.dp

@Composable
private fun PaneSurface(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    PantrixSurface(modifier = modifier.fillMaxSize(), border = PantrixTheme.colors.border) { content() }
}

API

state

A screen's main content and a pane that supports it: a document and its comments, a chart and its filters, a map and its results. Side by side where the room has space for both; over the main content, or in its place, where it has not.

ParameterTypeDefaultWhat it is
stateSupportingPaneStatewhether the supporting pane is shown, and its width: rememberSupportingPaneState keeps it across a change of configuration.
main@Composable SupportingPaneScope.() -> Unitthe screen's main content.
supporting@Composable SupportingPaneScope.() -> Unitwhat supports it.
modifierModifierModifierthe room the panes have.
mainTitleString?nullwhat the main content is called, for a screen reader as it comes into view: "Report".
supportingTitleString?nullwhat the supporting pane is called: the title of the sheet it is shown in, and for a screen reader as it comes into view: "Comments".
presentationSupportingPanePresentationSupportingPanePresentation.Sheethow the supporting pane is shown where there is no room beside the main content.
resizableBooleanfalsewhether a handle between the main content and the supporting pane makes the supporting pane wider and narrower.
styleSupportingPaneScaffoldStyle?nullthe scaffold's widths, on top of the theme's.
tagKeyString?nullthe key of the scaffold's automation tags.

SupportingPaneScaffoldStyle

PantrixSupportingPaneScaffold's geometry: how wide the supporting pane is beside the main content, how narrow a drag of the handle may make it and the main content, and the handle itself.

ParameterTypeDefaultWhat it is
supportingWidthDpDp.UnspecifiedHow wide the supporting pane is beside the main content until its handle is dragged, and the side sheet it is shown in where there is no room beside: 360dp, a phone's width.
minSupportingWidthDpDp.UnspecifiedThe narrowest a drag of the handle makes the supporting pane.
minMainWidthDpDp.UnspecifiedThe narrowest the main content is let be beside the supporting pane: a drag of the handle stops where it would be narrower, and a room too narrow for both at their narrowest shows the supporting pane over the main content.
gapDpDp.UnspecifiedBetween the panes. Unspecified in the presets: the layout's gutter, PantrixTheme.layout.gutter, which grows with the window. A brand whose panes meet edge to edge, with a line of their own between them, gives 0dp.
handleWidthDpDp.UnspecifiedThe mark a resizable supporting pane's handle draws in the gap: a short upright bar.
handleHeightDpDp.UnspecifiedHow tall the handle's upright bar is: handleWidth is how thick it is.
handleTargetDpDp.UnspecifiedHow wide the strip is that a finger or a pointer catches the handle by, centred on the gap and over the panes' edges where the gap is narrower: each platform's smallest touch target under a finger, a narrow strip under a pointer.