Pantrix UI

Tabs

A row of tabs that switches between views of the same content: one is selected, and a mark slides to the one selected next.

import com.pantrixui.components.container.tab.PantrixTabs

Examples

Views of a profile

At its plainest: three tabs that share the row's width equally, and the selected view below them. The selected tab's index is the screen's state; it is kept with rememberSaveable, and is not lost when the screen rotates.

The tabs do not draw the view: the text below them is chosen here, with when. The default indicator is the line under the selected tab.

var tab by rememberSaveable { mutableIntStateOf(0) }
PantrixTabs(
    tabs = listOf(
        TabItem(label = "General", tagKey = "general"),
        TabItem(label = "Security", tagKey = "security"),
        TabItem(label = "Notifications", tagKey = "notifications")
    ),
    selectedIndex = tab,
    onSelect = { tab = it },
    tagKey = "profile"
)
PantrixText(
    text = when (tab) {
        0 -> "Name, email and language"
        1 -> "Password and two-step verification"
        else -> "Which notifications you get"
    },
    color = PantrixTheme.colors.textSecondary
)

Tabs with badges

TabItem.badge puts a TabBadge after the label: a number where count is given, a dot where it is not. The "Activity" tab shows the number of unseen transactions; when the tab is opened the number goes to zero and the badge goes away. The dot on "Settings" says an update is waiting.

A screen reader reads the badge's contentDescription after the tab's name: "Activity, 3 new transactions". A number alone says nothing of what it counts; that is why the description has no default, and giving null is a decision.

var view by rememberSaveable { mutableIntStateOf(0) }
var unseen by rememberSaveable { mutableIntStateOf(3) }
PantrixTabs(
    tabs = listOf(
        TabItem(label = "Overview", tagKey = "overview"),
        TabItem(
            label = "Activity",
            tagKey = "activity",
            badge = if (unseen > 0) TabBadge(contentDescription = "$unseen new transactions", count = unseen) else null
        ),
        TabItem(
            label = "Settings",
            tagKey = "settings",
            badge = TabBadge(contentDescription = "Update available", tone = BadgeTone.Primary)
        )
    ),
    selectedIndex = view,
    onSelect = { chosen ->
        view = chosen
        if (chosen == 1) unseen = 0
    },
    tagKey = "account"
)
PantrixText(
    text = when (view) {
        0 -> "Account overview: balance and recent transactions"
        1 -> "Transactions of the last thirty days"
        else -> "Notifications, language and privacy"
    },
    color = PantrixTheme.colors.textSecondary
)

The selected tab's indicator

The style says what marks the selected tab: TabsStyle(indicator = ...). The four rows show the same selection, each with the name of its indicator written above it; press another tab in one of them, and all four change together.

Underline is the default on every platform: a line along the selected tab's bottom edge. Top puts the same line along the top edge, for a row that sits below what it switches. Pill puts a pill in the primary role's pale fill behind the selected tab, and does not draw the hairline under the row: it is how iOS draws its segmented control, and here it picks a chart's period. None draws no indicator; the selected tab is marked by its colour alone.

var period by rememberSaveable { mutableIntStateOf(1) }
val caption = PantrixTheme.typography.caption
val quiet = PantrixTheme.colors.textSecondary
PantrixText(text = "Underline", style = caption, color = quiet)
PantrixTabs(tabs = PERIODS, selectedIndex = period, onSelect = { period = it }, tagKey = "underline")
PantrixText(text = "Top", style = caption, color = quiet)
PantrixTabs(
    tabs = PERIODS,
    selectedIndex = period,
    onSelect = { period = it },
    style = TabsStyle(indicator = TabIndicator.Top),
    tagKey = "top"
)
PantrixText(text = "Pill", style = caption, color = quiet)
PantrixTabs(
    tabs = PERIODS,
    selectedIndex = period,
    onSelect = { period = it },
    style = TabsStyle(indicator = TabIndicator.Pill),
    tagKey = "period"
)
PantrixText(text = "None", style = caption, color = quiet)
PantrixTabs(
    tabs = PERIODS,
    selectedIndex = period,
    onSelect = { period = it },
    style = TabsStyle(indicator = TabIndicator.None),
    tagKey = "none"
)

private val PERIODS = listOf(
    TabItem(label = "Day", tagKey = "day"),
    TabItem(label = "Week", tagKey = "week"),
    TabItem(label = "Month", tagKey = "month")
)

Categories with icons in a row that scrolls

More categories than the row has room for, each with its icon. With scrollable = true each tab is as wide as its label and the row scrolls sideways. The selected tab comes to the middle of the row, so that the tabs on both sides of it can be seen and pressed.

The count on "Inbox" is over the largest number that is written: "99+". The last category is disabled: it cannot be pressed, and the arrow keys skip it.

var category by rememberSaveable { mutableIntStateOf(0) }
PantrixTabs(
    tabs = CATEGORIES,
    selectedIndex = category,
    onSelect = { category = it },
    scrollable = true,
    tagKey = "categories"
)

private val CATEGORIES = listOf(
    TabItem(
        label = "Inbox",
        icon = PantrixIcons.Inbox,
        tagKey = "inbox",
        badge = TabBadge(contentDescription = "More than 99 unread messages", count = 128)
    ),
    TabItem(label = "Starred", icon = PantrixIcons.Star, tagKey = "starred"),
    TabItem(label = "Folders", icon = PantrixIcons.Folder, tagKey = "folders"),
    TabItem(label = "People", icon = PantrixIcons.Person, tagKey = "people"),
    TabItem(label = "Calendar", icon = PantrixIcons.Calendar, tagKey = "calendar"),
    TabItem(label = "Search", icon = PantrixIcons.Search, tagKey = "search"),
    TabItem(label = "Archive", icon = PantrixIcons.Folder, enabled = false, tagKey = "archive")
)

With pages that are swiped

The app changes the selection as much as the user does. Here the selected tab is a pager's page: swipe the page sideways, and the indicator slides to the new tab. When a tab is pressed the tabs only report which one; it is the code in onSelect that turns the page.

val pager = rememberPagerState { 3 }
val scope = rememberCoroutineScope()
PantrixTabs(
    tabs = listOf(
        TabItem(label = "Photos", tagKey = "photos"),
        TabItem(label = "Videos", tagKey = "videos"),
        TabItem(label = "Documents", tagKey = "documents")
    ),
    selectedIndex = pager.currentPage,
    onSelect = { scope.launch { pager.animateScrollToPage(it) } },
    tagKey = "library"
)
HorizontalPager(state = pager, modifier = Modifier.height(96.dp)) { page ->
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        PantrixText(
            text = when (page) {
                0 -> "248 photos"
                1 -> "12 videos"
                else -> "37 documents"
            }
        )
    }
}

With no tab selected

When selectedIndex is an index outside the tabs, no tab is selected and no indicator is drawn: the app is in a place that is no tab's view. Press the button: the selection goes. Press a tab: it comes back.

var view by rememberSaveable { mutableIntStateOf(0) }
PantrixTabs(
    tabs = listOf(
        TabItem(label = "Feed", tagKey = "feed"),
        TabItem(label = "Explore", tagKey = "explore"),
        TabItem(label = "Saved", tagKey = "saved")
    ),
    selectedIndex = view,
    onSelect = { view = it },
    tagKey = "home"
)
PantrixButton(
    text = "Open search results",
    onClick = { view = -1 },
    variant = ButtonVariant.Outline,
    size = ButtonSize.Small,
    tagKey = "tabs_open_search"
)
PantrixText(
    text = if (view < 0) "Search results are open: no tab is selected" else "A tab's view is open",
    color = PantrixTheme.colors.textSecondary
)

In its own colours

With colors the row takes the colours it is given, not the theme's. The first row is given the colour of the selected tab and of the line; the tabs that are not selected and the hairline below stay in the theme's colours. The badge of the "Pending" tab, too, is filled with the colour it is given and not with its tone's.

The second row is a row of pills, and only the pill's fill is given. That fill is the colour of one of the theme's roles, so the theme pairs the selected label's colour with it. On a fill no role has, selectedContent is given as well.

val own = ownColors()
var order by rememberSaveable { mutableIntStateOf(0) }
var period by rememberSaveable { mutableIntStateOf(0) }
PantrixTabs(
    tabs = listOf(
        TabItem(
            label = "Pending",
            tagKey = "pending",
            badge = TabBadge(
                contentDescription = "2 pending orders",
                count = 2,
                colors = BadgeColors(container = own.color, content = own.onColor)
            )
        ),
        TabItem(label = "Shipped", tagKey = "shipped"),
        TabItem(label = "Delivered", tagKey = "delivered")
    ),
    selectedIndex = order,
    onSelect = { order = it },
    colors = TabsColors(selectedContent = own.color, indicator = own.color),
    tagKey = "orders"
)
PantrixTabs(
    tabs = PERIODS,
    selectedIndex = period,
    onSelect = { period = it },
    style = TabsStyle(indicator = TabIndicator.Pill),
    colors = TabsColors(indicator = PantrixTheme.colors.success.container),
    tagKey = "success_pill"
)

private val PERIODS = listOf(
    TabItem(label = "Day", tagKey = "day"),
    TabItem(label = "Week", tagKey = "week"),
    TabItem(label = "Month", tagKey = "month")
)

API

tabs

A row of tabs that switches between views of the same content: one is selected, and a mark slides to the one selected next.

ParameterTypeDefaultWhat it is
tabsList<TabItem>the tabs, in order.
selectedIndexIntthe selected tab's index. Outside tabs, no tab is selected and no mark is drawn.
onSelect(index: Int) -> Unitcalled with the index of the tab the user chose, by a press or by the keys, whether or not it is selected already. The row selects nothing by itself: a tab is selected when selectedIndex says so.
modifierModifierModifierthe row's modifier. The row fills the width it is given.
scrollableBooleanfalsefalse shares the row's width between the tabs. True makes each as wide as its label, at least the style's minTabWidth, in a row that scrolls sideways and keeps the selected tab in its middle.
styleTabsStyle?nullthis call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform.
colorsTabsColors?nullthis row's own colours: the ones it sets, with the theme's for the rest. A disabled tab keeps the theme's.
tagKeyString?nullthe row's automation key: the row is Tabs_<tagKey>, and its tabs are named inside it, Tabs_<tagKey>.Tab_<key>, so that two rows with the same tabs are told apart. A selection is reported as a Tabs event that carries the tab's index and whether a press or a key chose it, and never its label.

TabsStyle

PantrixTabs' geometry, and how it marks the selected tab.

ParameterTypeDefaultWhat it is
heightDpDp.UnspecifiedThe least a tab is tall: 48dp on Android, 44dp on iOS, 40dp on desktop and the web. A tab is never shorter than the platform's touch target, whatever this says.
horizontalPaddingDpDp.UnspecifiedInside a tab, before its icon or label and after its label.
indicatorHeightDpDp.UnspecifiedHow thick an TabIndicator.Underline or a TabIndicator.Top line is.
minTabWidthDpDp.UnspecifiedThe least a scrollable tab is wide.
edgePaddingDpDp.UnspecifiedBefore the first scrollable tab and after the last.
iconSizeDpDp.UnspecifiedThe size of a tab's icon.
iconGapDpDp.UnspecifiedBetween a tab's icon and its label, and between its label and its badge.
indicatorTabIndicator?nullWhat marks the selected tab.
indicatorPaddingDpDp.UnspecifiedBetween a TabIndicator.Pill and the edges of the tab it marks.

TabsColors

What PantrixTabs is painted with, for a row that does not take the theme's: a colour that is set is used, and one that is left out stays the theme's role.

ParameterTypeDefaultWhat it is
selectedContentColorColor.UnspecifiedThe label and the icon of the selected tab. The theme's primary, and on a pill the primary role's onContainer, the colour paired with the pill's fill. A pill given an indicator that is one of the theme's own colours brings the colour the theme pairs with it; for one no role has, give this with it.
unselectedContentColorColor.UnspecifiedThe label and the icon of the other tabs. The theme's textSecondary.
indicatorColorColor.UnspecifiedWhat marks the selected tab: the line under or over it, or the pill behind it. The theme's primary, and for a pill the primary role's container.
dividerColorColor.UnspecifiedThe hairline under the row, which a row of pills does not draw. The theme's divider on the theme's own grounds. A row of tabs has no fill of its own: on a fill, where that line would not read, a share of what is written there.

Accessibility