Created
July 22, 2025 17:29
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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 | |
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @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()) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @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), | |
| ) | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
record_morph_blob.webm