Skip to content

Instantly share code, notes, and snippets.

@LuizTM
Last active June 17, 2026 12:09
Show Gist options
  • Select an option

  • Save LuizTM/ea201981c8254fe49224d1f5dc605b59 to your computer and use it in GitHub Desktop.

Select an option

Save LuizTM/ea201981c8254fe49224d1f5dc605b59 to your computer and use it in GitHub Desktop.
custom tabbar template
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