在Jetpack Compose中创建CRT屏幕效果

本文译自「Creating a CRT Screen Effect in Jetpack Compose」,原文链接www.sinasamaki.com/creating-a-...,由sinasamaki发布于2025年11月7日。

CRT 显示器具有独特而怀旧的外观:模糊的边缘、扫描线和轻微的色彩溢出。让我们尝试使用 GraphicsLayer 和一些巧妙的图层技巧在 Jetpack Compose 中重现 这种效果。

GraphicsLayer

与上一篇文章一样,此效果的基础是 GraphicsLayer。它允许我们将内容绘制一次到屏幕外缓冲区。然后,我们可以以极低的性能开销多次使用不同的效果重新绘制它。

kotlin 复制代码
val graphicsLayer = rememberGraphicsLayer()

Box(Modifier.drawWithContent {
    graphicsLayer.record { 
        this@drawWithContent.drawContent() 
    }
}) {
    content()
}

一旦我们将内容记录到 graphicsLayer 中,就可以使用 drawLayer(graphicsLayer) 根据需要多次绘制它。

我喜欢在黑色背景上绘制色彩鲜艳、饱和度高的内容,并运用这种效果。以下是我们将应用此效果的基础可组合对象。

添加扫描线

为了模拟 CRT 显示器上的水平扫描线,我们将使用重复渐变。让我们将其放入一个扩展函数中,如下所示:

kotlin 复制代码
private fun DrawScope.drawScanLines(alpha: Float, blendMode: BlendMode) {
    val color = Colors.Black.copy(alpha = alpha)
    drawRect(
        brush = Brush.verticalGradient(
            0f to color,
            0.4f to color,
            0.4f to Colors.Transparent,
            1f to Colors.Transparent,
            tileMode = TileMode.Repeated,
            startY = 0f,
            endY = 10f,
        ),
        blendMode = blendMode
    )
    drawRect(
        brush = Brush.horizontalGradient(
            0f to color,
            0.1f to color,
            0.1f to Colors.Transparent,
            1f to Colors.Transparent,
            tileMode = TileMode.Repeated,
            startX = 0f,
            endX = 10f,
        ),
        blendMode = blendMode
    )
}

我们手动定义颜色停止点,以便在颜色之间形成清晰的边缘,然后将 tileMode 设置为 Repeated。这样,再加上较短的起点和终点,就能得到许多重复的平行线。

扩展函数还会接收我们所需的透明度和 BlendMode 参数。

然后,我们可以使用此函数在 graphicsLayer 上绘制扫描线。

kotlin 复制代码
.drawBehind {
    layer {
        drawLayer(graphicsLayer)
        drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)
    }
}

将混合模式设置为 DstOut 会从绘制的内容中"减去"我们的渐变,从而产生这种效果。

构建模糊图层

为了实现 CRT 屏幕常见的发光效果,我们将多次绘制 graphicsLayer 图层,每次绘制时分别设置不同的模糊半径、透明度和缩放比例。

kotlin 复制代码
val blurLayers = remember {
    listOf(
        Triple(1.dp, 0.2f, 1.02f to 1.03f),
        Triple(0.dp, .2f, 1f to 1f),
        Triple(1.dp, 0.9f, 1f to 1f),
        Triple(10.dp, 1f, 1f to 1f),
        Triple(40.dp, 1f, 1f to 1f),
    )
}

我们将使用一个 Triple 列表来存储每个图层的数据。该列表的顺序也定义了它们的绘制顺序。我建议你尝试调整这些值和顺序,以获得所需的效果。但这是我目前使用的方法。

kotlin 复制代码
blurLayers.forEach { (blur, alpha, scale) ->  
    Box(  
        Modifier  
            .matchParentSize()  
            .blur(blur, BlurredEdgeTreatment.Unbounded)  
            .graphicsLayer {  
                scaleX = scale.first  
                scaleY = scale.second  
                this.alpha = alpha  
            }  
            .drawBehind {  
                layer {  
                    drawLayer(graphicsLayer)  
                    drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)  
                }  
            }    
    )  
}

然后,我们使用列表中的值绘制每个图层。在 drawBehind 修改器上方,我们将图层大小设置为与父图层匹配,并应用模糊、缩放和透明度。请记住将模糊设置为 Unbounded,使其超出包含它的可组合对象的边界。

屏幕抖动

最后,我们来添加屏幕抖动效果,以模拟 CRT 显示器特有的抖动。我们可以通过创建一个 Offset 对象,并用 -1 到 1 之间的随机浮点值来更新它。

kotlin 复制代码
var shake by remember { mutableStateOf(Offset.Zero) }

LaunchedEffect(Unit) {
    while (true) {
        shake = Offset(
            Random.nextInt(-1, 1) * Random.nextFloat(),
            Random.nextInt(-1, 1) * Random.nextFloat(),
        )
        delay(32)
    }
}

这里只需在一个 while 循环中即可完成。可以调整延迟时间来控制闪烁的间隔频率。

kotlin 复制代码
modifier = modifier  
    .graphicsLayer {  
        translationX = shake.x  
        translationY = shake.y  
    }

然后可以使用修饰符来应用此偏移量。

整合所有功能

让我们将所有这些功能组合成一个可轻松使用的组合。它会接收 content 参数以及 flickerDelay 参数,后者用于控制闪烁频率。

kotlin 复制代码
@Composable  
fun CRTBox(  
    modifier: Modifier = Modifier,  
    flickerDelay: Int = 32,  
    content: @Composable () -> Unit,  
) {  
    var shake by remember { mutableStateOf(Offset.Zero) }  
  
    LaunchedEffect(Unit) {  
        while (flickerDelay > 0) {  
            shake = Offset(  
                Random.nextInt(-1, 1) * Random.nextFloat(),  
                Random.nextInt(-1, 1) * Random.nextFloat(),  
            )  
            delay(flickerDelay.toLong())  
        }  
    }  
  
    val graphicsLayer = rememberGraphicsLayer()  
  
    Box(  
        modifier = modifier  
            .graphicsLayer {  
                translationX = shake.x  
                translationY = shake.y  
            }  
    ) {  
        Box(Modifier.drawWithContent {  
            graphicsLayer.record { this@drawWithContent.drawContent() }  
        }) {  
            content()  
        }  
  
        val blurLayers = remember {  
            listOf(  
                Triple(5.dp, .3f, 1.02f to 1.03f),  
                Triple(0.dp, .8f, 1f to 1f),  
                Triple(1.dp, .9f, 1f to 1f),  
                Triple(10.dp, .6f, 1.001f to 1f),  
                Triple(40.dp, .7f, 1f to 1f),  
            )  
        }  
  
        blurLayers.forEach { (blur, alpha, scale) ->  
            Box(  
                Modifier  
                    .matchParentSize()  
                    .blur(blur, BlurredEdgeTreatment.Unbounded)  
                    .graphicsLayer {  
                        scaleX = scale.first  
                        scaleY = scale.second  
                        this.alpha = alpha  
                    }  
                    .drawBehind {  
                        layer {  
                            drawLayer(graphicsLayer)  
                            drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)  
                        }  
                    }            
            )  
        }  
    }}  
  
private fun DrawScope.layer(  
    bounds: Rect = size.toRect(),  
    block: DrawScope.() -> Unit  
) =  
    drawIntoCanvas { canvas ->  
        canvas.withSaveLayer(  
            bounds = bounds,  
            paint = Paint(),  
        ) { block() }  
    }  
  
private fun DrawScope.drawScanLines(alpha: Float, blendMode: BlendMode) {  
    val color = Colors.Black.copy(alpha = alpha)  
    drawRect(  
        brush = Brush.verticalGradient(  
            0f to color,  
            0.4f to color,  
            0.4f to Colors.Transparent,  
            1f to Colors.Transparent,  
            tileMode = TileMode.Repeated,  
            startY = 0f,  
            endY = 10f,  
        ),  
        blendMode = blendMode  
    )  
    drawRect(  
        brush = Brush.horizontalGradient(  
            0f to color,  
            0.1f to color,  
            0.1f to Colors.Transparent,  
            1f to Colors.Transparent,  
            tileMode = TileMode.Repeated,  
            startX = 0f,  
            endX = 10f,  
        ),  
        blendMode = blendMode  
    )  
}

然后,你可以像使用其他可组合组件一样使用它:

kotlin 复制代码
CRTBox {
    Text("GAME OVER")
}

Sweeper 更新

如果你想查看实际效果,请查看最新的 Sweeper 更新,该更新使用 CRT 效果创建了一个令人毛骨悚然的万圣节主题。

play.google.com/store/apps/...

apps.apple.com/us/app/swee...

感谢阅读,祝你好运!

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
霸王大陆14 分钟前
《零基础学 PHP:从入门到实战》教程-模块四:数组与函数-2
android·开发语言·php
v***431725 分钟前
springboot3整合knife4j详细版,包会!(不带swagger2玩)
android·前端·后端
h***047740 分钟前
MySQL 的 INSERT(插入数据)详解
android·数据库·mysql
w***42444 分钟前
SpringSecurity的配置
android·前端·后端
s***35301 小时前
SpringMVC新版本踩坑[已解决]
android·前端·后端
不会写代码的猴子1 小时前
Android16重磅更新:安全与性能全面升级
android
霸王大陆1 小时前
《零基础学 PHP:从入门到实战》教程-模块四:数组与函数-1
android·开发语言·php
e***19352 小时前
MySQL-触发器(TRIGGER)
android·数据库·mysql
不会写代码的猴子2 小时前
Android16
android
@游子2 小时前
SQL注入之文件读写(四)
android·数据库·sql