Skip to content

Instantly share code, notes, and snippets.

@Syedwaheed
Created July 20, 2026 20:03
Show Gist options
  • Select an option

  • Save Syedwaheed/39126565f50cfc2011d8ebb98f916ffa to your computer and use it in GitHub Desktop.

Select an option

Save Syedwaheed/39126565f50cfc2011d8ebb98f916ffa to your computer and use it in GitHub Desktop.
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.lerp as lerpColor
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
private val NightTop = Color(0xFF04060E)
private val NightHorizon = Color(0xFF101528)
private val TowerGold = Color(0xFFFFC66B)
private val TowerChampagne = Color(0xFFFFE9C4)
private val BeamWarm = Color(0xFFFFE3B0)
private val SparkleWhite = Color(0xFFFFF6E0)
@Composable
fun EiffelTowerTorch() {
val sweep = remember { Animatable(0f) }
val reveal = remember { Animatable(0f) }
val shatter = remember { Animatable(0f) }
var shatterOrigin by remember { mutableStateOf<Offset?>(null) }
val taps = remember { Channel<Offset>(Channel.CONFLATED) }
var dragPos by remember { mutableStateOf<Offset?>(null) }
var hasDragged by remember { mutableStateOf(false) }
var time by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
val start = withFrameNanos { it }
while (true) withFrameNanos { time = (it - start) / 1e9f }
}
// One show cycle: sweep the torch down, reveal the whole tower, then hold.
// A tap during the hold shatters the tower into its lattice pieces; either
// way the lights go out and the torch comes back to rebuild it.
LaunchedEffect(Unit) {
val sweepEasing = CubicBezierEasing(0.45f, 0f, 0.55f, 1f)
while (true) {
shatter.snapTo(0f)
reveal.snapTo(0f)
sweep.snapTo(0f)
sweep.animateTo(1f, tween(6000, easing = sweepEasing))
reveal.animateTo(1f, tween(500))
while (taps.tryReceive().isSuccess) Unit // drop taps landed before the hold
val tap = withTimeoutOrNull(2800) { taps.receive() }
if (tap == null) {
reveal.animateTo(0f, tween(1400))
} else {
shatterOrigin = tap
reveal.snapTo(0f)
shatter.animateTo(1f, tween(1150, easing = LinearEasing))
}
delay(400)
}
}
val stars = remember { List(90) { NightStar.random() } }
val sparkles = remember { List(160) { Sparkle.random() } }
val artCache = remember { TowerArtCache() }
val hintAlpha by rememberInfiniteTransition(label = "hint").animateFloat(
initialValue = 0.25f, targetValue = 0.7f,
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
label = "hintAlpha",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(NightTop)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { dragPos = it; hasDragged = true },
onDragEnd = { dragPos = null },
onDragCancel = { dragPos = null },
) { change, _ ->
change.consume()
dragPos = change.position
}
}
.pointerInput(Unit) {
detectTapGestures { pos ->
if (reveal.value > 0.99f && shatter.value == 0f) taps.trySend(pos)
}
},
) {
// Ambient layer: sky, stars, moon and a barely-visible tower silhouette.
Canvas(modifier = Modifier.fillMaxSize()) {
val art = artCache.get(size)
val torch = dragPos ?: autoTorchPosition(sweep.value, art)
val beamRadius = min(size.width, size.height) * 0.36f
drawRect(Brush.verticalGradient(0f to NightTop, 1f to NightHorizon))
drawStars(stars, time)
drawMoon()
// Faint city glow behind the tower base.
drawCircle(
brush = Brush.radialGradient(
0f to TowerGold.copy(alpha = 0.06f), 1f to Color.Transparent,
center = Offset(size.width / 2f, art.groundY), radius = size.width * 0.8f,
),
center = Offset(size.width / 2f, art.groundY),
radius = size.width * 0.8f,
)
drawTower(art, TowerGold, alpha = 0.055f)
// The torch light itself, spilling past the tower.
val glowAlpha = 0.12f * (1f - reveal.value)
if (glowAlpha > 0.005f) {
drawCircle(
brush = Brush.radialGradient(
0f to BeamWarm.copy(alpha = glowAlpha),
1f to Color.Transparent,
center = torch, radius = beamRadius * 1.3f,
),
center = torch,
radius = beamRadius * 1.3f,
)
}
}
// Lit layer: the golden tower, masked to the torch beam (or fully shown during the finale).
Canvas(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen },
) {
// While the tower is in pieces the debris layer owns the pixels.
if (shatter.value > 0f) return@Canvas
val art = artCache.get(size)
val torch = dragPos ?: autoTorchPosition(sweep.value, art)
val beamRadius = min(size.width, size.height) * 0.36f
// Brightness surge: as the full tower reveals, the gold warms up toward
// champagne white and the glow pass intensifies — "lights at full power".
val surge = reveal.value
drawTower(
art = art,
color = lerpColor(TowerGold, TowerChampagne, surge),
alpha = 1f,
glow = true,
glowStrength = 1f + surge * 0.9f,
)
if (surge > 0.01f) drawSparkles(sparkles, art, time, surge)
// Keep the layer where the beam is; outside it, keep only `reveal` worth of alpha.
drawRect(
brush = Brush.radialGradient(
0f to Color.White,
0.55f to Color.White,
1f to Color.White.copy(alpha = reveal.value),
center = torch, radius = beamRadius,
),
blendMode = BlendMode.DstIn,
)
}
// Shatter layer: the tower broken into its lattice pieces, thrown from
// the tap point and tumbling under gravity.
if (shatter.value > 0f && shatter.value < 1f) {
Canvas(modifier = Modifier.fillMaxSize()) {
val art = artCache.get(size)
val origin = shatterOrigin ?: Offset(art.cx, art.yAt(0.4f))
drawShatteredTower(art, origin, shatter.value)
}
}
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val towerFull = reveal.value > 0.99f && shatter.value == 0f
if (towerFull) {
Text(
text = "✦ tap the tower ✦",
color = BeamWarm.copy(alpha = hintAlpha),
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(14.dp))
} else if (!hasDragged) {
Text(
text = "✦ drag the light ✦",
color = BeamWarm.copy(alpha = hintAlpha),
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(14.dp))
}
Text(
text = "LA TOUR EIFFEL",
color = TowerGold.copy(alpha = 0.75f),
fontSize = 14.sp,
letterSpacing = 6.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Paris · Jetpack Compose Canvas",
color = Color.White.copy(alpha = 0.35f),
fontSize = 10.sp,
letterSpacing = 2.sp,
textAlign = TextAlign.Center,
)
}
}
}
/* ---------------------------- torch path ---------------------------- */
private fun autoTorchPosition(sweep: Float, art: TowerArt): Offset {
val overshoot = art.towerH * 0.12f
val y = art.topY - overshoot + sweep * (art.groundY - art.topY + 2 * overshoot)
// Slight hand-held sway while descending.
val x = art.cx + sin(sweep * 7f) * art.baseHalf * 0.45f
return Offset(x, y)
}
/* ---------------------------- tower geometry ---------------------------- */
private class TowerArt(
val size: Size,
val strokes: Path,
val decks: Path,
val pieces: List<TowerPiece>,
val cx: Float,
val topY: Float,
val groundY: Float,
val towerH: Float,
val baseHalf: Float,
private val flare: Float,
val archInnerHalf: Float,
val archTopY: Float,
) {
fun halfWidthAt(t: Float) = towerH * (0.012f + flare * t.pow(2.6f))
fun yAt(t: Float) = topY + t * towerH
}
private class TowerArtCache {
private var art: TowerArt? = null
fun get(size: Size): TowerArt =
art?.takeIf { it.size == size } ?: buildTowerArt(size).also { art = it }
}
private fun buildTowerArt(size: Size): TowerArt {
val w = size.width
val h = size.height
val cx = w / 2f
val groundY = h * 0.865f
val topY = h * 0.175f
val towerH = groundY - topY
val baseHalf = min(0.26f * towerH, 0.42f * w)
val flare = baseHalf / towerH - 0.012f
fun half(t: Float) = towerH * (0.012f + flare * t.pow(2.6f))
fun y(t: Float) = topY + t * towerH
val strokes = Path()
val decks = Path()
// Every drawn segment is also recorded as a piece, so a shatter can throw
// the exact same geometry the intact tower is made of.
val pieces = mutableListOf<TowerPiece>()
val rnd = Random(7)
fun piece(x1: Float, y1: Float, x2: Float, y2: Float, width: Float = 0f) {
pieces += TowerPiece(
a = Offset(x1, y1), b = Offset(x2, y2), width = width,
jx = rnd.nextFloat() * 2f - 1f,
jy = rnd.nextFloat() * 2f - 1f,
spin = rnd.nextFloat() * 2f - 1f,
)
}
// Outer edges, sampled so the power-curve flare stays smooth.
val steps = 72
val edgeChunk = 4
for (side in intArrayOf(-1, 1)) {
for (i in 0..steps) {
val t = i / steps.toFloat()
val x = cx + side * half(t)
if (i == 0) strokes.moveTo(x, y(t)) else strokes.lineTo(x, y(t))
}
for (i in 0 until steps step edgeChunk) {
val ta = i / steps.toFloat()
val tb = (i + edgeChunk) / steps.toFloat()
piece(cx + side * half(ta), y(ta), cx + side * half(tb), y(tb))
}
}
// Antenna spire.
strokes.moveTo(cx, topY)
strokes.lineTo(cx, h * 0.125f)
val antennaMidY = (topY + h * 0.125f) / 2f
piece(cx, topY, cx, antennaMidY)
piece(cx, antennaMidY, cx, h * 0.125f)
// Observation decks: top, second and first floors.
for ((t, widthFactor, thickness) in listOf(
Triple(0.055f, 1.9f, towerH * 0.016f),
Triple(0.385f, 1.45f, towerH * 0.022f),
Triple(0.685f, 1.32f, towerH * 0.026f),
)) {
val hw = half(t) * widthFactor
decks.addRect(Rect(cx - hw, y(t) - thickness / 2f, cx + hw, y(t) + thickness / 2f))
val deckChunks = 4
for (k in 0 until deckChunks) {
val x0 = cx - hw + 2f * hw * k / deckChunks
val x1 = cx - hw + 2f * hw * (k + 1) / deckChunks
piece(x0, y(t), x1, y(t), width = thickness)
}
}
// Lattice X-bracing between the outer edges.
fun braceBand(t0: Float, t1: Float, bands: Int) {
for (k in 0 until bands) {
val ta = t0 + (t1 - t0) * k / bands
val tb = t0 + (t1 - t0) * (k + 1) / bands
val ya = y(ta)
val yb = y(tb)
strokes.moveTo(cx - half(ta), ya); strokes.lineTo(cx + half(tb), yb)
strokes.moveTo(cx + half(ta), ya); strokes.lineTo(cx - half(tb), yb)
strokes.moveTo(cx - half(tb), yb); strokes.lineTo(cx + half(tb), yb)
piece(cx - half(ta), ya, cx + half(tb), yb)
piece(cx + half(ta), ya, cx - half(tb), yb)
piece(cx - half(tb), yb, cx + half(tb), yb)
}
}
braceBand(0.075f, 0.365f, 6)
braceBand(0.405f, 0.662f, 4)
braceBand(0.71f, 0.80f, 1)
// The arch between the legs: top half of an ellipse resting on the ground line.
val archInnerHalf = baseHalf * 0.52f
val archTopY = y(0.80f)
val archRy = groundY - archTopY
strokes.arcTo(
rect = Rect(cx - archInnerHalf, groundY - archRy, cx + archInnerHalf, groundY + archRy),
startAngleDegrees = 180f,
sweepAngleDegrees = 180f,
forceMoveTo = true,
)
val archSegs = 16
var prevArchX = cx - archInnerHalf
var prevArchY = groundY
for (i in 1..archSegs) {
val ang = PI.toFloat() * (1f + i / archSegs.toFloat())
val ax = cx + archInnerHalf * cos(ang)
val ay = groundY + archRy * sin(ang)
piece(prevArchX, prevArchY, ax, ay)
prevArchX = ax
prevArchY = ay
}
fun archInnerX(yy: Float): Float {
val n = 1f - ((groundY - yy) / archRy).pow(2)
return archInnerHalf * sqrt(n.coerceAtLeast(0f))
}
// Leg lattice between each outer edge and the arch.
val legBands = 3
for (k in 0 until legBands) {
val ta = 0.82f + 0.18f * k / legBands
val tb = 0.82f + 0.18f * (k + 1) / legBands
val ya = y(ta)
val yb = y(tb)
for (side in intArrayOf(-1, 1)) {
strokes.moveTo(cx + side * half(ta), ya); strokes.lineTo(cx + side * archInnerX(yb), yb)
strokes.moveTo(cx + side * archInnerX(ya), ya); strokes.lineTo(cx + side * half(tb), yb)
piece(cx + side * half(ta), ya, cx + side * archInnerX(yb), yb)
piece(cx + side * archInnerX(ya), ya, cx + side * half(tb), yb)
}
}
// Ground line.
strokes.moveTo(cx - baseHalf * 1.35f, groundY)
strokes.lineTo(cx + baseHalf * 1.35f, groundY)
val groundChunks = 8
val groundX0 = cx - baseHalf * 1.35f
val groundSpan = baseHalf * 2.7f
for (k in 0 until groundChunks) {
piece(
groundX0 + groundSpan * k / groundChunks, groundY,
groundX0 + groundSpan * (k + 1) / groundChunks, groundY,
)
}
return TowerArt(size, strokes, decks, pieces, cx, topY, groundY, towerH, baseHalf, flare, archInnerHalf, archTopY)
}
private fun DrawScope.drawTower(
art: TowerArt,
color: Color,
alpha: Float,
glow: Boolean = false,
glowStrength: Float = 1f,
) {
val strokeWidth = (art.towerH * 0.0035f).coerceAtLeast(1.5f)
if (glow) {
drawPath(
art.strokes,
color,
alpha = (alpha * 0.25f * glowStrength).coerceAtMost(0.5f),
style = Stroke(strokeWidth * 4f, cap = StrokeCap.Round),
)
}
drawPath(art.strokes, color, alpha = alpha, style = Stroke(strokeWidth, cap = StrokeCap.Round))
drawPath(art.decks, color, alpha = alpha)
}
/* ---------------------------- shatter ---------------------------- */
private class TowerPiece(
val a: Offset,
val b: Offset,
val width: Float, // 0 = use the tower stroke width
val jx: Float,
val jy: Float,
val spin: Float,
)
private fun smoothstep(edge0: Float, edge1: Float, x: Float): Float {
val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
return t * t * (3f - 2f * t)
}
private fun DrawScope.drawShatteredTower(art: TowerArt, origin: Offset, progress: Float) {
val t = progress * 1.15f // virtual seconds since the break
val gravity = art.towerH * 2.4f
val falloff = art.towerH * 0.35f
val jitter = art.towerH * 0.18f
val strokeWidth = (art.towerH * 0.0035f).coerceAtLeast(1.5f)
// Pieces cool from champagne white back down to gold as they fall.
val color = lerpColor(TowerChampagne, TowerGold, progress)
// Impact flash at the tap point.
if (progress < 0.3f) {
val k = progress / 0.3f
val flashRadius = art.towerH * (0.06f + 0.28f * k)
drawCircle(
brush = Brush.radialGradient(
0f to SparkleWhite.copy(alpha = 0.45f * (1f - k)), 1f to Color.Transparent,
center = origin, radius = flashRadius,
),
center = origin,
radius = flashRadius,
)
}
for (p in art.pieces) {
val fade = 1f - smoothstep(0.5f + 0.25f * abs(p.jy), 1f, progress)
if (fade <= 0.01f) continue
val mid = Offset((p.a.x + p.b.x) / 2f, (p.a.y + p.b.y) / 2f)
// Radial impulse from the tap point: nearby pieces fly hard, distant
// ones mostly just crumble and drop.
val dx = mid.x - origin.x
val dy = mid.y - origin.y
val dist = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
val burst = art.towerH * 1.2f / (1f + dist / falloff)
val vx = dx / dist * burst + p.jx * jitter
val vy = dy / dist * burst + p.jy * jitter
val shiftX = vx * t
val shiftY = vy * t + 0.5f * gravity * t * t
val angle = p.spin * 6f * t
val ca = cos(angle)
val sa = sin(angle)
fun place(pt: Offset): Offset {
val rx = pt.x - mid.x
val ry = pt.y - mid.y
return Offset(
mid.x + shiftX + rx * ca - ry * sa,
mid.y + shiftY + rx * sa + ry * ca,
)
}
val a = place(p.a)
val b = place(p.b)
val w = if (p.width > 0f) p.width else strokeWidth
drawLine(color, a, b, strokeWidth = w * 3.5f, cap = StrokeCap.Round, alpha = fade * 0.18f)
drawLine(color, a, b, strokeWidth = w, cap = StrokeCap.Round, alpha = fade)
}
// Hot sparks thrown from the impact point.
if (progress < 0.5f) {
val sparkFade = 1f - progress / 0.5f
for (i in 0 until 14) {
val ang = i * 2.4f // golden-angle spacing scatters directions evenly
val speed = art.towerH * (0.5f + 0.45f * ((i * 37) % 10) / 10f)
val sx = origin.x + cos(ang) * speed * t
val sy = origin.y + sin(ang) * speed * t + 0.35f * gravity * t * t
drawCircle(SparkleWhite.copy(alpha = sparkFade * 0.5f), radius = 3.5f, center = Offset(sx, sy))
drawCircle(SparkleWhite.copy(alpha = sparkFade), radius = 1.5f, center = Offset(sx, sy))
}
}
}
/* ---------------------------- night sky ---------------------------- */
private class NightStar(val x: Float, val y: Float, val radius: Float, val phase: Float, val speed: Float) {
companion object {
fun random() = NightStar(
x = Random.nextFloat(),
y = Random.nextFloat() * 0.55f,
radius = 0.6f + Random.nextFloat() * 1.3f,
phase = Random.nextFloat() * 6.28f,
speed = 0.4f + Random.nextFloat() * 1.4f,
)
}
}
private fun DrawScope.drawStars(stars: List<NightStar>, time: Float) {
for (star in stars) {
val twinkle = 0.35f + 0.65f * (0.5f + 0.5f * sin(time * star.speed + star.phase))
drawCircle(
color = Color.White.copy(alpha = 0.35f * twinkle),
radius = star.radius,
center = Offset(star.x * size.width, star.y * size.height),
)
}
}
private fun DrawScope.drawMoon() {
val center = Offset(size.width * 0.82f, size.height * 0.10f)
val radius = size.width * 0.045f
drawCircle(
brush = Brush.radialGradient(
0f to Color(0xFFE8ECF5).copy(alpha = 0.25f), 1f to Color.Transparent,
center = center, radius = radius * 3.5f,
),
center = center,
radius = radius * 3.5f,
)
drawCircle(Color(0xFFE8ECF5).copy(alpha = 0.8f), radius, center)
}
/* ---------------------------- sparkle finale ---------------------------- */
private class Sparkle(val t: Float, val u: Float, val phase: Float, val speed: Float) {
companion object {
fun random() = Sparkle(
t = Random.nextFloat(),
u = Random.nextFloat() * 2f - 1f,
phase = Random.nextFloat() * 6.28f,
speed = 5f + Random.nextFloat() * 7f,
)
}
}
private fun DrawScope.drawSparkles(sparkles: List<Sparkle>, art: TowerArt, time: Float, reveal: Float) {
for (sparkle in sparkles) {
// Below the arch, push sparkles out onto the legs so none float in the empty archway.
val u = if (sparkle.t > 0.80f) {
val sign = if (sparkle.u < 0f) -1f else 1f
sign * lerp(0.6f, 1f, kotlin.math.abs(sparkle.u))
} else {
sparkle.u
}
val x = art.cx + u * art.halfWidthAt(sparkle.t) * 0.95f
val y = art.yAt(sparkle.t)
val flicker = (0.5f + 0.5f * sin(time * sparkle.speed + sparkle.phase)).pow(6)
val alpha = flicker * reveal
if (alpha < 0.02f) continue
drawCircle(SparkleWhite.copy(alpha = alpha * 0.5f), radius = 4f, center = Offset(x, y))
drawCircle(SparkleWhite.copy(alpha = alpha), radius = 1.6f, center = Offset(x, y))
}
}
@Preview(showBackground = true, backgroundColor = 0xFF04060E, widthDp = 400, heightDp = 860)
@Composable
private fun EiffelTowerTorchPreview() {
EiffelTowerTorch()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment