Skip to content

Instantly share code, notes, and snippets.

@ardakazanci
Created July 19, 2025 06:32
Show Gist options
  • Save ardakazanci/5546bb29d13a1eeedbea99ba9693e87f to your computer and use it in GitHub Desktop.
Save ardakazanci/5546bb29d13a1eeedbea99ba9693e87f to your computer and use it in GitHub Desktop.
Stretch Tab Jetpack Compose
data class TabPosition(val left: Float, val right: Float)
@Composable
fun StretchTabComponent() {
val tabs = listOf("SALE", "RENT")
var selectedIndex by remember { mutableIntStateOf(0) }
val tabPositions = remember { mutableStateListOf<TabPosition>() }
val startX = remember { Animatable(0f) }
val endX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val maxRippleRadius = with(density) { 80.dp.toPx() }
LaunchedEffect(tabPositions.size) {
if (tabPositions.size == tabs.size) {
val target = tabPositions[selectedIndex]
startX.snapTo(target.left)
endX.snapTo(target.right)
}
}
Box(
Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
Box(
Modifier
.shadow(4.dp, RoundedCornerShape(12.dp))
.background(Color(0xFF1A1C1D), RoundedCornerShape(12.dp))
.width(250.dp)
.height(60.dp)
) {
Row(
Modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
tabs.forEachIndexed { index, title ->
val rippleRadius = remember { Animatable(0f) }
val rippleAlpha = remember { Animatable(0f) }
val textColor by animateColorAsState(
targetValue = if (selectedIndex == index)
Color.White else Color.LightGray.copy(alpha = 0.5f),
animationSpec = tween(durationMillis = 300)
)
Box(
Modifier
.weight(1f)
.wrapContentWidth(Alignment.CenterHorizontally)
.onGloballyPositioned { coords ->
val x = coords.positionInParent().x
val w = coords.size.width.toFloat()
if (tabPositions.size <= index)
tabPositions.add(TabPosition(x, x + w))
else
tabPositions[index] = TabPosition(x, x + w)
}
.pointerInput(Unit) {
detectTapGestures {
if (selectedIndex == index) return@detectTapGestures
scope.launch {
rippleRadius.snapTo(0f)
rippleAlpha.snapTo(0.5f)
launch {
rippleRadius.animateTo(
maxRippleRadius,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessVeryLow
)
)
}
launch {
rippleAlpha.animateTo(
0f,
tween(durationMillis = 1000)
)
}
}
val oldIdx = selectedIndex
val newIdx = index
val target = tabPositions[newIdx]
val overshoot = 30f
val moveRight = newIdx > oldIdx
scope.launch {
when {
moveRight -> {
endX.animateTo(
target.right + overshoot,
tween(150, easing = LinearEasing)
)
}
else -> {
startX.animateTo(
target.left - overshoot,
tween(150, easing = LinearEasing)
)
}
}
when {
moveRight -> {
startX.animateTo(
target.left,
tween(200, easing = FastOutSlowInEasing)
)
}
else -> {
endX.animateTo(
target.right,
tween(200, easing = FastOutSlowInEasing)
)
}
}
when {
moveRight -> {
endX.animateTo(
target.right,
tween(200, easing = FastOutSlowInEasing)
)
}
else -> {
startX.animateTo(
target.left,
tween(200, easing = FastOutSlowInEasing)
)
}
}
selectedIndex = newIdx
}
}
},
contentAlignment = Alignment.Center
) {
Canvas(Modifier.matchParentSize()) {
drawCircle(
color = Color.White.copy(alpha = rippleAlpha.value),
radius = rippleRadius.value,
center = Offset(size.width / 2, size.height / 2)
)
}
Text(
text = title,
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
}
}
}
Canvas(
Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
) {
drawLine(
color = Color(0xFFFF2C55),
start = Offset(startX.value, size.height - 8.dp.toPx()),
end = Offset(endX.value, size.height - 8.dp.toPx()),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment