Skip to content

Instantly share code, notes, and snippets.

@TuleSimon
Created June 25, 2026 10:16
Show Gist options
  • Select an option

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

Select an option

Save TuleSimon/e174f97a95586dbac8a878dba6c956f8 to your computer and use it in GitHub Desktop.
Magnifying Text Shader
package com.simon.mangifyingtext shader
import android.graphics.RenderEffect as AndroidRenderEffect
import android.graphics.RuntimeShader
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
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.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.TransformOrigin
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.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
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.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.simon.animatedbooksample.ui.theme.AnimatedBookSampleTheme
import kotlin.math.cos
import kotlin.math.sin
class MagnifyTextActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AnimatedBookSampleTheme(dynamicColor = false) {
Surface(color = Color(0xFF05070D), modifier = Modifier.fillMaxSize()) {
MagnifyTextScreen()
}
}
}
}
}
@Composable
private fun MagnifyTextScreen(modifier: Modifier = Modifier) {
val density = LocalDensity.current
val lensRadius = 112.dp
val lensRadiusPx = with(density) { lensRadius.toPx() }
val lensCenterState = remember { mutableStateOf(Offset.Zero) }
val dragVectorState = remember { mutableStateOf(Offset.Zero) }
val lensCenterProvider = remember { { lensCenterState.value } }
val dragVectorProvider = remember { { dragVectorState.value } }
var containerSize by remember { mutableStateOf(IntSize.Zero) }
var lensPlaced by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxSize()
.background(Color(0xFF05070D))
.systemBarsPadding()
.onSizeChanged { size ->
containerSize = size
if (!lensPlaced && size.width > 0 && size.height > 0) {
lensCenterState.value = Offset(
x = size.width * 0.42f,
y = size.height * 0.42f,
)
lensPlaced = true
} else if (size.width > 0 && size.height > 0) {
lensCenterState.value = lensCenterState.value.coerceInside(
size = size,
radiusPx = lensRadiusPx,
)
}
}
.pointerInput(containerSize, lensRadiusPx) {
if (containerSize.width == 0 || containerSize.height == 0) {
return@pointerInput
}
detectDragGestures(
onDragStart = { position ->
lensCenterState.value = position.coerceInside(
size = containerSize,
radiusPx = lensRadiusPx,
)
dragVectorState.value = Offset.Zero
},
onDragEnd = {
dragVectorState.value = Offset.Zero
},
onDragCancel = {
dragVectorState.value = Offset.Zero
},
onDrag = { change, dragAmount ->
lensCenterState.value = (lensCenterState.value + dragAmount).coerceInside(
size = containerSize,
radiusPx = lensRadiusPx,
)
dragVectorState.value = dragAmount
change.consume()
},
)
},
) {
MagnifyBackdrop(
lensCenterProvider = lensCenterProvider,
lensRadiusPx = lensRadiusPx,
modifier = Modifier.matchParentSize(),
)
MagnifiedTextLayer(
lensCenterProvider = lensCenterProvider,
dragVectorProvider = dragVectorProvider,
lensRadiusPx = lensRadiusPx,
zoom = 1.86f,
modifier = Modifier.matchParentSize(),
)
MagnifierChrome(
lensCenterProvider = lensCenterProvider,
dragVectorProvider = dragVectorProvider,
radiusPx = lensRadiusPx,
modifier = Modifier.matchParentSize(),
)
}
}
@Composable
private fun MagnifyBackdrop(
lensCenterProvider: () -> Offset,
lensRadiusPx: Float,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
Canvas(modifier = Modifier.matchParentSize()) {
val stageCenter = Offset(size.width * 0.5f, size.height * 0.47f)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color(0xFF02030A),
Color(0xFF111827),
Color(0xFF05070D),
),
),
size = size,
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFF8EA1BD).copy(alpha = 0.54f),
Color(0xFF26344E).copy(alpha = 0.34f),
Color.Transparent,
),
center = stageCenter,
radius = size.minDimension * 0.72f,
),
radius = size.minDimension * 0.72f,
center = stageCenter,
)
drawOval(
color = Color.White.copy(alpha = 0.055f),
topLeft = Offset(size.width * 0.15f, size.height * 0.67f),
size = Size(size.width * 0.70f, size.height * 0.08f),
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.58f),
),
center = Offset(size.width * 0.5f, size.height * 0.5f),
radius = size.maxDimension * 0.74f,
),
radius = size.maxDimension * 0.74f,
center = Offset(size.width * 0.5f, size.height * 0.5f),
)
}
MagnifyPhraseLayer(
textColor = Color(0xFFE7ECF4),
modifier = Modifier
.matchParentSize()
.circularLensCutout(
lensCenterProvider = lensCenterProvider,
lensRadiusPx = lensRadiusPx,
),
)
}
}
@Composable
private fun MagnifyPhraseLayer(
textColor: Color,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(
modifier = modifier.padding(horizontal = 34.dp),
contentAlignment = Alignment.Center,
) {
val titleSize = if (maxWidth < 380.dp) 46.sp else 58.sp
val titleLineHeight = if (maxWidth < 380.dp) 58.sp else 70.sp
Text(
text = "Magnify me",
color = textColor,
fontSize = titleSize,
lineHeight = titleLineHeight,
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun MagnifiedTextLayer(
lensCenterProvider: () -> Offset,
dragVectorProvider: () -> Offset,
lensRadiusPx: Float,
zoom: Float,
modifier: Modifier = Modifier,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShaderMagnifiedTextLayer(
lensCenterProvider = lensCenterProvider,
dragVectorProvider = dragVectorProvider,
lensRadiusPx = lensRadiusPx,
zoom = zoom,
modifier = modifier,
)
} else {
ScaledMagnifiedTextLayer(
lensCenterProvider = lensCenterProvider,
lensRadiusPx = lensRadiusPx,
zoom = zoom,
modifier = modifier,
)
}
}
@Composable
private fun RuntimeShaderMagnifiedTextLayer(
lensCenterProvider: () -> Offset,
dragVectorProvider: () -> Offset,
lensRadiusPx: Float,
zoom: Float,
modifier: Modifier = Modifier,
) {
val shader = remember { RuntimeShader(MagnifierShaderSource) }
Box(
modifier = modifier.graphicsLayer {
renderEffect = magnifierRenderEffect(
shader = shader,
center = lensCenterProvider(),
drag = dragVectorProvider(),
radiusPx = lensRadiusPx,
zoom = zoom,
)
},
) {
MagnifyPhraseLayer(
textColor = Color.White,
modifier = Modifier.matchParentSize(),
)
}
}
@Composable
private fun ScaledMagnifiedTextLayer(
lensCenterProvider: () -> Offset,
lensRadiusPx: Float,
zoom: Float,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.circularLensMask(
lensCenterProvider = lensCenterProvider,
lensRadiusPx = lensRadiusPx,
),
) {
MagnifyPhraseLayer(
textColor = Color.White,
modifier = Modifier
.matchParentSize()
.graphicsLayer {
val center = lensCenterProvider()
val width = size.width.coerceAtLeast(1f)
val height = size.height.coerceAtLeast(1f)
transformOrigin = TransformOrigin(
pivotFractionX = (center.x / width).coerceIn(0f, 1f),
pivotFractionY = (center.y / height).coerceIn(0f, 1f),
)
scaleX = zoom
scaleY = zoom
},
)
}
}
@Composable
private fun MagnifierChrome(
lensCenterProvider: () -> Offset,
dragVectorProvider: () -> Offset,
radiusPx: Float,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier) {
val center = lensCenterProvider()
if (center == Offset.Zero || radiusPx <= 0f) return@Canvas
val drag = dragVectorProvider()
val angle = Math.toRadians(42.0).toFloat()
val unit = Offset(cos(angle), sin(angle))
val normal = Offset(-unit.y, unit.x)
val handleStart = center + unit * (radiusPx * 0.78f)
val handleEnd = center + unit * (radiusPx * 1.78f) + drag * 0.26f
val shadowOffset = Offset(10f, 12f)
val dragStretch = drag.getDistance().coerceIn(0f, radiusPx * 0.20f)
drawLine(
color = Color.Black.copy(alpha = 0.18f),
start = handleStart + shadowOffset,
end = handleEnd + shadowOffset,
strokeWidth = 34f,
cap = StrokeCap.Round,
)
drawLine(
color = Color(0xFF8E2630),
start = handleStart,
end = handleEnd,
strokeWidth = 30f,
cap = StrokeCap.Round,
)
drawLine(
color = Color(0xFFE94C5A),
start = handleStart - normal * 2f,
end = handleEnd - normal * 2f,
strokeWidth = 18f,
cap = StrokeCap.Round,
)
drawLine(
color = Color.White.copy(alpha = 0.56f),
start = handleStart - normal * 9f,
end = handleEnd - normal * 9f,
strokeWidth = 4f,
cap = StrokeCap.Round,
)
drawCircle(
color = Color.Black.copy(alpha = 0.16f),
radius = radiusPx + 10f,
center = center + shadowOffset,
)
if (dragStretch > 0f) {
drawCircle(
color = Color.White.copy(alpha = 0.16f),
radius = radiusPx + dragStretch,
center = center + drag * 0.22f,
style = Stroke(width = 9f),
)
}
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = 0.38f),
Color(0xFFC7D0D8).copy(alpha = 0.14f),
Color.Transparent,
),
center = center - Offset(radiusPx * 0.22f, radiusPx * 0.24f),
radius = radiusPx,
),
radius = radiusPx * 0.95f,
center = center,
)
drawCircle(
color = Color(0xFF46484C),
radius = radiusPx,
center = center,
style = Stroke(width = 8f),
)
drawCircle(
color = Color(0xFFC7C3B8),
radius = radiusPx - 6f,
center = center,
style = Stroke(width = 4f),
)
drawCircle(
color = Color.White.copy(alpha = 0.72f),
radius = radiusPx - 12f,
center = center,
style = Stroke(width = 1.5f),
)
drawArc(
color = Color.White.copy(alpha = 0.78f),
startAngle = 206f,
sweepAngle = 74f,
useCenter = false,
topLeft = center - Offset(radiusPx * 0.74f, radiusPx * 0.74f),
size = Size(radiusPx * 1.48f, radiusPx * 1.48f),
style = Stroke(width = 7f, cap = StrokeCap.Round),
)
}
}
private fun Offset.coerceInside(size: IntSize, radiusPx: Float): Offset {
val horizontalInset = radiusPx * 0.58f
val verticalInset = radiusPx * 0.58f
val minX = horizontalInset
val maxX = (size.width - horizontalInset).coerceAtLeast(minX)
val minY = verticalInset
val maxY = (size.height - verticalInset).coerceAtLeast(minY)
return Offset(
x = x.coerceIn(minX, maxX),
y = y.coerceIn(minY, maxY),
)
}
private fun Modifier.circularLensMask(
lensCenterProvider: () -> Offset,
lensRadiusPx: Float,
): Modifier = drawWithContent {
val center = lensCenterProvider()
val path = Path().apply {
addOval(
Rect(
left = center.x - lensRadiusPx,
top = center.y - lensRadiusPx,
right = center.x + lensRadiusPx,
bottom = center.y + lensRadiusPx,
),
)
}
clipPath(path) {
this@drawWithContent.drawContent()
}
}
private fun Modifier.circularLensCutout(
lensCenterProvider: () -> Offset,
lensRadiusPx: Float,
): Modifier = drawWithContent {
val center = lensCenterProvider()
val path = Path().apply {
addOval(
Rect(
left = center.x - lensRadiusPx,
top = center.y - lensRadiusPx,
right = center.x + lensRadiusPx,
bottom = center.y + lensRadiusPx,
),
)
}
clipPath(path = path, clipOp = ClipOp.Difference) {
this@drawWithContent.drawContent()
}
}
private fun magnifierRenderEffect(
shader: RuntimeShader,
center: Offset,
drag: Offset,
radiusPx: Float,
zoom: Float,
): androidx.compose.ui.graphics.RenderEffect {
shader.setFloatUniform("center", center.x, center.y)
shader.setFloatUniform("drag", drag.x, drag.y)
shader.setFloatUniform("radius", radiusPx)
shader.setFloatUniform("zoom", zoom)
return AndroidRenderEffect
.createRuntimeShaderEffect(shader, "composable")
.asComposeRenderEffect()
}
private const val MagnifierShaderSource = """
uniform shader composable;
uniform float2 center;
uniform float2 drag;
uniform float radius;
uniform float zoom;
half4 main(float2 coord) {
float2 delta = coord - center;
float dist = length(delta);
float mask = 1.0 - smoothstep(radius - 1.0, radius + 1.0, dist);
if (mask <= 0.001) {
return half4(0.0);
}
float safeDist = max(dist, 0.001);
float2 direction = delta / safeDist;
float dragLength = length(drag);
float2 dragDirection = (dragLength > 0.001) ? drag / dragLength : float2(0.0, 0.0);
float edge = smoothstep(radius * 0.56, radius, dist);
float wave = sin(edge * 3.14159265) * 12.0;
float pull = min(dragLength * 2.6, radius * 0.23);
float2 sampleCoord = center + (delta / zoom) - (direction * wave) + (dragDirection * pull * edge);
half4 sharp = composable.eval(sampleCoord);
half4 blurA = composable.eval(sampleCoord + direction * 3.2);
half4 blurB = composable.eval(sampleCoord - direction * 3.2 + dragDirection * 2.0);
half4 color = mix(sharp, (sharp + blurA + blurB) / 3.0, edge * 0.48);
color.a *= mask;
return color;
}
"""
@Preview(showBackground = true)
@Composable
private fun MagnifyTextScreenPreview() {
AnimatedBookSampleTheme(dynamicColor = false) {
MagnifyTextScreen(modifier = Modifier.size(width = 390.dp, height = 760.dp))
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment