Pantrix UI

List Detail Scaffold

A list and the detail of the item opened in it: side by side where the room has space for both, the detail in the list's place where it has not, and an extra pane beside the detail, or over it in a side sheet of the scaffold's.

import com.pantrixui.components.container.listdetail.PantrixListDetailScaffold

Examples

Inbox

The most common shape: a list of messages and the message opened. The list opens a message with inbox.select(id); the detail is given the key of the message opened.

Pick "Narrow" in the card's window selector and press a message: the message takes the list's place. The back button at its top, Android's back button, Escape on a desktop and a swipe from the edge of the screen on iOS bring the list back. The back button stands only in one pane: the detail shows it by isSinglePane. listTitle and detailTitle introduce the panes to a screen reader: TalkBack says the message's subject as it opens, and "Inbox" as the list comes back.

Pick "Short", "Wide" or "Book": the list and the message stand side by side, and while no message is open emptyDetail shows on the right. Open a message and switch between "Narrow" and "Wide": the message opened and the list's scroll are both kept.

val inbox = rememberListDetailState()
PantrixListDetailScaffold(
    state = inbox,
    list = {
        MailList(opened = inbox.selectedKey, onOpen = { id -> inbox.select(id) })
    },
    detail = { id ->
        MailDetail(
            mail = MAIL.first { it.id == id },
            // In one pane the message stands in the list's place: the back button stands only then.
            onBack = if (isSinglePane) ({ inbox.back() }) else null
        )
    },
    modifier = Modifier.fillMaxWidth().height(EXAMPLE_HEIGHT),
    emptyDetail = { Placeholder(text = "Pick a message to read", icon = PantrixIcons.Inbox, tagKey = "inbox") },
    listTitle = "Inbox",
    detailTitle = { id -> MAIL.first { it.id == id }.subject },
    tagKey = "inbox"
)

@Composable
private fun MailList(
    opened: String?,
    onOpen: (String) -> Unit
) {
    PaneSurface {
        Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
            MAIL.forEachIndexed { index, mail ->
                if (index > 0) PantrixHorizontalDivider()
                ListRow(
                    title = mail.from,
                    subtitle = mail.subject,
                    selected = mail.id == opened,
                    onClick = { onOpen(mail.id) },
                    tagKey = "mail_${mail.id}"
                )
            }
        }
    }
}

@Composable
private fun MailDetail(
    mail: DemoMail,
    onBack: (() -> Unit)?
) {
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = mail.subject,
                navigationIcon = onBack?.let { back -> { PantrixBackButton(onClick = back, tagKey = "mail_back") } },
                tagKey = "mail"
            )
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .verticalScroll(rememberScrollState())
                    .padding(PantrixTheme.spacing.lg),
                verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
            ) {
                PantrixText(
                    text = mail.from,
                    style = PantrixTheme.typography.footnote,
                    color = PantrixTheme.colors.textSecondary
                )
                PantrixText(text = mail.body, tagKey = "mail_body")
            }
        }
    }
}

@Composable
private fun Placeholder(
    text: String,
    icon: ImageVector,
    tagKey: String
) {
    PaneSurface {
        Column(
            modifier = Modifier.align(Alignment.Center),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
        ) {
            PantrixIcon(icon, contentDescription = null, size = 40.dp, tint = PantrixTheme.colors.textSecondary)
            PantrixText(text = text, color = PantrixTheme.colors.textSecondary, tagKey = "${tagKey}_placeholder")
        }
    }
}

private val MAIL = listOf(
    DemoMail("1", "Elif Kaya", "Tomorrow's meeting", "I moved the meeting to 10. Could you share the slides a day before?"),
    DemoMail("2", "Can Demir", "Release notes", "I added the notes for release 1.4. Write if you see anything missing."),
    DemoMail("3", "Ayşe Yılmaz", "Design review", "The drafts of the new settings screen are ready; shall we look on Thursday?"),
    DemoMail("4", "Mert Şahin", "Invoice", "The October invoice is attached. It can be paid until the 30th."),
    DemoMail("5", "Zeynep Arslan", "Test results", "The nightly tests all passed; there is one warning on the tablet."),
    DemoMail("6", "Deniz Aydın", "Lunch", "Shall the team eat out on Friday? Whoever has a place in mind, write."),
    DemoMail("7", "Burak Çelik", "Accessibility", "I went through the whole flow with a screen reader; two buttons have no name."),
    DemoMail("8", "Selin Koç", "New joiner", "Someone joins the team on Monday; let's plan the first week.")
)

private val EXAMPLE_HEIGHT = 460.dp

@Composable
private fun ListRow(
    title: String,
    subtitle: String?,
    selected: Boolean,
    onClick: () -> Unit,
    tagKey: String,
    initials: String? = null,
    icon: ImageVector? = null
) {
    val colors = PantrixTheme.colors
    val spacing = PantrixTheme.spacing
    val ink = if (selected) colors.primary.onContainer else Color.Unspecified
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .selectable(selected = selected, onClick = onClick)
            .background(if (selected) colors.primary.container else Color.Transparent)
            .padding(spacing.md),
        horizontalArrangement = Arrangement.spacedBy(spacing.md),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (initials != null) PantrixAvatar(contentDescription = null, initials = initials)
        if (icon != null) PantrixIcon(icon, contentDescription = null, tint = ink)
        Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
            PantrixText(
                text = title,
                style = PantrixTheme.typography.callout,
                color = ink,
                maxLines = 1,
                tagKey = tagKey
            )
            if (subtitle != null) {
                PantrixText(
                    text = subtitle,
                    style = PantrixTheme.typography.footnote,
                    color = if (selected) ink else colors.textSecondary,
                    maxLines = 1
                )
            }
        }
    }
}

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

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

Settings: the first section open in two panes

A settings screen: the sections on the left, the settings of the one chosen on the right. In two panes the first section stands open instead of an empty detail; in one pane the list of sections shows first.

For that emptyDetail draws nothing and opens the first section: LaunchedEffect(Unit) { settings.select(...) }. The empty detail is shown only in two panes, so in one pane this never runs.

The settings' values are kept by the example, not by the detail: values. Each section is composed anew as it opens; values remembered inside the detail would be lost on going to another section and back. In "Narrow" change a switch, go back, open the section again: the value is where it was.

val settings = rememberListDetailState()
val values = remember { mutableStateMapOf<String, Boolean>() }
PantrixListDetailScaffold(
    state = settings,
    list = {
        SectionList(chosen = settings.selectedKey, onChoose = { key -> settings.select(key) })
    },
    detail = { key ->
        val section = SECTIONS.first { it.key == key }
        SettingsPage(
            section = section,
            isOn = { option -> values[option] ?: false },
            onChange = { option, on -> values[option] = on },
            onBack = if (isSinglePane) ({ settings.back() }) else null
        )
    },
    modifier = Modifier.fillMaxWidth().height(EXAMPLE_HEIGHT),
    // In two panes the first section opens instead of an empty detail. In one pane the empty detail is never shown: the list stays.
    emptyDetail = { LaunchedEffect(Unit) { settings.select(SECTIONS.first().key) } },
    tagKey = "settings"
)

@Composable
private fun SectionList(
    chosen: String?,
    onChoose: (String) -> Unit
) {
    PaneSurface {
        Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
            SECTIONS.forEach { section ->
                ListRow(
                    title = section.title,
                    subtitle = "${section.options.size} settings",
                    selected = section.key == chosen,
                    onClick = { onChoose(section.key) },
                    tagKey = "section_${section.key}"
                )
            }
        }
    }
}

@Composable
private fun SettingsPage(
    section: SettingsSection,
    isOn: (String) -> Boolean,
    onChange: (String, Boolean) -> Unit,
    onBack: (() -> Unit)?
) {
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = section.title,
                navigationIcon = onBack?.let { back ->
                    { PantrixBackButton(onClick = back, tagKey = "settings_back") }
                },
                tagKey = "settings"
            )
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .verticalScroll(rememberScrollState())
                    .padding(PantrixTheme.spacing.lg),
                verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.md)
            ) {
                section.options.forEach { option ->
                    PantrixSwitchButton(
                        checked = isOn(option),
                        onCheckedChange = { onChange(option, it) },
                        modifier = Modifier.fillMaxWidth(),
                        label = option,
                        tagKey = "option_${section.key}_${section.options.indexOf(option)}"
                    )
                }
            }
        }
    }
}

private val SECTIONS = listOf(
    SettingsSection("general", "General", listOf("Show the last page on start", "Install updates by itself")),
    SettingsSection("notifications", "Notifications", listOf("New messages", "Calendar reminders", "Sound alert")),
    SettingsSection("privacy", "Privacy", listOf("Send usage data", "Share location")),
    SettingsSection("display", "Appearance", listOf("Dark theme", "Less motion", "Large text"))
)

private val EXAMPLE_HEIGHT = 460.dp

@Composable
private fun ListRow(
    title: String,
    subtitle: String?,
    selected: Boolean,
    onClick: () -> Unit,
    tagKey: String,
    initials: String? = null,
    icon: ImageVector? = null
) {
    val colors = PantrixTheme.colors
    val spacing = PantrixTheme.spacing
    val ink = if (selected) colors.primary.onContainer else Color.Unspecified
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .selectable(selected = selected, onClick = onClick)
            .background(if (selected) colors.primary.container else Color.Transparent)
            .padding(spacing.md),
        horizontalArrangement = Arrangement.spacedBy(spacing.md),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (initials != null) PantrixAvatar(contentDescription = null, initials = initials)
        if (icon != null) PantrixIcon(icon, contentDescription = null, tint = ink)
        Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
            PantrixText(
                text = title,
                style = PantrixTheme.typography.callout,
                color = ink,
                maxLines = 1,
                tagKey = tagKey
            )
            if (subtitle != null) {
                PantrixText(
                    text = subtitle,
                    style = PantrixTheme.typography.footnote,
                    color = if (selected) ink else colors.textSecondary,
                    maxLines = 1
                )
            }
        }
    }
}

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

private class SettingsSection(
    val key: String,
    val title: String,
    val options: List<String>
)

Contacts, with their history beside them

A third pane beside a contact: their history. The "History" button in the detail opens it with contacts.showExtra().

Pick "Wide": there is room for three panes, and the history stands beside the detail, at the end, with its title and close button inside it. Pick "Short": there is room for two, and the history shows in a sheet that slides in from the end over the detail, inside the scaffold. The sheet's title is extraTitle and the close button is the sheet's, so the pane leaves its own title out by isExtraInSheet. A press on the scrim, the back button and Escape close the sheet. In "Narrow" it opens in the sheet too.

With the sheet open, scroll the history, then switch to "Wide": the history moves beside the detail and its scroll stays. The pane is one composition in both places.

Going from one contact to another makes the history the other's; going back to the list, back(), closes the history too.

val contacts = rememberListDetailState()
PantrixListDetailScaffold(
    state = contacts,
    list = {
        ContactList(opened = contacts.selectedKey, onOpen = { id -> contacts.select(id) })
    },
    detail = { id ->
        ContactCard(
            contact = CONTACTS.first { it.id == id },
            onHistory = { contacts.showExtra() },
            onBack = if (isSinglePane) ({ contacts.back() }) else null
        )
    },
    modifier = Modifier.fillMaxWidth().height(EXAMPLE_HEIGHT),
    emptyDetail = { Placeholder(text = "Pick a contact", icon = PantrixIcons.Person, tagKey = "contacts") },
    extra = { id ->
        History(
            contact = CONTACTS.first { it.id == id },
            // In a sheet the title and the close button are the sheet's.
            onClose = if (isExtraInSheet) null else ({ contacts.hideExtra() })
        )
    },
    listTitle = "Contacts",
    detailTitle = { id -> CONTACTS.first { it.id == id }.name },
    extraTitle = "History",
    tagKey = "contacts"
)

@Composable
private fun ContactList(
    opened: String?,
    onOpen: (String) -> Unit
) {
    PaneSurface {
        Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
            CONTACTS.forEach { contact ->
                ListRow(
                    title = contact.name,
                    subtitle = contact.role,
                    selected = contact.id == opened,
                    onClick = { onOpen(contact.id) },
                    initials = contact.initials,
                    tagKey = "contact_${contact.id}"
                )
            }
        }
    }
}

@Composable
private fun ContactCard(
    contact: DemoContact,
    onHistory: () -> Unit,
    onBack: (() -> Unit)?
) {
    val spacing = PantrixTheme.spacing
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = contact.name,
                navigationIcon = onBack?.let { back -> { PantrixBackButton(onClick = back, tagKey = "contact_back") } },
                tagKey = "contact"
            )
            Column(
                modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(spacing.lg),
                verticalArrangement = Arrangement.spacedBy(spacing.md)
            ) {
                PantrixAvatar(contentDescription = null, initials = contact.initials, size = 64.dp)
                PantrixText(text = contact.role, color = PantrixTheme.colors.textSecondary)
                PantrixText(text = contact.email)
                PantrixButton(
                    text = "History",
                    onClick = onHistory,
                    variant = ButtonVariant.Outline,
                    leadingIcon = { PantrixIcon(PantrixIcons.Clock, contentDescription = null) },
                    tagKey = "history"
                )
            }
        }
    }
}

@Composable
private fun History(
    contact: DemoContact,
    onClose: (() -> Unit)?
) {
    val lines = @Composable {
        Column(
            modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(PantrixTheme.spacing.lg),
            verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.md)
        ) {
            contact.history.forEach { line -> PantrixText(text = line, tagKey = "history_line") }
        }
    }
    if (onClose == null) {
        lines()
    } else {
        PaneSurface {
            Column(modifier = Modifier.fillMaxSize()) {
                PantrixTopBar(
                    title = "History",
                    actions = {
                        PantrixIconButton(
                            imageVector = PantrixIcons.Close,
                            contentDescription = "Close the history",
                            onClick = onClose,
                            tagKey = "history_close"
                        )
                    },
                    tagKey = "history"
                )
                lines()
            }
        }
    }
}

@Composable
private fun Placeholder(
    text: String,
    icon: ImageVector,
    tagKey: String
) {
    PaneSurface {
        Column(
            modifier = Modifier.align(Alignment.Center),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
        ) {
            PantrixIcon(icon, contentDescription = null, size = 40.dp, tint = PantrixTheme.colors.textSecondary)
            PantrixText(text = text, color = PantrixTheme.colors.textSecondary, tagKey = "${tagKey}_placeholder")
        }
    }
}

private val CONTACTS = listOf(
    DemoContact(
        "elif",
        "Elif Kaya",
        "EK",
        "Product manager",
        "elif@example.com",
        listOf("Yesterday: road map meeting", "Monday: release plan sent", "Last week: user interview")
    ),
    DemoContact(
        "can",
        "Can Demir",
        "CD",
        "Developer",
        "can@example.com",
        listOf("Today: code review", "Yesterday: bug report closed")
    ),
    DemoContact(
        "ayse",
        "Ayşe Yılmaz",
        "AY",
        "Designer",
        "ayse@example.com",
        listOf("Today: draft of the settings screen", "Tuesday: colour palette approved", "Last month: design system")
    ),
    DemoContact(
        "mert",
        "Mert Şahin",
        "MŞ",
        "Accounting",
        "mert@example.com",
        listOf("This month: the October invoice", "Last month: budget report")
    )
)

private val EXAMPLE_HEIGHT = 460.dp

@Composable
private fun ListRow(
    title: String,
    subtitle: String?,
    selected: Boolean,
    onClick: () -> Unit,
    tagKey: String,
    initials: String? = null,
    icon: ImageVector? = null
) {
    val colors = PantrixTheme.colors
    val spacing = PantrixTheme.spacing
    val ink = if (selected) colors.primary.onContainer else Color.Unspecified
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .selectable(selected = selected, onClick = onClick)
            .background(if (selected) colors.primary.container else Color.Transparent)
            .padding(spacing.md),
        horizontalArrangement = Arrangement.spacedBy(spacing.md),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (initials != null) PantrixAvatar(contentDescription = null, initials = initials)
        if (icon != null) PantrixIcon(icon, contentDescription = null, tint = ink)
        Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
            PantrixText(
                text = title,
                style = PantrixTheme.typography.callout,
                color = ink,
                maxLines = 1,
                tagKey = tagKey
            )
            if (subtitle != null) {
                PantrixText(
                    text = subtitle,
                    style = PantrixTheme.typography.footnote,
                    color = if (selected) ink else colors.textSecondary,
                    maxLines = 1
                )
            }
        }
    }
}

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

private class DemoContact(
    val id: String,
    val name: String,
    val initials: String,
    val role: String,
    val email: String,
    val history: List<String>
)

Files: the list's width is dragged

The list's width is the reader's: resizable = true puts a handle between the panes. Drag it with a mouse or a finger; on a keyboard come to the handle with Tab, and the arrow keys move it in steps of 16 dp, Home and End to its narrowest and widest. A screen reader reads the handle as "List width" and can change its value.

The width is kept in files.listWidth; the line in the detail writes it, and "Back to the default" returns it to the style's with Dp.Unspecified. The style here asks for a list of 280 dp, and 200 dp at the least: ListDetailScaffoldStyle(listWidth = 280.dp, minListWidth = 200.dp). The list can grow as wide as leaves the detail 320 dp.

The handle stands only in two panes: pick "Short" or "Wide".

val files = rememberListDetailState(initialSelectedKey = FOLDERS.first().name)
PantrixListDetailScaffold(
    state = files,
    list = {
        FolderList(opened = files.selectedKey, onOpen = { name -> files.select(name) })
    },
    detail = { name ->
        FolderContents(
            folder = FOLDERS.first { it.name == name },
            listWidth = files.listWidth,
            onResetWidth = { files.listWidth = Dp.Unspecified },
            onBack = if (isSinglePane) ({ files.back() }) else null
        )
    },
    modifier = Modifier.fillMaxWidth().height(EXAMPLE_HEIGHT),
    resizable = true,
    style = ListDetailScaffoldStyle(listWidth = 280.dp, minListWidth = 200.dp),
    tagKey = "files"
)

@Composable
private fun FolderList(
    opened: String?,
    onOpen: (String) -> Unit
) {
    PaneSurface {
        Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
            FOLDERS.forEach { folder ->
                ListRow(
                    title = folder.name,
                    subtitle = "${folder.files.size} files",
                    selected = folder.name == opened,
                    onClick = { onOpen(folder.name) },
                    icon = PantrixIcons.Folder,
                    tagKey = "folder_${FOLDERS.indexOf(folder)}"
                )
            }
        }
    }
}

@Composable
private fun FolderContents(
    folder: DemoFolder,
    listWidth: Dp,
    onResetWidth: () -> Unit,
    onBack: (() -> Unit)?
) {
    val spacing = PantrixTheme.spacing
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = folder.name,
                navigationIcon = onBack?.let { back -> { PantrixBackButton(onClick = back, tagKey = "folder_back") } },
                tagKey = "folder"
            )
            Column(
                modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(spacing.lg),
                verticalArrangement = Arrangement.spacedBy(spacing.sm)
            ) {
                PantrixText(
                    text = if (listWidth.isSpecified) {
                        "List width: ${listWidth.value.roundToInt()} dp"
                    } else {
                        "List width: the style's"
                    },
                    style = PantrixTheme.typography.footnote,
                    color = PantrixTheme.colors.textSecondary,
                    tagKey = "list_width"
                )
                PantrixButton(
                    text = "Back to the default",
                    onClick = onResetWidth,
                    variant = ButtonVariant.Text,
                    enabled = listWidth.isSpecified,
                    tagKey = "reset_width"
                )
                folder.files.forEach { file ->
                    Row(
                        horizontalArrangement = Arrangement.spacedBy(spacing.sm),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        PantrixIcon(PantrixIcons.File, contentDescription = null)
                        PantrixText(text = file)
                    }
                }
            }
        }
    }
}

private val FOLDERS = listOf(
    DemoFolder("Documents", listOf("Contract.pdf", "Meeting notes.txt", "Budget 2026.xlsx")),
    DemoFolder("Photos", listOf("Holiday 01.jpg", "Holiday 02.jpg", "Team photo.png", "Logo.svg")),
    DemoFolder("Music", listOf("Playlist.m3u", "Podcast episode.mp3")),
    DemoFolder("Downloads", listOf("Installer.dmg", "Report draft.docx"))
)

private val EXAMPLE_HEIGHT = 460.dp

@Composable
private fun ListRow(
    title: String,
    subtitle: String?,
    selected: Boolean,
    onClick: () -> Unit,
    tagKey: String,
    initials: String? = null,
    icon: ImageVector? = null
) {
    val colors = PantrixTheme.colors
    val spacing = PantrixTheme.spacing
    val ink = if (selected) colors.primary.onContainer else Color.Unspecified
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .selectable(selected = selected, onClick = onClick)
            .background(if (selected) colors.primary.container else Color.Transparent)
            .padding(spacing.md),
        horizontalArrangement = Arrangement.spacedBy(spacing.md),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (initials != null) PantrixAvatar(contentDescription = null, initials = initials)
        if (icon != null) PantrixIcon(icon, contentDescription = null, tint = ink)
        Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
            PantrixText(
                text = title,
                style = PantrixTheme.typography.callout,
                color = ink,
                maxLines = 1,
                tagKey = tagKey
            )
            if (subtitle != null) {
                PantrixText(
                    text = subtitle,
                    style = PantrixTheme.typography.footnote,
                    color = if (selected) ink else colors.textSecondary,
                    maxLines = 1
                )
            }
        }
    }
}

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

private class DemoFolder(
    val name: String,
    val files: List<String>
)

On a foldable: they meet at the fold

On a foldable the list and the detail meet at the fold. Each pane writes its own size and its own window class; the line above it shows what it is told. A pane is a window of its own: in "Book" the window is Expanded, but each of the two 456 dp panes reads Compact.

Pick "Book": the 951 dp window is wide enough for two panes anyway, but the list is not 360 dp: it reaches the fold. The gap is the 20 dp strip on each side of the fold; nothing falls on the fold. A narrower foldable held like a book takes two panes too. There is no handle: the fold places the panes.

Pick "Table": the fold lies across and does not divide panes side by side. The 669 dp window is Medium: one pane.

val notes = rememberListDetailState(initialSelectedKey = NOTES.first())
PantrixListDetailScaffold(
    state = notes,
    list = {
        Column(modifier = Modifier.fillMaxSize()) {
            Told(scope = this@PantrixListDetailScaffold, tagKey = "notes_told_list")
            MeasuredList(opened = notes.selectedKey, onOpen = { note -> notes.select(note) })
        }
    },
    detail = { note ->
        Column(modifier = Modifier.fillMaxSize()) {
            Told(scope = this@PantrixListDetailScaffold, tagKey = "notes_told_detail")
            MeasuredNote(note = note, onBack = if (isSinglePane) ({ notes.back() }) else null)
        }
    },
    modifier = Modifier.fillMaxWidth().height(EXAMPLE_HEIGHT),
    resizable = true,
    tagKey = "notes"
)

@Composable
private fun Told(
    scope: ListDetailScope,
    tagKey: String
) {
    PantrixText(
        text = "paneCount: ${scope.paneCount} · isSinglePane: ${if (scope.isSinglePane) "yes" else "no"}",
        modifier = Modifier.padding(PantrixTheme.spacing.md),
        style = PantrixTheme.typography.footnote,
        color = PantrixTheme.colors.textSecondary,
        tagKey = tagKey
    )
}

@Composable
private fun MeasuredList(
    opened: String?,
    onOpen: (String) -> Unit
) {
    Measured(name = "List", tagKey = "notes_list_size") {
        Column {
            NOTES.forEach { note ->
                ListRow(
                    title = note,
                    subtitle = null,
                    selected = note == opened,
                    onClick = { onOpen(note) },
                    tagKey = "note_${NOTES.indexOf(note)}"
                )
            }
        }
    }
}

@Composable
private fun MeasuredNote(
    note: String,
    onBack: (() -> Unit)?
) {
    PaneSurface {
        Column(modifier = Modifier.fillMaxSize()) {
            PantrixTopBar(
                title = note,
                navigationIcon = onBack?.let { back -> { PantrixBackButton(onClick = back, tagKey = "note_back") } },
                tagKey = "note"
            )
            Measured(name = "Detail", tagKey = "notes_detail_size") {}
        }
    }
}

