Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created May 7, 2026 14:00
Show Gist options
  • Select an option

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

Select an option

Save Kyriakos-Georgiopoulos/c75bd1951283bbbdd5fb7df352f705fd to your computer and use it in GitHub Desktop.
/*
* Copyright 2026 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.
*/
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddBox
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.Eco
import androidx.compose.material.icons.outlined.Email
import androidx.compose.material.icons.outlined.Lightbulb
import androidx.compose.material.icons.outlined.LocationCity
import androidx.compose.material.icons.outlined.Newspaper
import androidx.compose.material.icons.outlined.SentimentSatisfied
import androidx.compose.material.icons.outlined.SmartToy
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameMillis
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.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.roundToInt
import kotlin.math.sin
/**
* AGSL runtime shader used by [BubbleBottomBar] to create a moving glass lens.
*
* The shader samples the composable behind the selected bubble, bends the UV coordinates through a
* spherical normal approximation, and adds a thin-film interference highlight. The deformation
* uniform is driven from the horizontal velocity of the selected tab, so fast tab changes stretch
* the lens slightly while slow changes stay dense and controlled.
*
* The shader is only installed on Android 13 and newer. Older devices keep the same layout and use
* a simpler translucent oval fallback.
*/
const val KINEMATIC_LENS_SHADER = """
uniform shader composable;
uniform float2 touchCenter;
uniform float radius;
uniform float progress;
uniform float2 deformation;
uniform float popProgress;
uniform float sysTime;
float hash(float2 p) {
return fract(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453);
}
float smoothNoise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x), u.y);
}
half4 main(float2 fragCoord) {
float THICKNESS_BASE = 250.0;
float THICKNESS_GRAVITY = 150.0;
float THICKNESS_SWIRL = 120.0;
float THICKNESS_DETAIL = 50.0;
float COLOR_INTENSITY = 3.5;
float EDGE_FADE_END = 0.15;
float ENV_REFLECTION_STRENGTH = 0.7;
float ENV_BLUR_RADIUS = 60.0;
half4 rawBackground = composable.eval(fragCoord);
if (popProgress >= 1.0) return rawBackground;
float2 rawUv = fragCoord - touchCenter;
float speed = length(deformation);
float2 moveDir = speed > 0.001 ? deformation / speed : float2(0.0, 1.0);
float parallelDist = dot(rawUv, moveDir);
float2 perpVector = rawUv - moveDir * parallelDist;
float stretch = 1.0 + speed;
float squash = 1.0 / sqrt(stretch);
float2 uv = (moveDir * (parallelDist / stretch)) + (perpVector / squash);
float dist = length(uv);
float activeRadius = radius * (1.0 + popProgress * 1.5);
if (dist >= activeRadius) {
return rawBackground;
}
float2 nUv = uv / activeRadius;
float distSq = dot(nUv, nUv);
float z = sqrt(max(0.0, 1.0 - distSq));
float3 normal = normalize(float3(nUv, z));
float3 viewDir = float3(0.0, 0.0, 1.0);
float NdotV = max(0.0, dot(normal, viewDir));
float magnification = 0.45;
float lensDeform = (1.0 - z) * magnification * (1.0 - popProgress);
float2 refUvR = fragCoord - (nUv * activeRadius * (lensDeform * 0.88));
float2 refUvG = fragCoord - (nUv * activeRadius * (lensDeform * 1.00));
float2 refUvB = fragCoord - (nUv * activeRadius * (lensDeform * 1.12));
half3 bgColor = half3(
composable.eval(refUvR).r,
composable.eval(refUvG).g,
composable.eval(refUvB).b
);
float3 reflectionDir = reflect(-viewDir, normal);
float3 lightDir1 = normalize(float3(0.6, 0.7, 0.8));
float3 lightDir2 = normalize(float3(-0.5, -0.4, 0.6));
float lightAlign1 = max(0.0, dot(reflectionDir, lightDir1));
float lightAlign2 = max(0.0, dot(reflectionDir, lightDir2));
float n_film = 1.33;
float n_air = 1.0;
float R0 = pow((n_film - n_air) / (n_film + n_air), 2.0);
float fresnel = R0 + (1.0 - R0) * pow(1.0 - NdotV, 5.0);
float sinThetaI = sqrt(max(0.0, 1.0 - NdotV * NdotV));
float sinThetaT = sinThetaI / n_film;
float cosThetaT = sqrt(max(0.0, 1.0 - sinThetaT * sinThetaT));
float swirl = smoothNoise(nUv * 3.0 + sysTime * 0.12);
float thicknessNoise = smoothNoise(nUv * 5.0 - sysTime * 0.08);
float baseThickness = THICKNESS_BASE + nUv.y * THICKNESS_GRAVITY;
float thickness = baseThickness + swirl * THICKNESS_SWIRL + thicknessNoise * THICKNESS_DETAIL;
thickness = clamp(thickness, 80.0, 900.0);
float opd = 2.0 * n_film * thickness * cosThetaT;
float lambda_R = 650.0;
float lambda_G = 532.0;
float lambda_B = 450.0;
float TWO_PI = 6.2831853;
float oscR = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_R);
float oscG = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_G);
float oscB = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_B);
half3 interferenceColor = half3(oscR, oscG, oscB);
float interferenceStrength = smoothstep(0.0, EDGE_FADE_END, NdotV);
half3 filmReflection = interferenceColor * fresnel * COLOR_INTENSITY;
half3 whiteReflection = half3(fresnel);
half3 thinFilmColor = mix(whiteReflection, filmReflection, interferenceStrength);
float spec1 = pow(lightAlign1, 250.0) * 2.5;
float spec2 = pow(lightAlign2, 60.0) * 0.5;
half3 highlights = half3(spec1 + spec2);
float2 reflectOffset = normal.xy * ENV_BLUR_RADIUS;
float2 envCenter = fragCoord + reflectOffset;
float blurStep = ENV_BLUR_RADIUS * 0.4;
half3 envSample = composable.eval(envCenter).rgb * 0.4
+ composable.eval(envCenter + float2(blurStep, 0.0)).rgb * 0.15
+ composable.eval(envCenter - float2(blurStep, 0.0)).rgb * 0.15
+ composable.eval(envCenter + float2(0.0, blurStep)).rgb * 0.15
+ composable.eval(envCenter - float2(0.0, blurStep)).rgb * 0.15;
half3 envReflection = envSample * fresnel * ENV_REFLECTION_STRENGTH;
float rimShadow = smoothstep(0.90, 1.0, sqrt(distSq));
bgColor *= (1.0 - rimShadow * 0.35);
half3 finalColor = bgColor * (1.0 - half3(fresnel)) + thinFilmColor + envReflection + highlights;
return half4(finalColor, rawBackground.a);
}
"""
/**
* Visual phase for one tab in the liquid bottom bar.
*
* The selected item does not simply jump from inactive to active. It moves through a small
* physical story:
*
* - [UNSELECTED] keeps the tab merged into the white surface.
* - [SQUASHING] briefly compresses the droplet before launch, like stored elastic energy.
* - [RISING] lets the bubble detach upward while the base lump follows.
*
* [SPAWNING] is kept as a useful intermediate phase for experiments with a fuller state machine.
*/
enum class DropletState { UNSELECTED, SPAWNING, SQUASHING, RISING }
/**
* Minimal data needed by the blue liquid navigation.
*
* Keep this small and stable: the component animates every tab independently, so passing only the
* title and vector keeps the public API clear and avoids binding this demo to a routing layer.
*/
data class BottomNavItem(val title: String, val icon: ImageVector)
/**
* Animation state holder for a single tab.
*
* This intentionally stores [State] objects instead of sampled `Float` values. The parent can pass
* the motion object down without eagerly reading all animated values in composition. Children and
* canvas drawing then read the exact value they need in draw/layer phases, which keeps recomposition
* pressure low while animations are running.
*/
@Stable
private class TabMotion(
val lumpProgress: State<Float>,
val bubbleYProgress: State<Float>,
val baseScale: State<Float>,
val presenceProgress: State<Float>,
val squashProgress: State<Float>
)
/**
* Fixed arc angles for the white surface bulge in [GooeyBottomNavigation].
*
* The arc spans the lower half of an implied circle. Its touch points sit below the bar baseline,
* which gives the Bezier shoulders enough slope to look like a soft liquid connection instead of a
* hard semicircular cutout.
*/
private const val GooeyLeftAngle = 210f
private const val GooeyRightAngle = 330f
private const val GooeySweep = 120f
private val GooeyLeftRadians = Math.toRadians(GooeyLeftAngle.toDouble()).toFloat()
private val GooeyRightRadians = Math.toRadians(GooeyRightAngle.toDouble()).toFloat()
private val GooeyLeftCos = cos(GooeyLeftRadians)
private val GooeyLeftSin = sin(GooeyLeftRadians)
private val GooeyRightCos = cos(GooeyRightRadians)
private val GooeyRightSin = sin(GooeyRightRadians)
private val GooeyLeftTangent = Offset(-GooeyLeftSin, GooeyLeftCos)
private val GooeyRightTangent = Offset(-GooeyRightSin, GooeyRightCos)
/**
* Scale used to render inactive blue-bar icons from one fixed measured size.
*
* Keeping the measured size stable and animating scale in [androidx.compose.ui.graphics.graphicsLayer]
* avoids relayout during icon-size animation.
*/
private const val InactiveIconScale = 28f / 34f
/**
* Sample content for [GooeyBottomNavigation].
*
* These are intentionally top-level values so previews and demo screens do not allocate icon models
* on every recomposition.
*/
val blueNavItems = listOf(
BottomNavItem("News", Icons.Outlined.Newspaper),
BottomNavItem("Messages", Icons.Outlined.Email),
BottomNavItem("Events", Icons.Outlined.CalendarMonth),
BottomNavItem("Profile", Icons.Outlined.SentimentSatisfied)
)
/**
* Icons used by [AnchoredGreenBottomBar].
*
* This bar intentionally accepts only icons because its visual focus is the moving notch/circle
* relationship, not labels.
*/
val greenNavIcons = listOf(
Icons.Outlined.Newspaper,
Icons.Outlined.AddBox,
Icons.Outlined.LocationCity
)
/**
* Icons used by [BubbleBottomBar].
*
* Each index has a small personality animation in the bar: spin, shake, squash, or jump. The list
* size is therefore part of the demo choreography.
*/
val glassNavIcons = listOf(
Icons.Outlined.AutoAwesome,
Icons.Outlined.SmartToy,
Icons.Outlined.Eco,
Icons.Outlined.Lightbulb
)
/**
* Preview/demo entry point for the three navigation experiments in this file.
*
* The demo cycles through a liquid blue bar, an anchored green notch bar, and a shader-backed glass
* bar. It is kept in the same file so readers can see each component in a working screen without
* hunting through app navigation code.
*/
@Preview(showBackground = true)
@Composable
fun GooeyNavDemo() {
var currentRoute by remember { mutableStateOf("BlueLiquid") }
CircularRevealRouter(currentRoute = currentRoute) { route ->
when (route) {
"BlueLiquid" -> {
BlueLiquidScreen(
onTapScreen = { currentRoute = "GreenNews" }
)
}
"GreenNews" -> {
GreenNewsScreen(
onTapScreen = { currentRoute = "GlassShader" }
)
}
"GlassShader" -> {
BubbleScreen(
onTapScreen = { currentRoute = "BlueLiquid" }
)
}
}
}
}
/**
* Small route switcher that reveals the next demo screen through an expanding circle.
*
* Both old and new content are composed during the transition. The old route remains underneath
* while the target route is clipped by [CircularRevealShape], then the old content is removed when
* progress reaches `1f`.
*/
@Composable
fun CircularRevealRouter(
currentRoute: String,
content: @Composable (String) -> Unit
) {
var oldRoute by remember { mutableStateOf(currentRoute) }
var targetRoute by remember { mutableStateOf(currentRoute) }
val progress = remember { Animatable(1f) }
LaunchedEffect(currentRoute) {
if (currentRoute != targetRoute) {
oldRoute = targetRoute
targetRoute = currentRoute
progress.snapTo(0f)
progress.animateTo(1f, tween(800, easing = FastOutSlowInEasing))
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (progress.value < 1f) {
Box(modifier = Modifier.fillMaxSize()) { content(oldRoute) }
}
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
clip = true
shape = CircularRevealShape(progress.value)
}
) {
content(targetRoute)
}
}
}
/**
* Shape whose outline is an expanding circle centered in the available bounds.
*
* It is useful for full-screen reveal transitions because the final radius is computed from the
* diagonal distance to the farthest corner, guaranteeing that the target content fully covers the
* previous content at progress `1f`.
*
* The internal [Path] is reused across calls to [createOutline] to avoid per-frame allocation
* pressure during the reveal animation.
*/
class CircularRevealShape(private val progress: Float) : Shape {
private val reusablePath = Path()
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val center = Offset(size.width / 2f, size.height / 2f)
val maxRadius = hypot(center.x.toDouble(), center.y.toDouble()).toFloat()
val currentRadius = maxRadius * progress
reusablePath.reset()
reusablePath.addOval(
Rect(
center.x - currentRadius,
center.y - currentRadius,
center.x + currentRadius,
center.y + currentRadius
)
)
return Outline.Generic(reusablePath)
}
}
/**
* Demo screen for [GooeyBottomNavigation].
*
* The screen background and droplet share the same blue color. That matching color is the trick
* that makes the active bubble look like it has detached from the page and punched through the white
* navigation surface.
*/
@Composable
fun BlueLiquidScreen(onTapScreen: () -> Unit) {
val darkBlue = Color(0xFF1E28A0)
var selectedIndex by remember { mutableIntStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.background(darkBlue)
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() }, indication = null,
onClick = onTapScreen
),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${blueNavItems[selectedIndex].title} Screen",
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Tap anywhere to go to Green Screen",
color = Color.White.copy(alpha = 0.7f),
fontSize = 16.sp
)
}
}
GooeyBottomNavigation(
items = blueNavItems,
selectedIndex = selectedIndex,
backgroundColor = darkBlue,
onItemSelected = { index -> selectedIndex = index }
)
}
}
/**
* A liquid bottom navigation bar built from two coordinated layers:
*
* 1. A [Canvas] draws the white base shape and the colored droplet. The base uses an arc plus two
* quadratic shoulders to fake a soft metaball connection between the surface and the active tab.
* 2. A row of icons/text sits above the canvas. Each item owns its visual layer transforms so the
* parent row does not need to recompute layout every animation frame.
*
* The motion is intentionally simple but expressive. When a new tab is selected, previous tabs
* collapse to [DropletState.UNSELECTED], then the target tab briefly enters [DropletState.SQUASHING]
* before [DropletState.RISING]. The squash gives the animation a readable anticipation phase: the
* circle widens and flattens, then springs upward.
*
* Performance notes for Compose readers:
*
* - Animation specs are remembered, so springs/tweens are not allocated on every recomposition.
* - Expensive trigonometry for the fixed gooey angles is precomputed at file level.
* - The selected callback is wrapped with `rememberUpdatedState`, which keeps click lambdas stable.
* - Animated icon movement, scale, and alpha are applied in `graphicsLayer`; this is cheaper than
* changing measured size/offset in composition for every frame.
* - Path objects are remembered and reset each frame to avoid per-frame allocation pressure.
*
* The algorithm is O(n) per frame, where n is the number of tabs. That is appropriate for bottom
* navigation because n is normally tiny. Avoid using this exact shape builder for hundreds of items;
* it is designed for clarity and visual quality in a small navigation surface.
*
* @param items tabs to render. Empty input renders nothing.
* @param selectedIndex requested active tab. Out-of-range values are clamped defensively.
* @param backgroundColor color of the screen behind the white surface; also used for the droplet.
* @param onItemSelected invoked when a tab is tapped.
*/
@Composable
fun GooeyBottomNavigation(
items: List<BottomNavItem>,
selectedIndex: Int,
backgroundColor: Color,
onItemSelected: (Int) -> Unit
) {
if (items.isEmpty()) return
val navBarHeight = 150.dp
val baseYOffset = 70.dp
val whiteColor = Color.White
val maxOuterRadius = 41.dp
val activeCircleSize = 62.dp
val activeIconSize = 34.dp
val safeSelectedIndex = selectedIndex.coerceIn(items.indices)
val onItemSelectedState by rememberUpdatedState(onItemSelected)
val risingSpring = remember { spring<Float>(dampingRatio = 0.7f, stiffness = 350f) }
val settleSpring = remember { spring<Float>(dampingRatio = 0.9f, stiffness = 600f) }
val baseSpring = remember { spring<Float>(dampingRatio = 0.65f, stiffness = 350f) }
val squashSpring = remember { spring<Float>(dampingRatio = 0.65f, stiffness = 400f) }
val baseExitTween =
remember { tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing) }
val presenceTween = remember { tween<Float>(durationMillis = 200) }
val tabStates = remember(items.size) {
mutableStateMapOf<Int, DropletState>().apply {
put(safeSelectedIndex, DropletState.RISING)
}
}
var isInitialLaunch by remember { mutableStateOf(true) }
LaunchedEffect(items.size, safeSelectedIndex) {
if (isInitialLaunch) {
isInitialLaunch = false; return@LaunchedEffect
}
val staleKeys = tabStates.keys.filter { it !in items.indices }
staleKeys.forEach(tabStates::remove)
tabStates.keys.toList().forEach { index ->
if (index != safeSelectedIndex) tabStates[index] = DropletState.UNSELECTED
}
delay(100)
tabStates[safeSelectedIndex] = DropletState.SQUASHING
delay(120)
tabStates[safeSelectedIndex] = DropletState.RISING
}
val evaluatedTabs = items.indices.map { index ->
val currentState = tabStates[index] ?: DropletState.UNSELECTED
val transition = updateTransition(targetState = currentState, label = "TabMotion_$index")
val bubbleYProgress = transition.animateFloat(
transitionSpec = {
if (targetState == DropletState.RISING) risingSpring else settleSpring
}, label = "BubbleY"
) { state -> if (state == DropletState.RISING) 1f else 0f }
val lumpProgress = transition.animateFloat(
transitionSpec = {
if (targetState == DropletState.RISING) risingSpring else settleSpring
}, label = "LumpY"
) { state -> if (state == DropletState.RISING) 1f else 0f }
val baseScale = transition.animateFloat(
transitionSpec = {
if (targetState == DropletState.UNSELECTED) baseExitTween else baseSpring
}, label = "BaseScale"
) { state ->
when (state) {
DropletState.UNSELECTED -> 0f; DropletState.SPAWNING -> 0.8f; DropletState.SQUASHING -> 0.7f; DropletState.RISING -> 1f
}
}
val squashProgress = transition.animateFloat(
transitionSpec = { squashSpring },
label = "Squash"
) { state -> if (state == DropletState.SQUASHING) 1f else 0f }
val presenceProgress = transition.animateFloat(
transitionSpec = { presenceTween },
label = "Presence"
) { state -> if (state == DropletState.UNSELECTED) 0f else 1f }
TabMotion(lumpProgress, bubbleYProgress, baseScale, presenceProgress, squashProgress)
}
// -- Reusable Path objects to avoid per-frame allocations in Canvas --
val whitePath = remember { Path() }
val dropletPaths = remember(items.size) { Array(items.size) { Path() } }
Box(
modifier = Modifier
.fillMaxWidth()
.height(navBarHeight)
.background(backgroundColor)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val baseY = baseYOffset.toPx()
val tabWidth = size.width / items.size
val maxOuterRadiusPx = maxOuterRadius.toPx()
val activeCircleSizePx = activeCircleSize.toPx()
whitePath.reset()
whitePath.moveTo(0f, baseY)
items.indices.forEach { i ->
val metrics = evaluatedTabs[i]
val lumpProgress = metrics.lumpProgress.value.coerceAtLeast(0f)
if (lumpProgress > 0.001f) {
val cx = (i * tabWidth) + (tabWidth / 2f)
val outerR = maxOuterRadiusPx * lumpProgress
val valleySpan = outerR * 1.35f
val tangentPull = outerR * 0.65f
val leftTouch = Offset(
x = cx + outerR * GooeyLeftCos,
y = baseY + outerR * GooeyLeftSin
)
val rightTouch = Offset(
x = cx + outerR * GooeyRightCos,
y = baseY + outerR * GooeyRightSin
)
whitePath.lineTo(leftTouch.x - valleySpan, baseY)
whitePath.quadraticTo(
leftTouch.x - GooeyLeftTangent.x * tangentPull,
leftTouch.y - GooeyLeftTangent.y * tangentPull,
leftTouch.x,
leftTouch.y
)
whitePath.arcTo(
Rect(cx - outerR, baseY - outerR, cx + outerR, baseY + outerR),
GooeyLeftAngle,
GooeySweep,
false
)
whitePath.quadraticTo(
rightTouch.x + GooeyRightTangent.x * tangentPull,
rightTouch.y + GooeyRightTangent.y * tangentPull,
rightTouch.x + valleySpan,
baseY
)
} else {
whitePath.lineTo((i + 1) * tabWidth, baseY)
}
}
whitePath.lineTo(size.width, baseY)
whitePath.lineTo(size.width, size.height)
whitePath.lineTo(0f, size.height)
whitePath.close()
drawPath(path = whitePath, color = whiteColor)
items.indices.forEach { i ->
val metrics = evaluatedTabs[i]
val presenceProgress = metrics.presenceProgress.value
if (presenceProgress > 0.001f) {
val cx = (i * tabWidth) + (tabWidth / 2f)
val whiteAreaCenterPx = baseY + (size.height - baseY) / 2f
val bubbleYProgress = metrics.bubbleYProgress.value
val currentCenterYPx =
whiteAreaCenterPx - ((whiteAreaCenterPx - baseY) * bubbleYProgress)
val radius = (activeCircleSizePx / 2f) * metrics.baseScale.value
val lumpProgress = metrics.lumpProgress.value.coerceAtLeast(0f)
val tailLength =
(bubbleYProgress - lumpProgress).coerceAtLeast(0f) * (activeCircleSizePx * 1.5f)
val dropletPath = dropletPaths[i]
dropletPath.reset()
val rect = Rect(-radius, -radius, radius, radius)
if (tailLength > 0.1f) {
dropletPath.arcTo(rect, 180f, 180f, forceMoveTo = true)
val bottomY = radius + tailLength
dropletPath.cubicTo(
radius,
radius * 0.5f + tailLength * 0.2f,
radius * 0.2f,
bottomY,
0f,
bottomY
)
dropletPath.cubicTo(
-radius * 0.2f,
bottomY,
-radius,
radius * 0.5f + tailLength * 0.2f,
-radius,
0f
)
} else {
dropletPath.addOval(rect)
}
dropletPath.close()
val squashProgress = metrics.squashProgress.value
val squashScaleX = 1f + (squashProgress * 0.25f)
val squashScaleY = 1f - (squashProgress * 0.15f)
translate(left = cx, top = currentCenterYPx) {
scale(scaleX = squashScaleX, scaleY = squashScaleY, pivot = Offset.Zero) {
drawPath(
dropletPath,
color = backgroundColor,
alpha = presenceProgress
)
}
}
}
}
}
Row(modifier = Modifier.fillMaxSize()) {
items.forEachIndexed { index, item ->
GooeyNavigationItem(
item = item,
motion = evaluatedTabs[index],
navBarHeight = navBarHeight,
baseYOffset = baseYOffset,
activeCircleSize = activeCircleSize,
activeIconSize = activeIconSize,
backgroundColor = backgroundColor,
activeIconColor = whiteColor,
onClick = { onItemSelectedState(index) },
modifier = Modifier
.weight(1f)
.fillMaxHeight()
)
}
}
}
}
/**
* One tab in [GooeyBottomNavigation].
*
* The item draws two icons on top of each other: one in the active color and one in the background
* color. Their alpha values are cross-faded in a layer instead of lerping tint in composition. This
* keeps the animation in the render pipeline and avoids unnecessary recomposition while preserving
* the same visual result.
*
* The icon size is also represented as a layer scale from [InactiveIconScale] to `1f`. Scaling a
* fixed-size icon avoids repeated measurement during the spring animation. Text follows the same
* principle: its vertical motion and opacity are layer properties.
*/
@Composable
private fun GooeyNavigationItem(
item: BottomNavItem,
motion: TabMotion,
navBarHeight: Dp,
baseYOffset: Dp,
activeCircleSize: Dp,
activeIconSize: Dp,
backgroundColor: Color,
activeIconColor: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
val interactionSource = remember { MutableInteractionSource() }
// Pre-compute all dp-to-px conversions once
val navBarHeightPx = remember(navBarHeight, density) { with(density) { navBarHeight.toPx() } }
val baseYOffsetPx = remember(baseYOffset, density) { with(density) { baseYOffset.toPx() } }
val activeCircleSizePx =
remember(activeCircleSize, density) { with(density) { activeCircleSize.toPx() } }
val activeIconSizePx =
remember(activeIconSize, density) { with(density) { activeIconSize.toPx() } }
val paddingBelowCirclePx = remember(density) { with(density) { 12.dp.toPx() } }
val activeTextYPx = baseYOffsetPx + (activeCircleSizePx / 2f) + paddingBelowCirclePx
val restingTextYPx = activeTextYPx + paddingBelowCirclePx
val whiteAreaCenterPx = baseYOffsetPx + ((navBarHeightPx - baseYOffsetPx) / 2f)
val iconTravelPx = whiteAreaCenterPx - baseYOffsetPx
Box(
modifier = modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick
),
contentAlignment = Alignment.TopCenter
) {
Icon(
imageVector = item.icon,
contentDescription = item.title,
tint = activeIconColor,
modifier = Modifier
.size(activeIconSize)
.graphicsLayer {
val bubbleYProgress = motion.bubbleYProgress.value
val iconScale = InactiveIconScale + ((1f - InactiveIconScale) * bubbleYProgress)
val currentCenterY = whiteAreaCenterPx - (iconTravelPx * bubbleYProgress)
translationY = currentCenterY - (activeIconSizePx / 2f)
scaleX = iconScale
scaleY = iconScale
alpha = motion.presenceProgress.value
}
)
Icon(
imageVector = item.icon,
contentDescription = null,
tint = backgroundColor,
modifier = Modifier
.size(activeIconSize)
.graphicsLayer {
val bubbleYProgress = motion.bubbleYProgress.value
val iconScale = InactiveIconScale + ((1f - InactiveIconScale) * bubbleYProgress)
val currentCenterY = whiteAreaCenterPx - (iconTravelPx * bubbleYProgress)
translationY = currentCenterY - (activeIconSizePx / 2f)
scaleX = iconScale
scaleY = iconScale
alpha = 1f - motion.presenceProgress.value
}
)
Text(
text = item.title,
color = backgroundColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.graphicsLayer {
val bubbleYProgress = motion.bubbleYProgress.value
translationY = restingTextYPx - ((restingTextYPx - activeTextYPx) * bubbleYProgress)
alpha = motion.presenceProgress.value
}
)
}
}
/**
* Demo screen for [AnchoredGreenBottomBar].
*
* This screen uses a contrasting green background and white navigation surface to make the anchored
* circle/notch relationship easy to inspect.
*/
@Composable
fun GreenNewsScreen(onTapScreen: () -> Unit) {
val greenColor = Color(0xFF4CAF50)
val whiteBarColor = Color(0xFFF3F4F6)
val darkIconColor = Color(0xFF455A64)
var selectedIndex by remember { mutableIntStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.background(greenColor)
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() }, indication = null,
onClick = onTapScreen
),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "News Dashboard",
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Tap anywhere here to go to Glass Shader",
color = Color.White.copy(alpha = 0.8f),
fontSize = 16.sp
)
}
}
AnchoredGreenBottomBar(
items = greenNavIcons,
selectedIndex = selectedIndex,
barColor = whiteBarColor,
activeColor = greenColor,
inactiveColor = darkIconColor,
onItemSelected = { selectedIndex = it }
)
}
}
/**
* A bottom bar with an anchored floating circle and a matching cutout in the bar body.
*
* This version is less liquid than [GooeyBottomNavigation]. The selected tab moves a circular anchor
* along the top edge, while the canvas path carves a cubic Bezier notch below it. The two animations
* use the same spring values so the cutout, circle, and icon feel mechanically connected.
*
* Geometry notes:
*
* - `cutoutRadius` controls the size of the dip.
* - `shoulderWidth` controls how much horizontal runway the Bezier curve gets before dipping.
* - The left/right control points are clamped near rounded corners so the notch does not break the
* bar shape when the first or last tab is selected.
*/
@Composable
fun AnchoredGreenBottomBar(
items: List<ImageVector>,
selectedIndex: Int,
barColor: Color,
activeColor: Color,
inactiveColor: Color,
onItemSelected: (Int) -> Unit
) {
val barHeight = 72.dp
val topPadding = 52.dp
val totalHeight = barHeight + topPadding
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val usableWidth = screenWidth - 32.dp
val tabWidth = usableWidth / items.size
val density = LocalDensity.current
val tabWidthPx = with(density) { tabWidth.toPx() }
val topYPx = remember(topPadding, density) { with(density) { topPadding.toPx() } }
val barHeightPx = remember(barHeight, density) { with(density) { barHeight.toPx() } }
val syncStiffness = 250f
val syncDamping = 0.75f
val syncSpring = remember { spring<Float>(dampingRatio = syncDamping, stiffness = syncStiffness) }
val animatedCutoutX by animateFloatAsState(
targetValue = (selectedIndex * tabWidthPx) + (tabWidthPx / 2f),
animationSpec = syncSpring,
label = "CutoutSlide"
)
val onItemSelectedState by rememberUpdatedState(onItemSelected)
// Reusable Path for the bar shape
val barPath = remember { Path() }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
.height(totalHeight)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val cornerRadius = 16.dp.toPx()
val circleRadius = 32.dp.toPx()
val gap = 8.dp.toPx()
val cutoutRadius = circleRadius + gap
val dipDepth = cutoutRadius
val shoulderWidth = 16.dp.toPx()
val topY = topYPx
val bottomYBar = topY + barHeightPx
val cx = animatedCutoutX
val safeLeft = cornerRadius
val safeRight = size.width - cornerRadius
val leftShoulderX = (cx - cutoutRadius - shoulderWidth).coerceAtLeast(safeLeft)
val leftCp1X = (cx - cutoutRadius).coerceAtLeast(leftShoulderX)
val leftCp2X = (cx - cutoutRadius).coerceAtLeast(leftShoulderX)
val rightCp1X = (cx + cutoutRadius).coerceAtMost(safeRight)
val rightCp2X = (cx + cutoutRadius).coerceAtMost(safeRight)
val rightShoulderX =
(cx + cutoutRadius + shoulderWidth).coerceAtMost(safeRight).coerceAtLeast(rightCp2X)
barPath.reset()
barPath.moveTo(0f, topY + cornerRadius)
barPath.arcTo(
Rect(0f, topY, 2 * cornerRadius, topY + 2 * cornerRadius), 180f, 90f, false
)
barPath.lineTo(leftShoulderX, topY)
barPath.cubicTo(leftCp1X, topY, leftCp2X, topY + dipDepth, cx, topY + dipDepth)
barPath.cubicTo(rightCp1X, topY + dipDepth, rightCp2X, topY, rightShoulderX, topY)
barPath.lineTo(size.width - cornerRadius, topY)
barPath.arcTo(
Rect(size.width - 2 * cornerRadius, topY, size.width, topY + 2 * cornerRadius),
-90f, 90f, false
)
barPath.lineTo(size.width, bottomYBar - cornerRadius)
barPath.arcTo(
Rect(
size.width - 2 * cornerRadius,
bottomYBar - 2 * cornerRadius,
size.width,
bottomYBar
),
0f, 90f, false
)
barPath.lineTo(cornerRadius, bottomYBar)
barPath.arcTo(
Rect(0f, bottomYBar - 2 * cornerRadius, 2 * cornerRadius, bottomYBar),
90f, 90f, false
)
barPath.close()
drawPath(path = barPath, color = barColor)
val circleCenterY = topY
drawCircle(
color = Color.White,
radius = circleRadius,
center = Offset(cx, circleCenterY)
)
drawCircle(
color = activeColor,
radius = circleRadius,
center = Offset(cx, circleCenterY),
style = Stroke(width = 3.dp.toPx())
)
}
Row(modifier = Modifier.fillMaxSize()) {
items.forEachIndexed { index, icon ->
val isSelected = selectedIndex == index
val animatedIconSize by animateDpAsState(
targetValue = if (isSelected) 36.dp else 26.dp,
animationSpec = spring(dampingRatio = syncDamping, stiffness = syncStiffness),
label = "IconSize"
)
val animatedIconSizePx = with(density) { animatedIconSize.toPx() }
val animatedIconColor by animateColorAsState(
targetValue = if (isSelected) activeColor else inactiveColor,
animationSpec = spring(dampingRatio = syncDamping, stiffness = syncStiffness),
label = "IconColor"
)
val targetCenterYPx = if (isSelected) topYPx else topYPx + (barHeightPx / 2f)
val currentCenterYPx by animateFloatAsState(
targetValue = targetCenterYPx,
animationSpec = spring(dampingRatio = syncDamping, stiffness = syncStiffness),
label = "IconY"
)
val effectProgress by animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = spring(dampingRatio = syncDamping, stiffness = syncStiffness),
label = "EffectProgress"
)
val iconRotation = when (index) {
0 -> (sin(effectProgress * Math.PI * 3) * 15f).toFloat(); 1 -> effectProgress * 360f; else -> 0f
}
val iconScaleX = when (index) {
2 -> 1f - (sin(effectProgress * Math.PI) * 0.15f).toFloat(); else -> 1f
}
val iconScaleY = when (index) {
2 -> 1f + (sin(effectProgress * Math.PI) * 0.4f).toFloat(); else -> 1f
}
val iconOrigin = when (index) {
2 -> androidx.compose.ui.graphics.TransformOrigin(
0.5f,
1f
); else -> androidx.compose.ui.graphics.TransformOrigin.Center
}
val stableOnClick = remember(index, onItemSelectedState) {
{ onItemSelectedState(index) }
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = stableOnClick
),
contentAlignment = Alignment.TopCenter
) {
Icon(
imageVector = icon, contentDescription = null, tint = animatedIconColor,
modifier = Modifier
.offset {
IntOffset(
0,
(currentCenterYPx - animatedIconSizePx / 2f).roundToInt()
)
}
.size(animatedIconSize)
.graphicsLayer {
rotationZ = iconRotation; scaleX = iconScaleX; scaleY =
iconScaleY; transformOrigin = iconOrigin
}
)
}
}
}
}
}
/**
* Demo screen for [BubbleBottomBar].
*
* The background is a slow rotating gradient so the glass lens has real color information to refract.
* Selecting different tabs changes the gradient palette, making the shader response easier to study.
*/
@Composable
fun BubbleScreen(onTapScreen: () -> Unit) {
val glassPillColor = Color(0x2AFFFFFF)
val unselectedIconColor = Color.White.copy(alpha = 0.5f)
var selectedIndex by remember { mutableIntStateOf(0) }
val targetColor1 = when (selectedIndex) {
0 -> Color(0xFF311B92)
1 -> Color(0xFF01579B)
2 -> Color(0xFF1B5E20)
3 -> Color(0xFFBF360C)
else -> Color.Black
}
val targetColor2 = when (selectedIndex) {
0 -> Color(0xFF4A148C)
1 -> Color(0xFF006064)
2 -> Color(0xFF004D40)
3 -> Color(0xFF880E4F)
else -> Color.Black
}
val color1 by animateColorAsState(
targetValue = targetColor1,
animationSpec = tween(1200, easing = FastOutSlowInEasing),
label = "bg1"
)
val color2 by animateColorAsState(
targetValue = targetColor2,
animationSpec = tween(1200, easing = FastOutSlowInEasing),
label = "bg2"
)
val infiniteTransition = rememberInfiniteTransition(label = "bg_rotate")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(25000, easing = LinearEasing)),
label = "rotation"
)
Box(modifier = Modifier.fillMaxSize()) {
Canvas(modifier = Modifier.fillMaxSize()) {
val center = Offset(size.width / 2f, size.height / 2f)
val radius = size.maxDimension * 0.8f
val angleRad = Math.toRadians(rotation.toDouble())
val startX = center.x + cos(angleRad).toFloat() * radius
val startY = center.y + sin(angleRad).toFloat() * radius
val endX = center.x - cos(angleRad).toFloat() * radius
val endY = center.y - sin(angleRad).toFloat() * radius
drawRect(
brush = Brush.linearGradient(
colors = listOf(color1, color2),
start = Offset(startX, startY),
end = Offset(endX, endY)
)
)
}
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onTapScreen
),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Glass Shader Magic",
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Watch the colors shift through the centered glass!",
color = Color.White.copy(alpha = 0.9f),
fontSize = 16.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Tap anywhere to return to Blue Liquid",
color = Color.White.copy(alpha = 0.6f),
fontSize = 14.sp
)
}
}
BubbleBottomBar(
items = glassNavIcons,
selectedIndex = selectedIndex,
pillColor = glassPillColor,
activeIconColor = Color.White,
inactiveIconColor = unselectedIconColor,
onItemSelected = { selectedIndex = it }
)
}
}
}
/**
* A pill-shaped glass navigation bar with a shader-driven moving bubble.
*
* The selected item is represented by a circular cutout in the pill plus a lens centered over that
* cutout. On Android 13+, [KINEMATIC_LENS_SHADER] samples the content behind the bubble and applies
* refraction, chromatic offset, Fresnel highlights, and a subtle moving film pattern.
*
* Physics notes:
*
* - `animatedBubbleX` is the spring-simulated center of the active tab.
* - `speedX` is derived from the previous center and becomes a deformation signal.
* - `deformX` is clamped so high velocity reads as mass and inertia, not uncontrolled wobble.
* - `stretchX` and `stretchY` conserve a rough visual area by widening one axis while compressing
* the other.
*
* Performance notes:
*
* - The [RuntimeShader] object is remembered and only its uniforms change per frame.
* - The clock uses [withFrameMillis] so the shader animation follows the display frame cadence.
* - Path objects are remembered and reset each frame to avoid GC pressure.
* - On older Android versions, the fallback avoids shader work entirely and keeps the interaction
* usable.
*/
@Composable
fun BubbleBottomBar(
items: List<ImageVector>,
selectedIndex: Int,
pillColor: Color,
activeIconColor: Color,
inactiveIconColor: Color,
onItemSelected: (Int) -> Unit
) {
val barHeight = 56.dp
val paddingHorizontal = 24.dp
val paddingBottom = 32.dp
val topPadding = 48.dp
val totalHeight = barHeight + topPadding
val bubbleRadiusDp = 40.dp
val cutoutGapDp = 10.dp
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val usableWidth = screenWidth - (paddingHorizontal * 2)
val tabWidth = usableWidth / items.size
val density = LocalDensity.current
val tabWidthPx = with(density) { tabWidth.toPx() }
val barHeightPx = remember(barHeight, density) { with(density) { barHeight.toPx() } }
val topYPx = remember(topPadding, density) { with(density) { topPadding.toPx() } }
val centerYPx = topYPx + (barHeightPx / 2f)
val syncStiffness = 180f
val syncDamping = 0.60f
val animatedBubbleX by animateFloatAsState(
targetValue = (selectedIndex * tabWidthPx) + (tabWidthPx / 2f),
animationSpec = spring(dampingRatio = syncDamping, stiffness = syncStiffness),
label = "BubbleSlide"
)
var previousCx by remember { mutableFloatStateOf(animatedBubbleX) }
var speedX by remember { mutableFloatStateOf(0f) }
var sysTime by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
while (true) {
withFrameMillis { time -> sysTime = time / 1000f }
}
}
SideEffect {
speedX = animatedBubbleX - previousCx
previousCx = animatedBubbleX
}
val shader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) RuntimeShader(
KINEMATIC_LENS_SHADER
) else null
}
val deformX = (speedX * 0.005f).coerceIn(-0.3f, 0.3f)
val speedMagnitude = kotlin.math.abs(deformX)
val stretchX = 1f + speedMagnitude
val stretchY = 1f / kotlin.math.sqrt(stretchX.toDouble()).toFloat()
val onItemSelectedState by rememberUpdatedState(onItemSelected)
// Reusable Path objects for the pill and cutout
val pillPath = remember { Path() }
val cutoutPath = remember { Path() }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = paddingHorizontal)
.padding(bottom = paddingBottom)
.height(totalHeight)
.graphicsLayer {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) {
val circleRadiusPx = with(density) { bubbleRadiusDp.toPx() }
shader.setFloatUniform("touchCenter", animatedBubbleX, centerYPx)
shader.setFloatUniform("radius", circleRadiusPx)
shader.setFloatUniform("progress", 1f)
shader.setFloatUniform("deformation", deformX, 0f)
shader.setFloatUniform("popProgress", 0f)
shader.setFloatUniform("sysTime", sysTime)
renderEffect = android.graphics.RenderEffect.createRuntimeShaderEffect(
shader, "composable"
).asComposeRenderEffect()
}
clip = false
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val circleRadius = bubbleRadiusDp.toPx()
val gap = cutoutGapDp.toPx()
val cutoutBaseRadius = circleRadius + gap
val cx = animatedBubbleX
val rx = cutoutBaseRadius * stretchX
val ry = cutoutBaseRadius * stretchY
pillPath.reset()
pillPath.addRoundRect(
androidx.compose.ui.geometry.RoundRect(
rect = Rect(0f, topYPx, size.width, topYPx + barHeightPx),
cornerRadius = CornerRadius(barHeightPx / 2f)
)
)
cutoutPath.reset()
cutoutPath.addOval(
Rect(
left = cx - rx,
top = centerYPx - ry,
right = cx + rx,
bottom = centerYPx + ry
)
)
clipPath(cutoutPath, clipOp = ClipOp.Difference) {
drawPath(path = pillPath, color = pillColor)
drawPath(
path = pillPath,
color = Color(0x4DFFFFFF),
style = Stroke(width = 1.dp.toPx())
)
}
clipPath(pillPath) {
drawPath(
path = cutoutPath,
color = Color(0x4DFFFFFF),
style = Stroke(width = 1.dp.toPx())
)
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
drawOval(
color = Color(0x33FFFFFF),
topLeft = Offset(
cx - (circleRadius * stretchX),
centerYPx - (circleRadius * stretchY)
),
size = Size(circleRadius * stretchX * 2, circleRadius * stretchY * 2)
)
}
}
Row(modifier = Modifier.fillMaxSize()) {
items.forEachIndexed { index, icon ->
val isSelected = selectedIndex == index
val animatedIconSize by animateDpAsState(
targetValue = if (isSelected) 36.dp else 24.dp,
animationSpec = spring(dampingRatio = syncDamping, stiffness = syncStiffness),
label = "IconSize"
)
val animatedIconSizePx = with(density) { animatedIconSize.toPx() }
val animatedIconColor by animateColorAsState(
targetValue = if (isSelected) activeIconColor else inactiveIconColor,
animationSpec = spring(dampingRatio = syncDamping, stiffness = syncStiffness),
label = "IconColor"
)
val effectProgress by animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = spring(dampingRatio = syncDamping, stiffness = syncStiffness),
label = "EffectProgress"
)
val iconRotation = when (index) {
0 -> effectProgress * 360f
1 -> (sin(effectProgress * Math.PI * 5) * 20f).toFloat()
2 -> (sin(effectProgress * Math.PI * 2) * 30f).toFloat()
else -> 0f
}
val iconScaleX = when (index) {
0 -> 1f + (sin(effectProgress * Math.PI) * 0.3f).toFloat()
3 -> 1f - (sin(effectProgress * Math.PI) * 0.3f).toFloat()
else -> 1f
}
val iconScaleY = when (index) {
0 -> 1f + (sin(effectProgress * Math.PI) * 0.3f).toFloat()
3 -> 1f + (sin(effectProgress * Math.PI) * 0.5f).toFloat()
else -> 1f
}
val iconOrigin = when (index) {
2 -> androidx.compose.ui.graphics.TransformOrigin(0.5f, 1f)
3 -> androidx.compose.ui.graphics.TransformOrigin(0.5f, 1f)
else -> androidx.compose.ui.graphics.TransformOrigin.Center
}
val iconJumpY = when (index) {
1 -> -sin(effectProgress * Math.PI).toFloat() * 12f
3 -> -sin(effectProgress * Math.PI).toFloat() * 30f
else -> 0f
}
val finalYOffset = centerYPx - (animatedIconSizePx / 2f) + iconJumpY
val stableOnClick = remember(index, onItemSelectedState) {
{ onItemSelectedState(index) }
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = stableOnClick
),
contentAlignment = Alignment.TopCenter
) {
Icon(
imageVector = icon, contentDescription = null, tint = animatedIconColor,
modifier = Modifier
.offset { IntOffset(0, finalYOffset.roundToInt()) }
.size(animatedIconSize)
.graphicsLayer {
rotationZ = iconRotation
scaleX = iconScaleX
scaleY = iconScaleY
transformOrigin = iconOrigin
}
)
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment