Adaptive tab layout in Jetpack Compose


I have a custom tab row with this behaviour:

  • If all tabs fit, they share the available width equally.

  • If they don't fit, each tab uses its required width and the row becomes horizontally scrollable.

  • Each tab has a label and optional badge.

  • Selecting a partially hidden tab scrolls it fully into view.

The implementation works, but I'm looking for a more efficient/idiomatic Compose approach.

data class TabData(
    val label: String,
    val badgeCount: Int? = null,
)
@Composable
fun AdaptiveTabRow(
    tabs: List<TabData>,
    selectedIndex: Int,
    onSelectedIndexChange: (Int) -> Unit,
    modifier: Modifier = Modifier,
) {
    if (tabs.isEmpty()) return

    val isSingleTab = tabs.size == 1
    val evenSplitWidth = rememberEvenSplitWidth(tabs)

    val scrollState = rememberScrollState()

    val tabBounds = remember(tabs) {
        mutableStateMapOf<Int, IntRange>()
    }

    LaunchedEffect(selectedIndex, scrollState) {
        val bounds = snapshotFlow {
            tabBounds[selectedIndex]
        }
            .filterNotNull()
            .first()

        scrollState.scrollIntoView(bounds)
    }

    BoxWithConstraints(
        modifier = modifier,
    ) {
        val sharesWidthEvenly =
            !isSingleTab &&
                evenSplitWidth <= maxWidth

        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(48.dp)
                .then(
                    if (sharesWidthEvenly) {
                        Modifier
                    } else {
                        Modifier.horizontalScroll(scrollState)
                    }
                )
                .selectableGroup(),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            tabs.forEachIndexed { index, tab ->
                TabItem(
                    tab = tab,
                    selected = isSingleTab || index == selectedIndex,
                    onClick = {
                        onSelectedIndexChange(index)
                    },
                    sharesWidthEvenly = sharesWidthEvenly,
                    modifier = Modifier
                        .onPlaced { coordinates ->
                            val start = coordinates
                                .positionInParent()
                                .x
                                .roundToInt()

                            tabBounds[index] =
                                start until (
                                    start + coordinates.size.width
                                )
                        }
                        .then(
                            if (sharesWidthEvenly) {
                                Modifier.weight(1f)
                            } else {
                                Modifier
                            }
                        ),
                )
            }
        }
    }
}

@Composable
private fun TabItem(
    tab: TabData,
    selected: Boolean,
    onClick: () -> Unit,
    sharesWidthEvenly: Boolean,
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier
            .fillMaxHeight()
            .selectable(
                selected = selected,
                role = Role.Tab,
                onClick = onClick,
            )
            .padding(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(
            modifier = if (sharesWidthEvenly) {
                Modifier.weight(
                    weight = 1f,
                    fill = false,
                )
            } else {
                Modifier
            },
            text = tab.label,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )

        tab.badgeCount?.let { count ->
            Badge(count)
        }
    }
}

@Composable
private fun Badge(
    count: Int,
) {
    Box(
        modifier = Modifier
            .height(24.dp)
            .defaultMinSize(minWidth = 24.dp)
            .padding(horizontal = 6.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = count.toString(),
            maxLines = 1,
        )
    }
}

/**
 * Calculates how much total width would be required if every
 * tab had the same width as the widest tab.
 */
@Composable
private fun rememberEvenSplitWidth(
    tabs: List<TabData>,
): Dp {
    val textMeasurer = rememberTextMeasurer()
    val density = LocalDensity.current

    return remember(
        tabs,
        density,
        textMeasurer,
    ) {
        with(density) {
            val widestTabWidth = tabs.maxOf { tab ->
                val labelWidth = textMeasurer
                    .measure(tab.label)
                    .size
                    .width
                    .toDp()

                val badgeWidth = tab.badgeCount?.let { count ->
                    val textWidth = textMeasurer
                        .measure(count.toString())
                        .size
                        .width
                        .toDp()

                    maxOf(
                        24.dp,
                        textWidth + 12.dp,
                    )
                } ?: 0.dp

                val badgeSpacing =
                    if (tab.badgeCount != null) {
                        8.dp
                    } else {
                        0.dp
                    }

                32.dp +
                    labelWidth +
                    badgeSpacing +
                    badgeWidth
            }

            widestTabWidth * tabs.size
        }
    }
}

/**
 * Scrolls just enough to make the selected tab visible.
 */
private suspend fun ScrollState.scrollIntoView(
    bounds: IntRange,
) {
    if (viewportSize == 0) return

    val target = when {
        bounds.first < value -> {
            bounds.first
        }

        bounds.last > value + viewportSize -> {
            bounds.last - viewportSize
        }

        else -> return
    }

    animateScrollTo(
        target.coerceIn(
            minimumValue = 0,
            maximumValue = maxValue,
        )
    )
}

Is there a more efficient and idiomatic way to build this in Jetpack Compose?

In particular, can this be simplified by measuring the actual tab composables during layout instead of using rememberTextMeasurer(), BoxWithConstraints, onPlaced, and stored tab bounds?

I'm mainly looking to reduce unnecessary measurement/layout work and simplify the implementation while keeping the same behaviour.

1
Aug 17 at 10:27 PM
User AvatarCompose Learner
#best-practices#android#kotlin#android-jetpack-compose

No answer found for this question yet.