Pantrix UI

Navigation Bar

The app's top-level destinations along the bottom of the screen, three to five of them, with the one the app is at marked.

import com.pantrixui.components.navigation.navigationbar.PantrixNavigationBar

Examples

Three destinations

At its plainest: the list of destinations (items), the key of the current destination (selected) and onSelect, which is told the key of the destination that is pressed. The bar selects nothing by itself; the screen holds where the app is. The destinations share the bar's width.

Press a destination, then press the same one again: that is reported too. The line above counts it; a real app takes that destination back to its top.

val destinations = listOf(
    NavigationItem(key = "files", label = "Files", icon = PantrixIcons.Folder),
    NavigationItem(key = "search", label = "Search", icon = PantrixIcons.Search),
    NavigationItem(key = "profile", label = "Profile", icon = PantrixIcons.Person)
)
var where by rememberSaveable { mutableStateOf("files") }
var pressedAgain by rememberSaveable { mutableIntStateOf(0) }
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    Column {
        PantrixText(
            text = "Open destination: ${destinations.first { it.key == where }.label}. " +
                "Times the same destination was pressed again: $pressedAgain.",
            modifier = Modifier.padding(PantrixTheme.spacing.lg),
            color = PantrixTheme.colors.textSecondary
        )
        PantrixNavigationBar(
            items = destinations,
            selected = where,
            onSelect = { key -> if (key == where) pressedAgain++ else where = key },
            tagKey = "plain"
        )
    }
}

Three destinations, with a count on one

The three destinations of a mail app. The badge on "Inbox" counts the unread: NavigationBadge(contentDescription, count). When the destination is opened the count goes to zero and the badge goes away: with nothing left to count, badge is given null. "Starred" has a selectedIcon: while it is selected, that is drawn in place of the star. An app gives the icon's filled form there; PantrixIcons has no filled forms, so another icon stands here.

The badge's contentDescription is required and is read after the destination's name: "Inbox, 3 unread". A number alone does not say what it counts; giving null is a decision, made where the name says it already.

var where by rememberSaveable { mutableStateOf("starred") }
var unread by rememberSaveable { mutableIntStateOf(3) }
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    Column {
        PantrixText(
            text = when (where) {
                "starred" -> "Starred messages"
                "inbox" -> "Inbox: nothing left unread"
                else -> "Profile and settings"
            },
            modifier = Modifier.padding(PantrixTheme.spacing.lg),
            color = PantrixTheme.colors.textSecondary
        )
        PantrixNavigationBar(
            items = listOf(
                NavigationItem(
                    key = "starred",
                    label = "Starred",
                    icon = PantrixIcons.Star,
                    selectedIcon = PantrixIcons.Success
                ),
                NavigationItem(
                    key = "inbox",
                    label = "Inbox",
                    icon = PantrixIcons.Inbox,
                    badge = if (unread > 0) {
                        NavigationBadge(contentDescription = "$unread unread", count = unread)
                    } else {
                        null
                    }
                ),
                NavigationItem(key = "profile", label = "Profile", icon = PantrixIcons.Person)
            ),
            selected = where,
            onSelect = { key ->
                where = key
                if (key == "inbox") unread = 0
            },
            tagKey = "mail"
        )
    }
}

With a pill: five destinations, one that cannot be pressed

Five destinations: the most a bar takes. The current destination is marked by the pill behind its icon (NavigationIndicator.Pill): that is the default on every platform, written out here. The press and the focus ring are drawn in the pill's room too, not over the whole destination.

The badge on "Folders" is a dot (count was not given); the 128 on "Profile" is more than the largest number that is written, and becomes "99+". "Calendar" cannot be pressed: enabled = false.

var where by rememberSaveable { mutableStateOf("search") }
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        style = NavigationBarStyle(indicator = NavigationIndicator.Pill),
        tagKey = "pill"
    )
}

private val FIVE = listOf(
    NavigationItem(key = "starred", label = "Starred", icon = PantrixIcons.Star),
    NavigationItem(
        key = "folders",
        label = "Folders",
        icon = PantrixIcons.Folder,
        badge = NavigationBadge(contentDescription = "New files")
    ),
    NavigationItem(key = "search", label = "Search", icon = PantrixIcons.Search),
    NavigationItem(key = "calendar", label = "Calendar", icon = PantrixIcons.Calendar, enabled = false),
    NavigationItem(
        key = "profile",
        label = "Profile",
        icon = PantrixIcons.Person,
        badge = NavigationBadge(contentDescription = "128 notifications", count = 128)
    )
)

By colour alone

The same bar with NavigationIndicator.None: no pill is drawn, the current destination's icon and label are in the theme's primary colour, and the others in the secondary text colour. A tab bar on iOS marks its destination this way; no platform's preset chooses it, and whoever wants it gives a style.

var where by rememberSaveable { mutableStateOf("search") }
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        style = NavigationBarStyle(indicator = NavigationIndicator.None),
        tagKey = "tint"
    )
}

private val FIVE = listOf(
    NavigationItem(key = "starred", label = "Starred", icon = PantrixIcons.Star),
    NavigationItem(
        key = "folders",
        label = "Folders",
        icon = PantrixIcons.Folder,
        badge = NavigationBadge(contentDescription = "New files")
    ),
    NavigationItem(key = "search", label = "Search", icon = PantrixIcons.Search),
    NavigationItem(key = "calendar", label = "Calendar", icon = PantrixIcons.Calendar, enabled = false),
    NavigationItem(
        key = "profile",
        label = "Profile",
        icon = PantrixIcons.Person,
        badge = NavigationBadge(contentDescription = "128 notifications", count = 128)
    )
)

Style: other sizes and the label's type

style changes the bar's sizes, labelStyle the type the labels are set in. The preset bar is 64dp tall on every platform; Android's own bar is 80dp with a larger pill, and a tab bar on iOS is 49pt with no pill. Neither is the preset, but a style can ask for either.

The bar above is 80dp, with a pill of 64×32dp and a 24dp icon. The one below is 49dp, has no pill, has its labels set in caption and less room between an icon and its label (labelGap = 2.dp). Only the fields that are given change. A destination is never lower than the platform's touch target.

var where by rememberSaveable { mutableStateOf("search") }
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        style = NavigationBarStyle(
            height = 80.dp,
            indicatorWidth = 64.dp,
            indicatorHeight = 32.dp,
            iconSize = 24.dp
        ),
        tagKey = "tall"
    )
}
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        labelStyle = PantrixTheme.typography.caption,
        style = NavigationBarStyle(height = 49.dp, labelGap = 2.dp, indicator = NavigationIndicator.None),
        tagKey = "low"
    )
}

private val FIVE = listOf(
    NavigationItem(key = "starred", label = "Starred", icon = PantrixIcons.Star),
    NavigationItem(
        key = "folders",
        label = "Folders",
        icon = PantrixIcons.Folder,
        badge = NavigationBadge(contentDescription = "New files")
    ),
    NavigationItem(key = "search", label = "Search", icon = PantrixIcons.Search),
    NavigationItem(key = "calendar", label = "Calendar", icon = PantrixIcons.Calendar, enabled = false),
    NavigationItem(
        key = "profile",
        label = "Profile",
        icon = PantrixIcons.Person,
        badge = NavigationBadge(contentDescription = "128 notifications", count = 128)
    )
)

The current destination in a role's colours

Only the current destination's colour has changed; the rest is the theme's. selectedContainer paints the pill; given one of the theme's role colours, the colour of the icon on it comes from that role too, unless selectedContent is given as well. selectedTint is the colour that marks the destination in a style that draws no pill.

Each has its own place: giving the one changes nothing where the other is drawn. The same colors were given to two bars, one with a pill and one without.

val success = PantrixTheme.colors.success
val colors = NavigationColors(selectedContainer = success.container, selectedTint = success.color)
var where by rememberSaveable { mutableStateOf("search") }
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        colors = colors,
        tagKey = "role"
    )
}
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        style = NavigationBarStyle(indicator = NavigationIndicator.None),
        colors = colors,
        tagKey = "role_tint"
    )
}

private val FIVE = listOf(
    NavigationItem(key = "starred", label = "Starred", icon = PantrixIcons.Star),
    NavigationItem(
        key = "folders",
        label = "Folders",
        icon = PantrixIcons.Folder,
        badge = NavigationBadge(contentDescription = "New files")
    ),
    NavigationItem(key = "search", label = "Search", icon = PantrixIcons.Search),
    NavigationItem(key = "calendar", label = "Calendar", icon = PantrixIcons.Calendar, enabled = false),
    NavigationItem(
        key = "profile",
        label = "Profile",
        icon = PantrixIcons.Person,
        badge = NavigationBadge(contentDescription = "128 notifications", count = 128)
    )
)

In its own colours

A bar in a colour the theme does not have: the app's own purple. The theme cannot know what reads on this fill; with the fill (container), the colour on it (content) is given too. The other destinations are written in that colour, the one that cannot be pressed in a share of it, and the line along the top comes from it as well.

The pill and the icon on it were given too (selectedContainer, selectedContent). A style that draws no pill does not use them: the current destination is in the bar's text colour, and the others in a share of it.

val own = ownColors()
var where by rememberSaveable { mutableStateOf("folders") }
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        colors = NavigationColors(
            container = own.color,
            content = own.onColor,
            selectedContainer = own.container,
            selectedContent = own.onContainer
        ),
        tagKey = "own"
    )
}

private val FIVE = listOf(
    NavigationItem(key = "starred", label = "Starred", icon = PantrixIcons.Star),
    NavigationItem(
        key = "folders",
        label = "Folders",
        icon = PantrixIcons.Folder,
        badge = NavigationBadge(contentDescription = "New files")
    ),
    NavigationItem(key = "search", label = "Search", icon = PantrixIcons.Search),
    NavigationItem(key = "calendar", label = "Calendar", icon = PantrixIcons.Calendar, enabled = false),
    NavigationItem(
        key = "profile",
        label = "Profile",
        icon = PantrixIcons.Person,
        badge = NavigationBadge(contentDescription = "128 notifications", count = 128)
    )
)

Behind the system bar

insets is the room the bar keeps clear along the bottom edge and at its sides: the fill runs behind that room, and the destinations stay out of it. On a real screen PantrixScaffold gives the insets (bottomBar = { insets -> … }) and the system's navigation bar sits over the bar's fill. That is how the app's own bottom bar gets its insets.

There is no scaffold here: 24dp stand in for the insets, and the line inside them for the system's gesture bar. The destinations stay above the line, and the bar's fill goes down under it.

var where by rememberSaveable { mutableStateOf("search") }
PantrixSurface(modifier = Modifier.fillMaxWidth(), border = PantrixTheme.colors.divider) {
    Box(contentAlignment = Alignment.BottomCenter) {
        PantrixNavigationBar(
            items = FIVE,
            selected = where,
            onSelect = { where = it },
            insets = PaddingValues(bottom = 24.dp),
            tagKey = "insets"
        )
        // A line in the place of the system's gesture bar: over the bar's fill, under the destinations.
        Box(
            modifier = Modifier
                .padding(bottom = 10.dp)
                .size(width = 120.dp, height = 4.dp)
                .background(PantrixTheme.colors.textSecondary, PantrixTheme.shapes.full)
        )
    }
}

private val FIVE = listOf(
    NavigationItem(key = "starred", label = "Starred", icon = PantrixIcons.Star),
    NavigationItem(
        key = "folders",
        label = "Folders",
        icon = PantrixIcons.Folder,
        badge = NavigationBadge(contentDescription = "New files")
    ),
    NavigationItem(key = "search", label = "Search", icon = PantrixIcons.Search),
    NavigationItem(key = "calendar", label = "Calendar", icon = PantrixIcons.Calendar, enabled = false),
    NavigationItem(
        key = "profile",
        label = "Profile",
        icon = PantrixIcons.Person,
        badge = NavigationBadge(contentDescription = "128 notifications", count = 128)
    )
)

Upright bar

The bar stands upright: the destinations one above another, in a capsule of the bar's fill with a thin line around it. On the iPhone Duo's outer screen the system keeps a column for such bars along the right edge; PantrixScaffold puts the navigation at the bottom of that column and tells it ScaffoldNavigationPlacement.VerticalBar (the "Scaffold" page has an example).

The one on the left has its icons alone, as UIKit's tabs in that column: rest on a destination or press it long, and its name shows in a tooltip at its start; a screen reader says the name too. The one on the right writes the names as well, with showLabels = true, and is as wide as its longest name. The two share one state: press a destination in one and the other marks it too.

var where by rememberSaveable { mutableStateOf("search") }
Row(horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.xxl)) {
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        orientation = NavigationBarOrientation.Vertical,
        tagKey = "upright_icons"
    )
    PantrixNavigationBar(
        items = FIVE,
        selected = where,
        onSelect = { where = it },
        orientation = NavigationBarOrientation.Vertical,
        showLabels = true,
        tagKey = "upright_labels"
    )
}