private val NOTES = listOf("Shopping list", "Book suggestions", "Holiday plan", "Meeting notes")

private val EXAMPLE_HEIGHT = 460.dp

@Composable
private fun Measured(
    name: String,
    tagKey: String,
    content: @Composable () -> Unit
) {
    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 }) {
        Column(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(1f)) { content() }
            // The sizes 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(
                    // A pane is a window of its own: its class follows the pane's width, not the window's.
                    text = "$name: $words dp · ${PantrixTheme.window.widthClass.name}",
                    modifier = Modifier.padding(PantrixTheme.spacing.md),
                    style = PantrixTheme.typography.footnote,
                    color = PantrixTheme.colors.textSecondary,
                    tagKey = tagKey
                )
            }
        }
    }
}

@Composable
private fun ListRow(
    title: String,
    subtitle: String?,
    selected: Boolean,
    onClick: () -> Unit,
    tagKey: String,
    initials: String? = null,
    icon: ImageVector? = null
) {
    val colors = PantrixTheme.colors
    val spacing = PantrixTheme.spacing
    val ink = if (selected) colors.primary.onContainer else Color.Unspecified
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .selectable(selected = selected, onClick = onClick)
            .background(if (selected) colors.primary.container else Color.Transparent)
            .padding(spacing.md),
        horizontalArrangement = Arrangement.spacedBy(spacing.md),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (initials != null) PantrixAvatar(contentDescription = null, initials = initials)
        if (icon != null) PantrixIcon(icon, contentDescription = null, tint = ink)
        Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
            PantrixText(
                text = title,
                style = PantrixTheme.typography.callout,
                color = ink,
                maxLines = 1,
                tagKey = tagKey
            )
            if (subtitle != null) {
                PantrixText(
                    text = subtitle,
                    style = PantrixTheme.typography.footnote,
                    color = if (selected) ink else colors.textSecondary,
                    maxLines = 1
                )
            }
        }
    }
}

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

API

state

A list and the detail of the item opened in it: side by side where the room has space for both, the detail in the list's place where it has not, and an extra pane beside the detail, or over it in a side sheet of the scaffold's.

ParameterTypeDefaultWhat it is
stateListDetailStatewhat is open: rememberListDetailState keeps it across a change of configuration.
list@Composable ListDetailScope.() -> Unitthe list. It opens an item with ListDetailState.select.
detail@Composable ListDetailScope.(key: String) -> Unitthe detail of the item whose key it is given. A new key composes it anew.
modifierModifierModifierthe room the panes have.
emptyDetail(@Composable ListDetailScope.() -> Unit)?nullshown in the detail pane beside the list while no item is open: "Choose a message". Nothing is shown there without it.
extra(@Composable ListDetailScope.(key: String) -> Unit)?nulla third pane for the open item, beside its detail: the contact's history, a file's properties. Shown once ListDetailState.showExtra is called.
listTitleString?nullwhat the list is called, for a screen reader as it comes into view: "Inbox".
detailTitle((key: String) -> String)?nullwhat the detail of the item whose key it is given is called, for a screen reader as it comes into view: the message's subject.
extraTitleString?nullwhat the extra pane is called: the title of the side sheet it is shown in over the detail, and for a screen reader as it comes into view.
resizableBooleanfalsewhether a handle between the list and the detail makes the list wider and narrower.
styleListDetailScaffoldStyle?nullthe scaffold's widths, on top of the theme's.
tagKeyString?nullthe key of the scaffold's automation tags.

ListDetailScaffoldStyle

PantrixListDetailScaffold's geometry: how wide the list and the extra pane are beside the detail, how narrow a drag of the handle may make the list and the detail, and the handle itself.

ParameterTypeDefaultWhat it is
listWidthDpDp.UnspecifiedHow wide the list is beside the detail until its handle is dragged: 360dp, a phone's width.
minListWidthDpDp.UnspecifiedThe narrowest a drag of the handle makes the list.
minDetailWidthDpDp.UnspecifiedThe narrowest the detail is let be beside the list: a drag of the handle stops where the detail would be narrower, and so does a list width set on the state.
extraWidthDpDp.UnspecifiedHow wide the extra pane is beside the detail, where there is room for three panes.
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 list'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.