Skip to content

Instantly share code, notes, and snippets.

@TuleSimon
Created April 29, 2026 09:18
Show Gist options
  • Select an option

  • Save TuleSimon/10eecc0e72304b967a0d258c4e212104 to your computer and use it in GitHub Desktop.

Select an option

Save TuleSimon/10eecc0e72304b967a0d258c4e212104 to your computer and use it in GitHub Desktop.
A flashlight in Jetpack Compose that reveals text hidden in the dark. The cone shape, soft diffusion, dust streaks, and warm bulb glow are all one AGSL fragment shader running over regular Compose Text. You can drag the torch to move it, and tap the switch on the body to toggle the light.
package com.anonymous.flashlight
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
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.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.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.geometry.isSpecified
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asComposeRenderEffect
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.lerp
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
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.unit.toSize
import com.anonymous.flashlight.ui.theme.FlashlightTheme
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
private const val SHADER_SRC = """
uniform shader content;
uniform float2 resolution;
uniform float2 flashPos;
uniform float heading;
uniform float halfAngle;
uniform float coneLength;
uniform float onAlpha;
uniform float flickerSeed;
uniform float brightness;
float hash(float2 p) {
p = fract(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float valueNoise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + float2(1.0, 0.0));
float c = hash(i + float2(0.0, 1.0));
float d = hash(i + float2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
half4 main(float2 fragCoord) {
half4 src = content.eval(fragCoord);
float2 delta = fragCoord - flashPos;
float dist = length(delta);
// angular offset from the beam axis, wrapped to [-PI, PI]
float angle = atan(delta.y, delta.x);
float aDiff = angle - heading;
aDiff = aDiff - 6.28318530718 * floor((aDiff + 3.14159265359) / 6.28318530718);
// beam-aligned coords so noise stretches into ray-like streaks
float ch = cos(-heading);
float sh = sin(-heading);
float2 beam = float2(
delta.x * ch - delta.y * sh,
delta.x * sh + delta.y * ch
);
float n1 = valueNoise(float2(beam.x * 0.004, beam.y * 0.025) + float2(flickerSeed * 0.5, 0.0));
float n2 = valueNoise(float2(beam.x * 0.012, beam.y * 0.06) + float2(0.0, flickerSeed * 0.7));
float dust = n1 * 0.6 + n2 * 0.4;
// tight bright core + wide soft skirt, combined into a single angular falloff.
// both branches shape the same intensity mask — no additive warm overlay,
// so the cone illuminates the text rather than painting fog on top of it.
float core = exp(-(aDiff * aDiff) / (2.0 * (halfAngle * 0.5) * (halfAngle * 0.5)));
float wide = exp(-(aDiff * aDiff) / (2.0 * (halfAngle * 1.25) * (halfAngle * 1.25))) * 0.32;
float angFall = max(core, wide);
float r = dist / coneLength;
float radFall = 1.0 / (1.0 + 5.0 * r * r);
float intensity = angFall * radFall * onAlpha * (0.88 + 0.18 * dust);
half3 warm = half3(1.00, 0.93, 0.74);
half3 cool = half3(0.88, 0.91, 1.00);
half3 tint = mix(warm, cool, smoothstep(0.0, coneLength, dist));
half3 lit = src.rgb * tint * brightness;
half3 outRgb = mix(half3(0.0), lit, intensity);
// tight bulb glow right at the lens — the only additive layer
float haloR = dist / (coneLength * 0.07);
outRgb += warm * exp(-haloR * haloR) * onAlpha * 0.35 * brightness;
return half4(outRgb, 1.0);
}
"""
@Stable
class FlashlightState(
initialPosition: Offset = Offset.Unspecified,
initialIsOn: Boolean = false,
initialHeadingRadians: Float = -(PI / 2.0).toFloat(),
) {
var position: Offset by mutableStateOf(initialPosition)
var isOn: Boolean by mutableStateOf(initialIsOn)
var headingRadians: Float by mutableFloatStateOf(initialHeadingRadians)
fun toggle() {
isOn = !isOn
}
fun translate(delta: Offset, bounds: Size) {
if (position.isUnspecified) return
val newX = (position.x + delta.x).coerceIn(0f, bounds.width)
val newY = (position.y + delta.y).coerceIn(0f, bounds.height)
position = Offset(newX, newY)
}
fun rotateBy(deltaRadians: Float) {
headingRadians += deltaRadians
}
}
@Composable
fun rememberFlashlightState(): FlashlightState = remember { FlashlightState() }
@Composable
fun FlashlightScreen(
state: FlashlightState = rememberFlashlightState(),
) {
val shader = remember { RuntimeShader(SHADER_SRC) }
var brightness by remember { mutableFloatStateOf(1.9f) }
val coneAlpha by animateFloatAsState(
targetValue = if (state.isOn) 1f else 0f,
animationSpec = tween(durationMillis = 220),
label = "coneAlpha",
)
val nubProgress by animateFloatAsState(
targetValue = if (state.isOn) 1f else 0f,
animationSpec = tween(durationMillis = 180),
label = "nubProgress",
)
val flickerTransition = rememberInfiniteTransition(label = "flicker")
val flickerSeed by flickerTransition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 60_000, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "flickerSeed",
)
Box(modifier = Modifier
.fillMaxSize()
.background(Color.Black)) {
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { sz ->
if (state.position.isUnspecified && sz.width > 0 && sz.height > 0) {
state.position = Offset(sz.width / 2f, sz.height / 2f)
}
}
.graphicsLayer {
val safePos = if (state.position.isSpecified) state.position
else Offset(-10_000f, -10_000f)
shader.setFloatUniform("resolution", size.width, size.height)
shader.setFloatUniform("flashPos", safePos.x, safePos.y)
shader.setFloatUniform("heading", state.headingRadians)
shader.setFloatUniform("halfAngle", (PI / 7.0).toFloat())
shader.setFloatUniform(
"coneLength",
min(size.width, size.height) * 0.9f,
)
shader.setFloatUniform("onAlpha", coneAlpha)
shader.setFloatUniform("flickerSeed", flickerSeed)
shader.setFloatUniform("brightness", brightness)
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "content")
.asComposeRenderEffect()
},
) {
RevealText()
}
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(state) {
awaitEachGesture {
val down = awaitFirstDown()
if (down.position in switchHitRect(state)) {
handleSwitchTap(down.position, state)
} else {
handleBodyDrag(down.position, state)
}
}
},
) {
drawTorchBody(state, nubProgress = nubProgress)
}
BrightnessSlider(
value = brightness,
onValueChange = { brightness = it },
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(0.7f)
.padding(bottom = 48.dp),
)
}
}
private suspend fun AwaitPointerEventScope.handleSwitchTap(
downPos: Offset,
state: FlashlightState,
) {
val cancelDistance = 8.dp.toPx()
while (true) {
val change = awaitPointerEvent().changes.first()
if (change.changedToUp()) {
state.toggle()
change.consume()
return
}
if ((change.position - downPos).getDistance() > cancelDistance) return
change.consume()
}
}
private suspend fun AwaitPointerEventScope.handleBodyDrag(
downPos: Offset,
state: FlashlightState,
) {
var prev = downPos
var prevDir: Float? = null
while (true) {
val change = awaitPointerEvent().changes.first()
if (change.changedToUp()) {
change.consume()
return
}
val delta = change.position - prev
prev = change.position
state.translate(delta, size.toSize())
if (delta.getDistance() > 4f) {
val dir = atan2(delta.y, delta.x)
prevDir?.let { p ->
var d = dir - p
if (d > PI) d -= (2 * PI).toFloat()
if (d < -PI) d += (2 * PI).toFloat()
if (abs(d) > 0.02f) state.rotateBy(d.coerceIn(-0.15f, 0.15f))
}
prevDir = dir
}
change.consume()
}
}
@Composable
private fun BrightnessSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Brightness",
color = Color.White.copy(alpha = 0.55f),
fontSize = 11.sp,
letterSpacing = 2.sp,
)
Slider(
value = value,
onValueChange = onValueChange,
valueRange = 0.5f..3.0f,
colors = SliderDefaults.colors(
thumbColor = Color(0xFFFFD54F),
activeTrackColor = Color(0xFFFFD54F).copy(alpha = 0.7f),
inactiveTrackColor = Color.White.copy(alpha = 0.18f),
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
),
)
}
}
@Composable
private fun RevealText() {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 56.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = "Lorem ipsum",
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
)
for (paragraph in LOREM_PARAGRAPHS) {
Text(
text = paragraph,
color = Color.White,
fontSize = 16.sp,
lineHeight = 28.sp,
letterSpacing = 0.5.sp,
)
}
}
}
private val LOREM_PARAGRAPHS = listOf(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor " +
"incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis " +
"nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu " +
"fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in " +
"culpa qui officia deserunt mollit anim id est laborum.",
"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium " +
"doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore " +
"veritatis et quasi architecto beatae vitae dicta sunt explicabo.",
"Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed " +
"quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. " +
"Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.",
"At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis " +
"praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias " +
"excepturi sint occaecati cupiditate non provident, similique sunt in culpa.",
)
private val switchLocalY = 30.dp
private val switchHitHalf = 28.dp
private fun PointerInputScope.switchHitRect(state: FlashlightState): Rect {
if (state.position.isUnspecified) return Rect.Zero
val localY = switchLocalY.toPx()
val rotation = state.headingRadians + (PI / 2.0).toFloat()
val c = cos(rotation)
val s = sin(rotation)
val center = state.position + Offset(-localY * s, localY * c)
val half = switchHitHalf.toPx()
return Rect(center.x - half, center.y - half, center.x + half, center.y + half)
}
private fun DrawScope.drawTorchBody(state: FlashlightState, nubProgress: Float) {
if (state.position.isUnspecified) return
val rotationDegrees = Math.toDegrees(state.headingRadians + PI / 2.0).toFloat()
val apex = state.position
rotate(degrees = rotationDegrees, pivot = apex) {
val bezelW = 38.dp.toPx()
val bezelH = 16.dp.toPx()
val collarW = 32.dp.toPx()
val collarH = 5.dp.toPx()
val bodyW = 26.dp.toPx()
val bodyH = 64.dp.toPx()
val tailCollarW = 30.dp.toPx()
val tailCollarH = 5.dp.toPx()
val tailW = 28.dp.toPx()
val tailH = 10.dp.toPx()
val bezelTop = apex.y - bezelH / 2f
drawRoundRect(
color = Color(0xFF3F3F3F),
topLeft = Offset(apex.x - bezelW / 2f, bezelTop),
size = Size(bezelW, bezelH),
cornerRadius = CornerRadius(5.dp.toPx()),
)
drawRoundRect(
color = Color(0xFF6E6E6E),
topLeft = Offset(apex.x - bezelW / 2f + 1.dp.toPx(), bezelTop + 1.dp.toPx()),
size = Size(bezelW - 2.dp.toPx(), 3.dp.toPx()),
cornerRadius = CornerRadius(3.dp.toPx()),
)
var y = bezelTop + bezelH
drawRoundRect(
color = Color(0xFF2A2A2A),
topLeft = Offset(apex.x - collarW / 2f, y),
size = Size(collarW, collarH),
cornerRadius = CornerRadius(2.dp.toPx()),
)
y += collarH
val bodyTop = y
drawRoundRect(
color = Color(0xFF252525),
topLeft = Offset(apex.x - bodyW / 2f, bodyTop),
size = Size(bodyW, bodyH),
cornerRadius = CornerRadius(5.dp.toPx()),
)
drawRoundRect(
color = Color(0xFF3D3D3D),
topLeft = Offset(apex.x - bodyW / 2f + 3.dp.toPx(), bodyTop + 4.dp.toPx()),
size = Size(2.dp.toPx(), bodyH - 8.dp.toPx()),
cornerRadius = CornerRadius(1.dp.toPx()),
)
val ridgeStartY = bodyTop + bodyH * 0.55f
repeat(5) { i ->
drawRoundRect(
color = Color(0xFF101010),
topLeft = Offset(
apex.x - bodyW / 2f + 2.dp.toPx(),
ridgeStartY + i * 4.dp.toPx(),
),
size = Size(bodyW - 4.dp.toPx(), 1.5.dp.toPx()),
cornerRadius = CornerRadius(0.5.dp.toPx()),
)
}
y = bodyTop + bodyH
drawRoundRect(
color = Color(0xFF2A2A2A),
topLeft = Offset(apex.x - tailCollarW / 2f, y),
size = Size(tailCollarW, tailCollarH),
cornerRadius = CornerRadius(2.dp.toPx()),
)
y += tailCollarH
drawRoundRect(
color = Color(0xFF1A1A1A),
topLeft = Offset(apex.x - tailW / 2f, y),
size = Size(tailW, tailH),
cornerRadius = CornerRadius(4.dp.toPx()),
)
val switchBaseW = 16.dp.toPx()
val switchBaseH = 26.dp.toPx()
val switchCenterY = apex.y + switchLocalY.toPx()
drawRoundRect(
color = Color(0xFF0A0A0A),
topLeft = Offset(apex.x - switchBaseW / 2f, switchCenterY - switchBaseH / 2f),
size = Size(switchBaseW, switchBaseH),
cornerRadius = CornerRadius(3.dp.toPx()),
)
drawRoundRect(
color = Color(0xFF1F1F1F),
topLeft = Offset(
apex.x - switchBaseW / 2f + 1.5.dp.toPx(),
switchCenterY - switchBaseH / 2f + 1.5.dp.toPx(),
),
size = Size(switchBaseW - 3.dp.toPx(), switchBaseH - 3.dp.toPx()),
cornerRadius = CornerRadius(2.dp.toPx()),
)
val nubW = 12.dp.toPx()
val nubH = 11.dp.toPx()
val trackTop = switchCenterY - switchBaseH / 2f + 2.5.dp.toPx()
val trackBottom = switchCenterY + switchBaseH / 2f - 2.5.dp.toPx() - nubH
val nubY = trackBottom + (trackTop - trackBottom) * nubProgress
val nubColor = lerp(Color(0xFFCFCFCF), Color(0xFFFFD54F), nubProgress)
drawRoundRect(
color = nubColor,
topLeft = Offset(apex.x - nubW / 2f, nubY),
size = Size(nubW, nubH),
cornerRadius = CornerRadius(2.5.dp.toPx()),
)
drawRoundRect(
color = Color.White.copy(alpha = 0.35f),
topLeft = Offset(apex.x - nubW / 2f + 1.5.dp.toPx(), nubY + 1.5.dp.toPx()),
size = Size(nubW - 3.dp.toPx(), 2.dp.toPx()),
cornerRadius = CornerRadius(1.dp.toPx()),
)
drawCircle(
color = Color(0xFF0A0A0A),
radius = 14.dp.toPx(),
center = apex,
)
drawCircle(
color = if (state.isOn) Color(0xFFFFE082) else Color(0xFF2A2520),
radius = 11.dp.toPx(),
center = apex,
alpha = if (state.isOn) 1f else 0.85f,
)
drawCircle(
color = Color.White,
radius = 4.dp.toPx(),
center = apex,
alpha = if (state.isOn) 0.95f else 0f,
)
if (state.isOn) {
drawCircle(
color = Color.White.copy(alpha = 0.55f),
radius = 2.5.dp.toPx(),
center = Offset(apex.x - 4.dp.toPx(), apex.y - 4.dp.toPx()),
)
}
}
}
// Layoutlib in Android Studio doesn't fully render RuntimeShader effects, so the
// preview will show the underlying text + torch crisply rather than masked. Still
// useful for layout iteration; runtime rendering is correct.
@Preview(showBackground = true, backgroundColor = 0xFF000000, widthDp = 380, heightDp = 800)
@Composable
private fun FlashlightOffPreview() {
FlashlightTheme { FlashlightScreen() }
}
@Preview(showBackground = true, backgroundColor = 0xFF000000, widthDp = 380, heightDp = 800)
@Composable
private fun FlashlightOnPreview() {
FlashlightTheme {
val state = remember {
FlashlightState(
initialPosition = Offset(540f, 1700f),
initialIsOn = true,
)
}
FlashlightScreen(state = state)
}
}
@TuleSimon

Copy link
Copy Markdown
Author
Screenshot 2026-04-29 at 10 26 31

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