private val FIVE = listOf(
    NavigationItem(key = "starred", label = "Starred", icon = PantrixIcons.Star),
    NavigationItem(
        key = "folders",
        label = "Folders",
        icon = PantrixIcons.Folder,
        badge = NavigationBadge(contentDescription = "New files")
    ),
    NavigationItem(key = "search", label = "Search", icon = PantrixIcons.Search),
    NavigationItem(key = "calendar", label = "Calendar", icon = PantrixIcons.Calendar, enabled = false),
    NavigationItem(
        key = "profile",
        label = "Profile",
        icon = PantrixIcons.Person,
        badge = NavigationBadge(contentDescription = "128 notifications", count = 128)
    )
)

API

items

The app's top-level destinations along the bottom of the screen, three to five of them, with the one the app is at marked.

ParameterTypeDefaultWhat it is
itemsList<NavigationItem>the destinations, in order: three to five of them.
selectedString?the key of the destination the app is at, or null where it is at none of them.
onSelect(key: String) -> Unittold the key of a destination that is pressed, the current one's too.
modifierModifierModifierapplied to the bar.
orientationNavigationBarOrientationNavigationBarOrientation.Horizontalwhich way the bar runs: along the bottom, or upright in the system's vertical bar.
showLabelsBooleanorientation == NavigationBarOrientation.Horizontalwhether each destination shows its label under its icon: along the bottom it does unless told not to, upright it does not unless told to.
labelStyleTextStylePantrixTheme.typography.footnotethe type the labels are set in: the theme's footnote unless given. Their colour is the bar's.
styleNavigationBarStyle?nullthis call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform.
colorsNavigationColors?nullthis call's own colours. Only the ones it sets change; the rest stay the theme's.
insetsPaddingValuesPaddingValues()the room the bar keeps clear along the bottom edge and at its sides, with its fill behind it: what a scaffold gives its bottom bar. An upright bar keeps it clear around its capsule, with nothing behind it.
tagKeyString?nulla locale-independent key for automation: the bar's tag is NavigationBar_<tagKey>, and its destinations are named inside it, NavigationBar_<tagKey>.Item_<destination's key>.

NavigationBarStyle

PantrixNavigationBar's geometry, and how it marks the destination the app is at.

ParameterTypeDefaultWhat it is
heightDpDp.UnspecifiedHow tall the bar is, over the insets it keeps clear. A destination is never lower than the platform's touch target, and grows with a larger type.
indicatorWidthDpDp.UnspecifiedHow wide the pill behind the current destination's icon is, and the room a press is drawn in.
indicatorHeightDpDp.UnspecifiedHow tall that pill is.
iconSizeDpDp.UnspecifiedA destination's icon.
labelGapDpDp.UnspecifiedBetween the pill's room and the label under it.
indicatorNavigationIndicator?nullWhat marks the current destination.
dividerBoolean?nullWhether a hairline runs along the bar's top edge, inside its height; upright, around its capsule.
uprightIndicatorWidthDpDp.UnspecifiedHow wide the pill is in an upright bar, and the room a press is drawn in there. A destination there is as wide as its pill and its label.

NavigationColors

What a navigation bar or a navigation rail is painted with, where it does not take the theme's colours: a colour that is set is used, and one that is left out stays the theme's. The two take the same colours, so a screen that shows one or the other by the window's width paints both with one set.

ParameterTypeDefaultWhat it is
containerColorColor.UnspecifiedThe fill, which runs behind the insets that are kept clear. The theme's surface.
contentColorColor.UnspecifiedWhat is written on the fill: the label of the destination the app is at, under its pill, and the icons and labels of the others. On the theme's own fill the first is the theme's textPrimary and the others its textSecondary. On a fill of the caller's, and where this is given, all of them are this colour, and where no pill marks the current destination, the others are a share of it.
selectedContainerColorColor.UnspecifiedThe pill behind the current destination's icon, where the style draws one. The primary role's container.
selectedContentColorColor.UnspecifiedThe current destination's icon on its pill: the colour that reads on selectedContainer. The primary role's onContainer.
selectedTintColorColor.UnspecifiedThe current destination's icon and its label where the style draws no pill, and colour alone marks it: a colour that reads on container. The primary role's color on the theme's own fill, and content on a fill of the caller's, where nothing says that the brand's colour reads.
dividerColorColor.UnspecifiedThe hairline along a bar's top edge or a rail's end edge, where the style draws one. The theme's divider on the theme's own grounds. On a fill, where that line would not read, a share of content: the edge a subtle button has.

Accessibility