Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created October 31, 2025 15:44
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/e4f0ea887c6b20758cd760d56849266a to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/e4f0ea887c6b20758cd760d56849266a to your computer and use it in GitHub Desktop.
/*
* Copyright 2025 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("MagicNumber")
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.PersonOutline
import androidx.compose.material.icons.outlined.Place
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sin
import androidx.compose.ui.graphics.lerp as colorLerp
private data class ThemeSpec(val bg: Color, val bgPill: Color, val bubble: Color)
private val Themes = listOf(
ThemeSpec(Color(0xFF0E1A2B), Color(0xFF22406C), Color(0xFF4FC3F7)),
ThemeSpec(Color(0xFF1A1423), Color(0xFF3D2C5F), Color(0xFFB388FF)),
ThemeSpec(Color(0xFF0D1F1E), Color(0xFF1F4B48), Color(0xFF64FFDA)),
ThemeSpec(Color(0xFF24160B), Color(0xFF5C3415), Color(0xFFFFAB40)),
ThemeSpec(Color(0xFF101A12), Color(0xFF295233), Color(0xFF81C784))
)
private val BarWhite = Color(0xFFFFFFFF)
private val IconGray = Color(0xFF2C2C2C)
private const val HANDLE_BASE = 0.44f
private val ARC_BASE_DP = 22.dp
private val WAVE_MAX_DP = 6.dp
private const val MIN_DUR = 1100
private const val MAX_DUR = 1900
private val PATH_EASING = CubicBezierEasing(0.15f, 0.00f, 0.00f, 1.00f)
private const val SRC_STRETCH_END = 0.35f
private const val SRC_CLOSE_END = 0.48f
private const val SOURCE_OVERSHOOT = 0.16f
private const val LAND_START = 0.70f
private const val LAND_END = 1.00f
private const val DEST_POP = 0.16f
private val ICON_CENTER_BIAS_DP = (-1.5).dp
@Composable
private fun PillsBackground(
modifier: Modifier = Modifier,
bgColor: Color,
pillColor: Color
) {
Canvas(modifier.fillMaxSize()) {
drawRect(bgColor)
fun pill(x: Float, y: Float, w: Float, h: Float, radius: Float, angle: Float, alpha: Float) {
rotate(degrees = angle, pivot = Offset(x + w / 2f, y + h / 2f)) {
drawRoundRect(
color = pillColor.copy(alpha = alpha),
topLeft = Offset(x, y),
size = androidx.compose.ui.geometry.Size(w, h),
cornerRadius = CornerRadius(radius, radius)
)
}
}
val W = size.width
val H = size.height
val r = min(W, H)
pill(W * 0.15f, H * 0.15f, r * 0.55f, r * 0.10f, r * 0.05f, -18f, 0.30f)
pill(W * 0.55f, H * 0.10f, r * 0.45f, r * 0.08f, r * 0.04f, 24f, 0.22f)
pill(W * 0.10f, H * 0.60f, r * 0.60f, r * 0.11f, r * 0.06f, 14f, 0.18f)
pill(W * 0.50f, H * 0.70f, r * 0.40f, r * 0.09f, r * 0.05f, -28f, 0.15f)
}
}
/**
* Concave notch bottom bar with a traveling "liquid" notch and bubble.
*
* This composable draws a bottom bar whose top edge has only the top corners rounded and a
* concave cut-out (the notch) that moves to the selected item. A colored circular "bubble"
* rides in that notch and performs a short arc (slingshot) toward the destination item.
* Icons vertically follow the bubble (lift) and cross-fade their tint.
*
* Animation details:
* - Duration automatically scales with the number of tabs crossed (near = faster, far = slower).
* - The notch widens/deepens on the active (destination) slot on contact, producing a small
* "pop" effect; the source notch closes smoothly.
* - A subtle surface wave runs along the top edge, centered on the bubble.
*
* Layout details:
* - Only the bar's top corners are rounded; the bottom edge is flat for seamless insets.
* - `barTopFraction` controls how much of the total height is above the white bar area.
* - `itemBoxWidth` defines each icon slot's width for spacing and touch target alignment.
*
* @param items Icons to display, in order, one per tab.
* @param selectedIndex The currently selected tab index; triggers the transition when changed.
* @param onItemSelected Callback invoked when a tab is tapped with the index of that tab.
* @param modifier Optional [Modifier] for this bar.
* @param barHeight Total height of the component (background + bar + notch area).
* @param cornerRadius Radius for the bar's top-left/top-right corners.
* @param bubbleSize Diameter of the colored bubble that rides in the notch.
* @param notchPadding Extra horizontal padding around the bubble that shapes the notch.
* @param barTopFraction Fraction of [barHeight] from the top to the white bar top line
* (0f..1f). The remaining height becomes the white bar section.
* @param itemBoxWidth Width reserved for each icon cell; affects spacing and hit area.
* @param edgeTightenDp Reduces side insets so end items sit closer to the corners.
* @param bubbleColor Base color of the bubble; can be animated by the caller.
*/
@Composable
fun ConcaveNotchBottomBar(
items: List<ImageVector>,
selectedIndex: Int,
onItemSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
barHeight: Dp = 116.dp,
cornerRadius: Dp = 18.dp,
bubbleSize: Dp = 46.dp,
notchPadding: Dp = 12.dp,
barTopFraction: Float = 0.45f,
itemBoxWidth: Dp = 32.dp,
edgeTightenDp: Dp = 12.dp,
bubbleColor: Color = Color(0xFF4FC3F7)
) {
var fromIndex by remember { mutableIntStateOf(selectedIndex) }
var toIndex by remember { mutableIntStateOf(selectedIndex) }
val tAnim = remember { Animatable(1f) }
LaunchedEffect(selectedIndex, items.size) {
if (selectedIndex == toIndex) return@LaunchedEffect
fromIndex = toIndex
toIndex = selectedIndex
val steps = abs(toIndex - fromIndex)
val maxSteps = max(1, items.size - 1)
val frac = steps.toFloat() / maxSteps
val dur = lerpInt(MIN_DUR, MAX_DUR, frac)
tAnim.snapTo(0f)
tAnim.animateTo(1f, tween(dur))
}
val density = LocalDensity.current
val barTopDp = barHeight * barTopFraction
val whiteHeightDp = barHeight * (1f - barTopFraction)
Surface(color = Color.Transparent, modifier = modifier.fillMaxWidth()) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val w = with(density) { maxWidth.toPx() }
val h = with(density) { barHeight.toPx() }
val r = with(density) { cornerRadius.toPx() }
val barTop = h * barTopFraction
val whiteH = h - barTop
val bubbleR = with(density) { bubbleSize.toPx() } / 2f
val pad = with(density) { notchPadding.toPx() }
val notchHalfW = bubbleR * 1.72f + pad
val notchDepth = min(bubbleR + pad * 1.35f, (h - barTop) * 0.92f)
val hardInset = r + notchHalfW
val maxPull = max(0f, hardInset - (notchHalfW + with(density) { 4.dp.toPx() }))
val pull = min(with(density) { edgeTightenDp.toPx() }, maxPull)
val inset = hardInset - pull
val itemW = with(density) { itemBoxWidth.toPx() }
val cStart = inset + itemW / 2f
val cEnd = (w - inset) - itemW / 2f
val step = if (items.size > 1) (cEnd - cStart) / (items.size - 1) else 0f
fun cxFor(i: Int) = cStart + i * step
val t = PATH_EASING.transform(tAnim.value)
val ts = slingTimeSmooth(t)
val fromCx = cxFor(fromIndex)
val toCx = cxFor(toIndex)
val dx = toCx - fromCx
val handle = HANDLE_BASE * (1f + 0.35f * (abs(dx) / (step * max(1, items.size - 1))))
val c1 = fromCx + dx * handle
val c2 = toCx - dx * handle
val bubbleCx = cubicBezier1D(fromCx, c1, c2, toCx, ts)
val arcPx = with(density) { ARC_BASE_DP.toPx() } * (1f + 0.25f * (abs(dx) / max(step, 1f)))
val jumpArc = -arcPx * sin(PI * ts).toFloat()
val valleyY = barTop + notchDepth
val rawBubbleCy = valleyY - (bubbleR + pad) + jumpArc
val minBubbleCy = bubbleR + with(density) { 2.dp.toPx() }
val bubbleCy = max(minBubbleCy, rawBubbleCy)
val iconRestCY = barTop + whiteH / 2f
val iconBiasPx = with(density) { ICON_CENTER_BIAS_DP.toPx() }
val iconLiftPx = (bubbleCy - iconRestCY) + iconBiasPx
val horizontalInsetDp = with(density) { inset.toDp() }
val closeGate = 1f - gate(ts, 0f, SRC_CLOSE_END)
val stretchBell = softBell(ts, 0f, SRC_STRETCH_END)
val srcStrength = closeGate
val srcOvershoot = 1f + SOURCE_OVERSHOOT * stretchBell
val slotsAway = abs((bubbleCx - toCx) / (step.takeIf { it > 0f } ?: 1f))
val prox = smoothstepPow(1f - slotsAway.coerceIn(0f, 1.1f), 3.0f)
val contactRaw = 1f - (abs(jumpArc) / max(arcPx, 1e-3f))
val contactGate = gate(ts, LAND_START, LAND_END)
val contact = (contactRaw.coerceIn(0f, 1f) * contactGate).coerceIn(0f, 1f)
val destStrength = (prox * contact).coerceIn(0f, 1f)
val contactPop = if (contact > 0f) destStrength.pow(0.6f) else 0f
val destWidthMul = 1f + DEST_POP * contactPop
val destDepthMul = 1f + DEST_POP * contactPop
val notches = buildList {
if (srcStrength > 0.001f) add(
Notch(
cx = fromCx,
halfWidth = notchHalfW * srcStrength * srcOvershoot,
depth = notchDepth * srcStrength * srcOvershoot
)
)
if (destStrength > 0.001f) add(
Notch(
cx = toCx,
halfWidth = notchHalfW * destStrength * destWidthMul,
depth = notchDepth * destStrength * destDepthMul
)
)
}
val wave = waveAmplitude(ts, with(density) { WAVE_MAX_DP.toPx() }) + (2f * contactPop)
val bubbleCyAtFromStart = valleyY - (bubbleR + pad)
val prevLiftStartPx = (bubbleCyAtFromStart - iconRestCY) + iconBiasPx
val prevReturnLiftPx = prevLiftStartPx * closeGate
val prevTintBlend = gate(ts, 0f, SRC_CLOSE_END)
val prevTintColor = colorLerp(Color.White, IconGray, prevTintBlend)
Box(Modifier.fillMaxWidth()) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(barHeight)
) {
drawTopRoundedBarWithNotches(
width = w,
height = h,
top = barTop,
topCornerRadius = r,
notches = notches,
waveCenterX = bubbleCx,
waveAmplitude = wave,
color = BarWhite
)
drawCircle(color = bubbleColor, radius = bubbleR, center = Offset(bubbleCx, bubbleCy))
}
Column(
modifier = Modifier
.fillMaxWidth()
.height(barHeight)
) {
Spacer(Modifier.height(barTopDp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalInsetDp)
.height(whiteHeightDp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
items.forEachIndexed { index, icon ->
val isTo = index == toIndex
val isFrom = index == fromIndex && fromIndex != toIndex
val lift = when {
isTo -> iconLiftPx
isFrom -> prevReturnLiftPx
else -> 0f
}
val tint = when {
isTo -> Color.White
isFrom -> prevTintColor
else -> IconGray
}
Box(
modifier = Modifier
.width(itemBoxWidth)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onItemSelected(index) },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier
.graphicsLayer { translationY = lift }
.size(if (isTo) 28.dp else 26.dp)
)
}
}
}
}
}
}
}
}
private fun DrawScope.drawTopRoundedBarWithNotches(
width: Float,
height: Float,
top: Float,
topCornerRadius: Float,
notches: List<Notch>,
waveCenterX: Float,
waveAmplitude: Float,
color: Color
) {
val left = 0f
val right = width
val bottom = height
val ns = notches.sortedBy { it.cx }
val eps = 0.8f
val nearThreshold = topCornerRadius + 2f
val waveWidth = width * 0.26f
val ctrlY = top - waveAmplitude * 0.6f
val rightCtrlX = min(right - topCornerRadius * 1.1f, waveCenterX + waveWidth * 0.55f)
val leftCtrlX = max(left + topCornerRadius * 1.1f, waveCenterX - waveWidth * 0.55f)
val leftmost = ns.firstOrNull()
val rightmost = ns.lastOrNull()
val leftNearCorner = leftmost != null && (leftmost.cx - leftmost.halfWidth) <= (left + topCornerRadius + nearThreshold)
val rightNearCorner = rightmost != null && (rightmost.cx + rightmost.halfWidth) >= (right - topCornerRadius - nearThreshold)
val p = Path().apply {
moveTo(left, bottom)
lineTo(right, bottom)
lineTo(right, top + topCornerRadius)
quadraticTo(right, top, right - topCornerRadius, top)
fun Path.roundedU(cx: Float, halfW: Float, depth: Float) {
val k = 0.66f
val startX = cx - halfW - eps
val endX = cx + halfW + eps
val valleyX = cx
val valleyY = top + depth
cubicTo(endX - halfW * (1f - k), top, valleyX + halfW * k, valleyY, valleyX, valleyY)
cubicTo(valleyX - halfW * k, valleyY, startX + halfW * (1f - k), top, startX, top)
}
for (i in ns.indices.reversed()) {
val n = ns[i]
val endX = n.cx + n.halfWidth + eps
if (i == ns.lastIndex && rightNearCorner) {
lineTo(endX, top)
} else {
quadraticTo(rightCtrlX, ctrlY, endX, top)
}
roundedU(n.cx, n.halfWidth, n.depth)
}
if (leftNearCorner) {
lineTo(left + topCornerRadius, top)
} else {
quadraticTo(leftCtrlX, ctrlY, left + topCornerRadius, top)
}
quadraticTo(left, top, left, top + topCornerRadius)
lineTo(left, bottom)
close()
}
drawPath(path = p, color = color)
}
private data class Notch(val cx: Float, val halfWidth: Float, val depth: Float)
private fun cubicBezier1D(p0: Float, p1: Float, p2: Float, p3: Float, t: Float): Float {
val u = 1f - t
return u * u * u * p0 + 3f * u * u * t * p1 + 3f * u * t * t * p2 + t * t * t * p3
}
private fun slingTimeSmooth(t: Float): Float {
val fastOut = CubicBezierEasing(0.05f, 0.00f, 0.20f, 1.00f).transform(t)
val blend = if (t < 0.5f) 0.85f else 0.55f
val base = (fastOut * blend + t * (1f - blend)).coerceIn(0f, 1f)
val cushion = smoothstep((base / 0.25f).coerceIn(0f, 1f))
return (0.12f * cushion + 0.88f * base).coerceIn(0f, 1f)
}
private fun softBell(t: Float, a: Float, b: Float): Float {
if (t <= a || t >= b) return 0f
val x = ((t - a) / (b - a)).coerceIn(0f, 1f)
val s = smoothstep(x)
val mid = if (s <= 0.5f) s * 2f else (1f - s) * 2f
return smoothstep(mid)
}
private fun gate(t: Float, a: Float, b: Float): Float {
if (t <= a) return 0f
if (t >= b) return 1f
val x = ((t - a) / (b - a)).coerceIn(0f, 1f)
return smoothstep(x)
}
private fun smoothstep(x: Float): Float {
val t = x.coerceIn(0f, 1f)
return t * t * t * (t * (t * 6 - 15) + 10)
}
private fun smoothstepPow(x: Float, power: Float): Float {
val s = smoothstep(x)
return s.toDouble().pow(power.toDouble()).toFloat()
}
private fun waveAmplitude(t: Float, maxAmp: Float): Float {
val takeoff = if (t <= 0.35f) sin(t / 0.35f * PI).toFloat() else 0f
val land = if (t >= 0.55f) sin((t - 0.55f) / 0.45f * PI).toFloat() else 0f
return maxAmp * max(takeoff, land)
}
private fun lerpInt(a: Int, b: Int, t: Float): Int = (a + (b - a) * t).toInt()
@Composable
fun ConcaveNotchBottomBarDemo() {
var selected by remember { mutableIntStateOf(2) }
val icons = listOf(
Icons.Outlined.Settings,
Icons.Outlined.FavoriteBorder,
Icons.Outlined.Place,
Icons.Outlined.NotificationsNone,
Icons.Outlined.PersonOutline
)
val theme = Themes[selected % Themes.size]
val bgColor by animateColorAsState(targetValue = theme.bg, animationSpec = tween(500), label = "bg")
val pillColor by animateColorAsState(targetValue = theme.bgPill, animationSpec = tween(500), label = "pill")
val bubbleColor by animateColorAsState(targetValue = theme.bubble, animationSpec = tween(500), label = "bubble")
MaterialTheme {
Box(Modifier.fillMaxSize()) {
PillsBackground(modifier = Modifier.fillMaxSize(), bgColor = bgColor, pillColor = pillColor)
Scaffold(
containerColor = Color.Transparent,
bottomBar = {
ConcaveNotchBottomBar(
items = icons,
selectedIndex = selected,
onItemSelected = { selected = it },
barHeight = 116.dp,
cornerRadius = 20.dp,
bubbleSize = 46.dp,
notchPadding = 12.dp,
barTopFraction = 0.45f,
itemBoxWidth = 32.dp,
edgeTightenDp = 16.dp,
bubbleColor = bubbleColor
)
}
) { inner ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(inner),
contentAlignment = Alignment.Center
) {
Text("Content", color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center)
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment