Last active
June 17, 2026 12:09
-
-
Save LuizTM/ea201981c8254fe49224d1f5dc605b59 to your computer and use it in GitHub Desktop.
custom tabbar template
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.example.playground.compose | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.indication | |
| import androidx.compose.foundation.interaction.MutableInteractionSource | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.aspectRatio | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.wrapContentWidth | |
| import androidx.compose.foundation.lazy.LazyColumn | |
| import androidx.compose.foundation.shape.CircleShape | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material.icons.Icons | |
| import androidx.compose.material.icons.filled.Add | |
| import androidx.compose.material.icons.filled.Build | |
| import androidx.compose.material.ripple.rememberRipple | |
| import androidx.compose.material3.Button | |
| import androidx.compose.material3.ButtonDefaults | |
| import androidx.compose.material3.Icon | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableIntStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.shadow | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.vector.ImageVector | |
| import androidx.compose.ui.layout.Layout | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.platform.testTag | |
| import androidx.compose.ui.semantics.Role | |
| import androidx.compose.ui.semantics.contentDescription | |
| import androidx.compose.ui.semantics.role | |
| import androidx.compose.ui.semantics.semantics | |
| import androidx.compose.ui.semantics.testTagsAsResourceId | |
| import androidx.compose.ui.tooling.preview.Devices.PIXEL_5 | |
| import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO | |
| import androidx.compose.ui.tooling.preview.Devices.PIXEL_FOLD | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.Constraints | |
| import androidx.compose.ui.unit.Dp | |
| import androidx.compose.ui.unit.dp | |
| /** | |
| * A single action item for the FixedBottomFloatingActionsRow. | |
| */ | |
| data class FloatingActionItem( | |
| val icon: ImageVector, | |
| val contentDescription: String, | |
| val onClick: (item: FloatingActionItem, index: Int) -> Unit, | |
| val backgroundColor: Color? = null, | |
| val label: String? = null | |
| ) | |
| /** | |
| * FixedBottomFloatingActionsRow | |
| * - Non-collapsing horizontal row anchored to the bottom of the view | |
| * - Shows between 2 and 5 circular action buttons (Material FAB style) | |
| * - Has a "floating" surface look through tonalElevation and rounded corners | |
| * | |
| * This version uses a single-pass Layout to measure each action group (button + optional label) | |
| * reliably and performantly. Each group is measured with the same fixed width so buttons stay circular | |
| * and labels don't break the layout. The trailingAction is composed as a part of the groups but placed | |
| * outside the background box so it visually floats while still contributing to sizing calculations. | |
| */ | |
| @Composable | |
| fun FixedBottomFloatingActionsRow( | |
| items: List<FloatingActionItem>, | |
| // optional trailing action that should follow the same sizing rules but be drawn outside the background | |
| trailingAction: FloatingActionItem? = null, | |
| // optional shared click handler: receives the clicked item and its index | |
| onActionClick: ((item: FloatingActionItem, index: Int) -> Unit)? = null, | |
| // min/max button sizes and spacing allow the component to adapt to different screen widths/densities | |
| minButtonSize: Dp = 40.dp, | |
| maxButtonSize: Dp = 65.dp, | |
| minSpacing: Dp = 8.dp, | |
| maxSpacing: Dp = 16.dp, | |
| backgroundColor: Color? = null, | |
| showLabels: Boolean = false, | |
| ) { | |
| val clamped = when { | |
| items.size < 2 -> throw IllegalArgumentException("FixedBottomFloatingActionsRow requires at least 2 items") | |
| items.size > 5 -> throw IllegalArgumentException("FixedBottomFloatingActionsRow supports at most 5 items") | |
| else -> items | |
| } | |
| // Compose all groups (items + optional trailing) into a single list so Layout sees them 1:1 | |
| val groups = remember(clamped, trailingAction) { clamped + listOfNotNull(trailingAction) } | |
| // horizontal padding inside the background (these will be included in the measured size) | |
| val horizontalPadding = 10.dp | |
| val verticalPadding = 8.dp | |
| // selection state (hoisted so it's stable across recompositions and usable inside Layout content) | |
| var selectedIndex by remember { mutableIntStateOf(0) } | |
| // Root layout that draws a shaped background (first child) and places all groups | |
| val density = LocalDensity.current | |
| // Cache dp->px conversions to avoid recomputing them on every measure pass | |
| val minButtonPxCached = | |
| remember(density, minButtonSize) { with(density) { minButtonSize.toPx() } } | |
| val maxButtonPxCached = | |
| remember(density, maxButtonSize) { with(density) { maxButtonSize.toPx() } } | |
| val minSpacingPxCached = remember(density, minSpacing) { with(density) { minSpacing.toPx() } } | |
| val maxSpacingPxCached = remember(density, maxSpacing) { with(density) { maxSpacing.toPx() } } | |
| val horizontalPaddingPxCached = | |
| remember(density, horizontalPadding) { with(density) { horizontalPadding.toPx() } } | |
| val verticalPaddingPxCached = | |
| remember(density, verticalPadding) { with(density) { verticalPadding.toPx() } } | |
| val gapPxCached = remember(density) { with(density) { 18.dp.toPx() } } | |
| // Root layout that draws a shaped background (first child) and places all groups | |
| Layout( | |
| content = { | |
| // background placeholder - measured and placed first (draw capsule using shadow+background to avoid extra Surface node) | |
| Box( | |
| modifier = Modifier | |
| .shadow(6.dp, shape = RoundedCornerShape(percent = 100)) | |
| .background( | |
| backgroundColor ?: MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), | |
| shape = RoundedCornerShape(percent = 100) | |
| ) | |
| ) {} | |
| // action groups (one measurable per group) | |
| // action groups (one measurable per group) — iterate with index to support shared click lambda | |
| for ((index, item) in groups.withIndex()) { | |
| FloatingActionGroup( | |
| index = index, | |
| item = item, | |
| isSelected = selectedIndex == index, | |
| showLabels = showLabels, | |
| onSelect = { selectedIndex = it }, | |
| onActionClick = onActionClick | |
| ) | |
| } | |
| }, | |
| modifier = Modifier | |
| .wrapContentWidth() | |
| .padding(bottom = 16.dp) | |
| ) { measurables, constraints -> | |
| // measurables[0] = background placeholder, measurables[1..] = groups | |
| val backgroundIndex = 0 | |
| val groupMeasurables = | |
| if (measurables.size > 1) measurables.subList(1, measurables.size) else emptyList() | |
| // use cached px values | |
| val minButtonPx = minButtonPxCached | |
| val maxButtonPx = maxButtonPxCached | |
| val minSpacingPx = minSpacingPxCached | |
| val maxSpacingPx = maxSpacingPxCached | |
| val horizontalPaddingPx = horizontalPaddingPxCached | |
| val verticalPaddingPx = verticalPaddingPxCached | |
| val gapPx = gapPxCached | |
| val maxAvailable = | |
| constraints.maxWidth.toFloat().coerceAtLeast(0f) - 4 * horizontalPaddingPx | |
| // sizingGroupCount determines button size & spacing (includes trailing) | |
| val sizingGroupCount = groupMeasurables.size | |
| val mainGroupCount = clamped.size | |
| // compute candidate button size assuming minimum spacing (use sizingGroupCount) | |
| val totalMinSpacing = minSpacingPx * ((sizingGroupCount - 1).coerceAtLeast(0)) | |
| val candidateButton = ((maxAvailable - 2 * horizontalPaddingPx - totalMinSpacing) / maxOf( | |
| 1, | |
| sizingGroupCount | |
| ).toFloat()).coerceIn(minButtonPx, maxButtonPx) | |
| // compute spacing based on candidate button | |
| var spacing = if (sizingGroupCount > 1) { | |
| ((maxAvailable - 2 * horizontalPaddingPx - candidateButton * sizingGroupCount) / (sizingGroupCount - 1)).coerceIn( | |
| minSpacingPx, | |
| maxSpacingPx | |
| ) | |
| } else { | |
| minSpacingPx | |
| } | |
| // If spacing is below minimum, reduce button size to make spacing minSpacing | |
| var finalButton = candidateButton | |
| if (spacing < minSpacingPx) { | |
| spacing = minSpacingPx | |
| finalButton = | |
| ((maxAvailable - 2 * horizontalPaddingPx - spacing * (sizingGroupCount - 1)) / maxOf( | |
| 1, | |
| sizingGroupCount | |
| ).toFloat()).coerceIn(minButtonPx, maxButtonPx) | |
| } | |
| val buttonPx = finalButton.toInt().coerceAtLeast(0) | |
| val spacingPx = spacing.toInt() | |
| // measure each group with fixed width (button width) using an explicit loop to avoid allocations | |
| val childConstraints = Constraints.fixedWidth(buttonPx) | |
| val groupPlaceables = ArrayList<androidx.compose.ui.layout.Placeable>(groupMeasurables.size) | |
| for (i in groupMeasurables.indices) { | |
| val placeable = groupMeasurables[i].measure(childConstraints) | |
| groupPlaceables.add(placeable) | |
| } | |
| // total content width for main groups (exclude trailing from surface width) | |
| val mainContentWidthPx = | |
| if (mainGroupCount > 0) buttonPx * mainGroupCount + spacingPx * (mainGroupCount - 1) else 0 | |
| val measuredSurfaceWidthPx = (mainContentWidthPx + 2 * horizontalPaddingPx).toInt() | |
| // trailing placeable (if present) | |
| val trailingPlaceable = | |
| if (trailingAction != null && groupPlaceables.size == sizingGroupCount) groupPlaceables.last() else null | |
| // total measured width includes surface + gap + trailing button (if any) | |
| val measuredTotalWidthPx = | |
| measuredSurfaceWidthPx + if (trailingPlaceable != null) (gapPx.toInt() + buttonPx) else 0 | |
| // height is max of main group heights (and trailing, to ensure enough space) | |
| val height = if (groupPlaceables.isNotEmpty()) groupPlaceables.maxOf { it.height } else 0 | |
| val measuredHeightPx = (height + (verticalPaddingPx * 2)).toInt() | |
| // measure background placeholder with exact surface size (now that we know height) | |
| val bgPlaceable = measurables.getOrNull(backgroundIndex) | |
| ?.measure(Constraints.fixed(measuredSurfaceWidthPx, measuredHeightPx)) | |
| layout(measuredTotalWidthPx.coerceAtMost(constraints.maxWidth), measuredHeightPx) { | |
| // place background (drawn as a Box with background/shape by composing earlier) | |
| bgPlaceable?.placeRelative(0, 0) | |
| // place groups: all groups measured; trailing group will be placed outside background area (floating) | |
| var x = horizontalPaddingPx.toInt() | |
| for (i in groupPlaceables.indices) { | |
| val p = groupPlaceables[i] | |
| val y = verticalPaddingPx.toInt() | |
| if (trailingAction != null && i == groupPlaceables.lastIndex) { | |
| // trailing group: place immediately after the last main button (outside the background) | |
| val trailingX = horizontalPaddingPx.toInt() + mainContentWidthPx + gapPx.toInt() | |
| p.placeRelative(trailingX, y) // align with main groups vertically | |
| } else { | |
| p.placeRelative(x, y) | |
| x += buttonPx + spacingPx | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun FloatingActionGroup( | |
| index: Int, | |
| item: FloatingActionItem, | |
| isSelected: Boolean, | |
| showLabels: Boolean, | |
| onSelect: (Int) -> Unit, | |
| onActionClick: ((item: FloatingActionItem, index: Int) -> Unit)? = null | |
| ) { | |
| Column(horizontalAlignment = Alignment.CenterHorizontally) { | |
| val interactionSource = remember { MutableInteractionSource() } | |
| val rippleColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.12f) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .aspectRatio(1f), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| if (isSelected) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background( | |
| MaterialTheme.colorScheme.tertiary, | |
| shape = CircleShape | |
| ) | |
| ) | |
| } | |
| Button( | |
| onClick = { | |
| onSelect(index) | |
| item.onClick(item, index) | |
| onActionClick?.invoke(item, index) | |
| }, | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .indication( | |
| interactionSource, | |
| rememberRipple(color = rippleColor, bounded = false) | |
| ) | |
| .testTag("fab_action_$index") | |
| .semantics { | |
| contentDescription = item.contentDescription | |
| role = Role.Button | |
| }, | |
| shape = CircleShape, | |
| interactionSource = interactionSource, | |
| colors = ButtonDefaults.elevatedButtonColors( | |
| containerColor = if (isSelected) Color.Transparent else MaterialTheme.colorScheme.surface, | |
| contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface | |
| ) | |
| ) { | |
| Icon(imageVector = item.icon, contentDescription = item.contentDescription) | |
| } | |
| } | |
| if (showLabels && !item.label.isNullOrEmpty()) { | |
| Spacer(modifier = Modifier.height(6.dp)) | |
| Text( | |
| text = item.label, | |
| style = MaterialTheme.typography.labelSmall, | |
| color = MaterialTheme.colorScheme.onSurfaceVariant | |
| ) | |
| } | |
| } | |
| } | |
| @Preview(device = PIXEL_5) | |
| @Preview(device = PIXEL_9_PRO) | |
| @Preview(device = PIXEL_FOLD) | |
| @Composable | |
| fun PreviewFixedBottomFloatingActionsRow() { | |
| val items = listOf( | |
| FloatingActionItem( | |
| icon = Icons.Default.Add, | |
| contentDescription = "One", | |
| onClick = { _, _ -> }, | |
| label = "One" | |
| ), | |
| FloatingActionItem( | |
| icon = Icons.Default.Add, | |
| contentDescription = "Two", | |
| onClick = { _, _ -> }, | |
| label = "Two" | |
| ), | |
| FloatingActionItem( | |
| icon = Icons.Filled.Build, | |
| contentDescription = "Three", | |
| onClick = { _, _ -> }, | |
| label = "Three" | |
| ), | |
| FloatingActionItem( | |
| icon = Icons.Default.Add, | |
| contentDescription = "Four", | |
| onClick = { _, _ -> }, | |
| label = "Four" | |
| ) | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(MaterialTheme.colorScheme.background), | |
| contentAlignment = Alignment.BottomCenter | |
| ) { | |
| LazyColumn { | |
| items(50) { index -> | |
| Text( | |
| text = "Item #$index", modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(16.dp) | |
| ) | |
| } | |
| } | |
| FixedBottomFloatingActionsRow( | |
| items = items, | |
| trailingAction = FloatingActionItem( | |
| icon = Icons.Default.Add, | |
| contentDescription = "Main", | |
| onClick = { item, index -> | |
| println("Selected trailing action: ${item.contentDescription} at index $index") | |
| }, | |
| label = null | |
| ), | |
| backgroundColor = Color.White.copy(alpha = 0.55f), | |
| showLabels = true, | |
| ) | |
| } | |
| } | |
| @Composable | |
| fun ComposePlayground() { | |
| val items = listOf( | |
| FloatingActionItem( | |
| label = "One", | |
| icon = Icons.Default.Add, | |
| contentDescription = "Action 1", | |
| onClick = { item, index -> | |
| println("Selected trailing action: ${item.contentDescription} at index $index") | |
| }), | |
| FloatingActionItem( | |
| icon = Icons.Default.Add, | |
| contentDescription = "Action 2", | |
| onClick = { item, index -> | |
| println("Selected trailing action: ${item.contentDescription} at index $index") | |
| }), | |
| FloatingActionItem( | |
| icon = Icons.Default.Add, | |
| contentDescription = "Action 3", | |
| onClick = { item, index -> | |
| println("Selected trailing action: ${item.contentDescription} at index $index") | |
| }), | |
| FloatingActionItem( | |
| icon = Icons.Default.Add, | |
| contentDescription = "Action 4", | |
| onClick = { item, index -> | |
| println("Selected trailing action: ${item.contentDescription} at index $index") | |
| }) | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.LightGray) | |
| .semantics{ testTagsAsResourceId = true}, | |
| contentAlignment = Alignment.BottomCenter | |
| ) { | |
| LazyColumn { | |
| items(50) { index -> | |
| Text( | |
| text = "Item #$index", modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(16.dp) | |
| ) | |
| } | |
| } | |
| FixedBottomFloatingActionsRow( | |
| items = items, | |
| trailingAction = FloatingActionItem( | |
| icon = Icons.Default.Add, | |
| contentDescription = "Main", | |
| onClick = { item, index -> | |
| println("Selected trailing action: ${item.contentDescription} at index $index") | |
| }, | |
| label = null, | |
| ), | |
| backgroundColor = Color.White.copy(alpha = 0.55f), | |
| showLabels = false | |
| ) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment