Skip to content

Instantly share code, notes, and snippets.

@iprashantpanwar
Created July 22, 2025 17:29
Show Gist options
  • Select an option

  • Save iprashantpanwar/d1386f3c29aabc6f69c4b15403ede9a7 to your computer and use it in GitHub Desktop.

Select an option

Save iprashantpanwar/d1386f3c29aabc6f69c4b15403ede9a7 to your computer and use it in GitHub Desktop.
Morphing Blobs with Jetpack Compose, Inspiration: https://dribbble.com/shots/17566578-Fitness-Mobile-App-Everyday-Workout
/**
* Encapsulates the visual styling of a morphing blob.
*/
data class BlobStyle(
val effect: BlobEffect = BlobEffect(),
val shader: BlobShader = BlobShader.Radial(),
val shape: BlobShape = BlobShape.Fill
) {
companion object {
fun default(): BlobStyle = BlobStyle()
fun glow(): BlobStyle = BlobStyle(
effect = BlobEffect(alpha = 0.6f, blurRadius = 50f),
shader = BlobShader.Radial(
colors = listOf(Color(0xFFF44336), Color.Transparent)
)
)
fun softStroke(): BlobStyle = BlobStyle(
shape = BlobShape.Stroke(strokeWidth = 2f),
effect = BlobEffect(alpha = 0.8f, blurRadius = 12f),
shader = BlobShader.Linear(
colors = listOf(Color.Cyan, Color.Magenta),
to = Offset(0f, 400f)
)
)
}
}
/**
* Defines how the blob is visually shaped—either filled or stroked.
*/
sealed class BlobShape {
data object Fill : BlobShape()
data class Stroke(
val strokeWidth: Float = 1.5f
) : BlobShape()
}
/**
* Defines visual effects like transparency, blur, and optional blend mode.
*/
data class BlobEffect(
val alpha: Float = 1f,
val blurRadius: Float = 0f,
)
/**
* Encapsulates shader behavior for blob fill.
*/
sealed class BlobShader {
abstract fun toAndroidShader(context: BlobShaderContext): Shader
/**
* Radial gradient shader.
*/
data class Radial(
val colors: List<Color> = listOf(Color(0xFFE57373), Color.Transparent),
val stops: List<Float>? = null,
val tileMode: Shader.TileMode = Shader.TileMode.CLAMP
) : BlobShader() {
override fun toAndroidShader(context: BlobShaderContext): Shader {
return RadialGradient(
context.center.x,
context.center.y,
context.radius.coerceAtLeast(1f),
List(maxOf(2, colors.size)) { i -> colors[i % colors.size] }.map { it.toArgb() }.toIntArray(),
stops?.toFloatArray(),
tileMode
)
}
}
/**
* Linear gradient shader.
*/
data class Linear(
val colors: List<Color> = listOf(Color(0xFFE57373), Color(0xFFE57373)),
val from: Offset = Offset.Zero,
val to: Offset = Offset.Zero,
val tileMode: TileMode = TileMode.Mirror
) : BlobShader() {
override fun toAndroidShader(context: BlobShaderContext): Shader {
return LinearGradientShader(
from = from,
to = to,
colors = List(maxOf(2, colors.size)) { i -> colors[i % colors.size] },
tileMode = tileMode
)
}
}
// Future: Sweep, Procedural, Noise, Bitmap etc.
// data class Sweep(...)
}
/**
* Context passed into shaders containing draw-time info.
*/
data class BlobShaderContext(
val center: Offset,
val radius: Float,
val size: Size,
val density: Float
)
@Composable
fun MorphingBlob(
modifier: Modifier = Modifier,
morphPoints: Int = 6,
durationMillis: Int = (4000 + Random.nextInt(2000)),
blobStyle: BlobStyle = BlobStyle.default()
) {
val path = remember { Path() }
val radiusAnimValues = remember { List(morphPoints) { Animatable(Random.nextFloat()) } }
val angleOffset = remember { (2 * Math.PI / morphPoints * Math.random()).toFloat() }
val fractions = remember { FloatArray(morphPoints + 1) { it.toFloat() / morphPoints } }
val translationCenter = remember { mutableStateOf(Offset.Zero) }
val lastTranslationAngle = remember { mutableStateOf(0f) }
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
// Animate radius
LaunchedEffect(Unit) {
radiusAnimValues.forEach { animatable ->
launch {
while (true) {
val dest = generateDestFractions(fractions)
animatable.animateTo(
targetValue = dest.random(),
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = durationMillis,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
)
)
}
}
}
}
// Animate translation separately to avoid drift
LaunchedEffect(canvasSize) {
val width = canvasSize.width.toFloat()
val height = canvasSize.height.toFloat()
val outerRadius = (min(width, height) / 2f).coerceAtLeast(1f)
val r = outerRadius / 4000f
val outR = outerRadius / 6f
val cx = width / 2f
val cy = height / 2f
while (true) {
delay(30) // 30 FPS
val vx = translationCenter.value.x - cx
val vy = translationCenter.value.y - cy
val ratio = 1 - r / outR
val wx = vx * ratio
val wy = vy * ratio
lastTranslationAngle.value =
((Math.random() - 0.5) * Math.PI / 4 + lastTranslationAngle.value).toFloat()
val distRatio = Math.random().toFloat()
translationCenter.value = Offset(
cx + wx + r * distRatio * cos(lastTranslationAngle.value.toDouble()).toFloat(),
cy + wy + r * distRatio * sin(lastTranslationAngle.value.toDouble()).toFloat()
)
}
}
Canvas(modifier = modifier
.graphicsLayer {
alpha = blobStyle.effect.alpha
}
.onSizeChanged { size ->
canvasSize = size
translationCenter.value = Offset(size.width / 2f, size.height / 2f)
}
) {
if (canvasSize.width == 0 || canvasSize.height == 0) return@Canvas
val width = size.width
val height = size.height
val cx = translationCenter.value.x
val cy = translationCenter.value.y
val outerRadius = min(width, height) / 2f
val innerRadius = outerRadius * 0.75f
val ringWidth = outerRadius - innerRadius
val xs = FloatArray(morphPoints)
val ys = FloatArray(morphPoints)
val paint = Paint().asFrameworkPaint().apply {
isAntiAlias = true
// Apply background style
when (blobStyle.shape) {
is BlobShape.Fill -> {
style = android.graphics.Paint.Style.FILL
}
is BlobShape.Stroke -> {
style = android.graphics.Paint.Style.STROKE
strokeWidth = blobStyle.shape.strokeWidth * density
}
}
// Apply shader
shader = blobStyle.shader.toAndroidShader(
BlobShaderContext(
center = Offset(cx, cy),
radius = outerRadius,
size = Size(width, height),
density = density
)
)
// Apply glow and alpha
maskFilter = BlurMaskFilter(
blobStyle.effect.blurRadius.coerceAtLeast(1f) * density,
BlurMaskFilter.Blur.NORMAL
)
}
for (i in 0 until morphPoints) {
val t = radiusAnimValues[i].value
val r = innerRadius + ringWidth * t
val angle = (2 * Math.PI / morphPoints * i).toFloat() + angleOffset
xs[i] = cx + r * cos(angle.toDouble()).toFloat()
ys[i] = cy + r * sin(angle.toDouble()).toFloat()
}
path.reset()
path.moveTo(xs[0], ys[0])
for (i in 0 until morphPoints) {
val curr = Offset(xs.circular(i), ys.circular(i))
val next = Offset(xs.circular(i + 1), ys.circular(i + 1))
val v1 = getVector(xs, ys, i)
val v2 = getVector(xs, ys, i + 1)
path.cubicTo(
curr.x + v1.x, curr.y + v1.y,
next.x - v2.x, next.y - v2.y,
next.x, next.y
)
}
drawIntoCanvas {
it.nativeCanvas.drawPath(path.asAndroidPath(), paint)
}
}
}
private fun FloatArray.circular(index: Int): Float {
if (isEmpty()) error("Array cannot be empty")
return this[(index % size + size) % size]
}
private const val LINE_SMOOTHNESS = 0.16f
private fun getVector(xs: FloatArray, ys: FloatArray, i: Int): Offset {
val next = Offset(xs.circular(i + 1), ys.circular(i + 1))
val prev = Offset(xs.circular(i - 1), ys.circular(i - 1))
return (next - prev) * LINE_SMOOTHNESS
}
private fun generateDestFractions(fractions: FloatArray): List<Float> {
if (fractions.isEmpty()) return emptyList()
val startIndex = (fractions.indices).random()
return buildList {
addAll(fractions.slice(startIndex until fractions.size))
addAll(fractions.slice(0 until startIndex).reversed())
}
}
@Composable
fun WorkoutBlobs(
modifier: Modifier = Modifier
) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
MorphingBlob(
modifier = Modifier.size(260.dp),
morphPoints = 6,
blobStyle = BlobStyle(
effect = BlobEffect(blurRadius = 1f, alpha = 0.5f),
shader = BlobShader.Radial(colors = listOf(Color(0xFFF97272), Color(0xFFF97272))),
shape = BlobShape.Fill
)
)
MorphingBlob(
modifier = Modifier.size(260.dp),
morphPoints = 6,
blobStyle = BlobStyle(
effect = BlobEffect(blurRadius = 1f, alpha = 0.4f),
shader = BlobShader.Radial(colors = listOf(Color(0xFFF97272), Color(0xFFF97272))),
shape = BlobShape.Fill
)
)
MorphingBlob(
modifier = Modifier.size(280.dp),
morphPoints = 10,
durationMillis = 2000,
blobStyle = BlobStyle(
effect = BlobEffect(blurRadius = 1f, alpha = 0.3f),
shader = BlobShader.Radial(colors = listOf(Color(0xFFF97272), Color(0xFFF97272))),
shape = BlobShape.Fill
)
)
MorphingBlob(
modifier = Modifier.size(480.dp),
morphPoints = 16,
durationMillis = 2000,
blobStyle = BlobStyle(
effect = BlobEffect(blurRadius = 4f, alpha = 0.05f),
shader = BlobShader.Radial(colors = listOf(Color(0xFFF97272), Color.Transparent)),
shape = BlobShape.Fill
)
)
MorphingBlob(
modifier = Modifier.size(300.dp),
morphPoints = 6,
blobStyle = BlobStyle(
effect = BlobEffect(alpha = 0.6f),
shader = BlobShader.Radial(colors = listOf(Color(0xFFF97272), Color(0xFFF97272))),
shape = BlobShape.Stroke(strokeWidth = 1.5f)
)
)
MorphingBlob(
modifier = Modifier.size(360.dp),
morphPoints = 10,
blobStyle = BlobStyle(
effect = BlobEffect(alpha = 0.4f),
shader = BlobShader.Radial(colors = listOf(Color(0xFFF97272), Color(0xFFF97272))),
shape = BlobShape.Stroke(strokeWidth = 1.5f)
)
)
MorphingBlob(
modifier = Modifier.size(490.dp),
morphPoints = 12,
durationMillis = 2000,
blobStyle = BlobStyle(
effect = BlobEffect(alpha = 0.2f),
shader = BlobShader.Radial(colors = listOf(Color(0xFFF97272), Color(0xFFF97272))),
shape = BlobShape.Stroke(strokeWidth = 1.5f)
)
)
MorphingBlob(
modifier = Modifier.size(500.dp),
morphPoints = 12,
durationMillis = 1500,
blobStyle = BlobStyle(
effect = BlobEffect(alpha = 0.2f),
shader = BlobShader.Radial(colors = listOf(Color(0xFFF97272), Color(0xFFF97272))),
shape = BlobShape.Stroke(strokeWidth = 1.5f)
)
)
Text(
modifier = Modifier.align(Alignment.Center),
text = "Jumping Jack",
color = Color.White,
style = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold),
)
}
}
@iprashantpanwar
Copy link
Author

record_morph_blob.webm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment