Created
June 25, 2026 10:16
-
-
Save TuleSimon/e174f97a95586dbac8a878dba6c956f8 to your computer and use it in GitHub Desktop.
Magnifying Text Shader
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
| 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