本文译自「Deform the canvas」,原文链接medium.com/@off.mind.b...,由Alex Volkov发布于2025年8月2日。

大家好!使用着色器时,我总是着迷于如何轻松创建看起来精致而昂贵的效果。今天的文章就是另一个很好的例子。最近,我的 Telegram 频道的一位订阅者问我,如何在用手指拖动视图时创建拉伸效果。自然而然地,我立刻想到了用着色器来实现。现在结果已经出来了,我很高兴分享我的构建过程。和往常一样,这篇文章分为几个部分:第一部分展示了 Compose 的简单设置,第二部分逐步讲解着色器,最后,我们将在 Compose 中添加一些小细节,这些细节实际上构成了 90% 的视觉效果------尽管这可能感觉有点不公平。
最终效果如下:

在深入探讨之前,我想提醒你,我并没有为所有效果创建教程。不过,所有效果都可以在我的 GitHub 上找到。你还可以在我的 Telegram 频道 中找到视频、新效果的公告以及问题的解答。期待在那里见到你!
让我们从最基本的 Compose 设置开始。在本教程中,我不会包含任何背景图片或额外的样式。我们会尽可能地保持简洁。我们只需要一个应用着色器的容器、一些着色器本身的参数,以及一个放置在着色器盒内的菜单或列表的基本模拟。以下是我们开始所需的基本设置:
kotlin
val shader = remember { RuntimeShader(runtimeShader) }
var targetPercentage by remember { mutableFloatStateOf(0f) }
val percentage = animateFloatAsState(
targetValue = targetPercentage,
animationSpec = tween(
durationMillis = 1000,
easing = ElasticOutEasing)
)
val pressed = remember { mutableStateOf(false) }
var fingerPosition by remember { mutableStateOf(Offset.Zero) }
var fingerStartPosition by remember { mutableStateOf(Offset.Zero) }Box(
modifier = Modifier
.onSizeChanged { size ->
shader.setFloatUniform(
"resolution", size.width.toFloat(), size.height.toFloat()
)
}
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull() ?: continue
pressed.value = change.pressed
if (change.pressed) {
if (change.previousPressed.not()) {
fingerStartPosition = change.position
}
targetPercentage = 1f
fingerPosition = change.position - fingerStartPosition
} else {
targetPercentage = 0f
}
event.changes.forEach { it.consume() }
}
}
}
.graphicsLayer {
if (pressed.value) {
shader.setFloatUniform("percentage", 1f)
} else {
shader.setFloatUniform("percentage", percentage.value)
}
shader.setFloatUniform("touch", fingerPosition.x, fingerPosition.y)
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
}
.clickable {
targetPercentage = if (targetPercentage == 0f) 1f else 0f
}
) {
//... 这里是下拉列表本身或任何其他可以通过拖动拉伸的可组合项
}
shader - 这是着色器本身,我们将在第二部分中编写它。
targetPercentage 、percentage 和 pressed - 这些控制传递给着色器的效果强度。我们需要它们来为用户抬起手指后的"反弹"效果添加动画效果。思路很简单:当有活动触摸时,强度为 1(最大值),当用户抬起手指时,我们将其动画化为 0。我们将使用自定义的 ElasticOutEasing 来代替常规的线性动画,我将在最后一节中对其进行描述。
fingerPosition 和 fingerStartPosition - 我们只将增量向量传递给着色器,这意味着我们关心方向和强度(向量长度)。拉伸始终从中心开始(我发现这比从精确的触摸点开始更美观)。因此,我们存储两个值,并将它们的差值传递给着色器。
接下来,在 onSizeChanged 中,我们将视图大小传递给着色器。在 pointerInput 中,我们跟踪拖动并计算增量向量。最后,在 graphicsLayer 中,我们将增量和效果强度传递给着色器。
接下来是包含将要拉伸的视图示例的代码块。它实际上只是一段基本的占位符代码,所以我认为不值得详细分析。我将其包含在这里只是为了方便------这样你就可以复制它,而不必担心自己编写它:
kotlin
val actions = listOf("Cut", "Copy", "Paste", "Edit")
Column(
modifier = Modifier
.width(250.dp)
.background(Color(0x8843484C), RoundedCornerShape(8.dp))
.border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.padding(8.dp)
) {
actions.forEach { action ->
Text(
text = action,
color = Color.White.copy(alpha = 0.8f),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
)
if(action != "Edit") {
HorizontalDivider()
}
}
}
Compose 的设置就到这里!让我们开始编写着色器吧!
我们从最简单的着色器开始。下面是输出输入的极简代码,无需进行任何重大更改。我唯一添加的是一个辅助方法,方便以后修改。它叫做 GetImageTexture 。从技术上讲,你可以使用 image.eval(fragCoord) 返回所有未更改的图像,但是一旦在重新映射图像之前开始修改坐标,就需要处理宽高比和图像平移------这基本上就是使用以画布中心为中心的坐标系。这个方法可以处理这些问题。你只需向它传递标准化的 UV 坐标、自定义中心点和分辨率,它就会返回正确的结果。如果你现在使用这些设置运行着色器,你应该会看到与不使用着色器时完全相同的图像。这是一个好兆头!
kotlin
private val runtimeShader = """
uniform shader image;
uniform float2 resolution;
uniform float percentage;
uniform float2 touch;
vec4 GetImageTexture(vec2 p, vec2 pivot, vec2 r) {
p.x /= r.x / r.y;
p += pivot;
p *= r;
return image.eval(p);
}
half4 main(float2 fragCoord) {
float ratio = resolution.x / resolution.y;
float2 uv = fragCoord / resolution - 0.5;
uv.x *= ratio;
vec4 img = GetImageTexture(uv, vec2(0.5), resolution);
return half4(img.rgb, img.a);
}""".trimIndent()
你在这里看到的应该非常熟悉------几乎所有我的着色器都是这样启动的,说实话,大多数其他着色器也是如此。我再重复一遍:我们获取 fragCoord,它是每个像素相对于视图画布的位置。在此基础上,我们创建一个标准化的 UV 坐标系,根据宽高比进行调整,并偏移 0.5------这将原点置于视图的正中央。并非每个人都这样做,但我觉得这样更方便。它可以"免费"地实现很多效果,比如镜像行为,因为我们只围绕中心计算一次所有内容,而不是分别处理每条边。下面是我们画布的示意图。

好了,现在该了解一下我们想要实现的效果了。我们需要从中心(在当前实现中)沿着特定向量拉伸画布,同时保持其余部分不变。那么,如何拉伸画布呢?其实,最简单的方法就是乘以一个数字!让我们测试一下------添加一个比例变量并尝试一下。最后,我们将 UV 乘以这个比例向量。
glsl
vec2 scale = vec2(0.8, 1.2);
uv *= vec2(scale.x, scale.y);vec4 img = GetImageTexture(uv, vec2(0.5), resolution);

尝试使用不同的scale值来更好地感受效果。正如你所注意到的------使用1不会改变任何值,因为乘以1后值保持不变。小于1的值会拉伸图像,而大于1的值会压缩图像。记住这一点------我们稍后会计算缩放强度并将其从1中减去,这样当缩放为零时,效果也为零,这意味着我们乘以了一个单位向量。
太好了,现在让我们将触摸位置添加到计算中。简单回顾一下------我们从触摸中接收了一个位移向量。因此,当手指触摸屏幕时,向量为 (0, 0)。如果我们将手指向右移动 20 像素,则得到 (20, 0)。我们首先需要对这个向量进行归一化,并将其乘以宽高比。
glsl
vec2 nMouse = touch / resolution;
nMouse.x *= ratio;
如果我们将手指向左移动 20 像素,则会得到一个 (-20, 0) 的向量。因此,我们不会直接使用这个向量作为比例,而是取其长度。它看起来会像这样:
glsl
vec2 scale = vec2(length(nMouse.x), length(nMouse.y));
记住,没有效果意味着乘以 1,而不是乘以 0?这就是为什么在使用比例向量时,我们在应用之前先将其从 1 中减去:
glsl
uv *= vec2(1.0) - scale;
如果一切设置正确,我们现在应该能够控制沿两个轴的拉伸。大致如下所示:

一切看起来都很好------现在我们只需要在一个方向上应用拉伸。为此,我们可以使用向量的点积。原理很简单:如果向量指向同一方向,则此运算返回正值;如果向量指向相反方向,则返回负值。我强烈建议你在专用资源上阅读更多关于此运算(以及其他向量运算)的内容,因为线性代数是着色器中一切的基础。在这里,我将向你展示它在实践中的工作原理!
glsl
float influence = dot(normalize(nMouse), uv);
scale *= influence;

如你所见,效果已经近乎完美!但我不太喜欢另一侧在另一个方向上变形(即被压缩)。这是点积的副作用------当它返回负值时,缩放比例也会变为负值,画布的这一部分会被挤压。我希望保持视图的这一部分不变,所以我添加了一个从零到定义最大值的线性插值。
glsl
influence = smoothstep(0., 1.5, influence);
最后一步是乘以"效果强度",我们通过百分比参数传递该强度(也许这不是最好的命名,但我习惯这样称呼它)。这不会改变拖动过程中的行为,但它可以让我们在拖动结束后平滑地将视图恢复到原始状态。
glsl
scale *= percentage;
就是这样!着色器已准备就绪。以下是完整代码------尽管最终的视觉效果看起来丰富而复杂,但它简洁明了。在最终版本中,我还限制了 x 轴和 y 轴上的最大缩放值。你可以根据需要随意调整这些值。
glsl
uniform shader image;
uniform float2 resolution;
uniform float percentage;
uniform float2 touch;
vec4 GetImageTexture(vec2 p, vec2 pivot, vec2 r) {
p.x /= r.x / r.y;
p += pivot;
p *= r;
return image.eval(p);
} half4 main(float2 fragCoord) {
float ratio = resolution.x / resolution.y;
float2 uv = fragCoord / resolution - 0.5;
uv.x *= ratio;
vec2 nMouse = touch / resolution;
nMouse.x *= ratio;
vec2 scale = vec2(min(length(nMouse.x), 0.3), min(length(nMouse.y), 0.4));
float influence = dot(normalize(nMouse), uv);
influence = smoothstep(0., 1.5, influence);
scale *= influence;
scale *= percentage;
uv *= vec2(1.0) - scale;
vec4 img = GetImageTexture(uv, vec2(0.5), resolution);
return half4(img);
}
现在来看看我从 Compose 中留下的部分------ElasticOutEasing。在 Compose 中创建动画时,你可以使用内置的缓动函数,也可以定义自己的缓动函数。我使用了一个简单的弹性缓动函数示例,如下所示:
kotlin
val ElasticOutEasing = Easing { t ->
val p = 0.3f
if (t == 0f || t == 1f) t
else {
val s = p / 4
2f.pow(-10f * t) * sin((t - s) * (2f * PI.toFloat()) / p) + 1f
}
}
此缓动函数在动画结束时创建类似弹跳的效果。它一开始很快,然后略微超过目标并稳定下来,模仿弹簧的行为。其核心思想是将指数衰减 (2^-10t) 与正弦波相结合,以模拟弹性运动。它在起始 (0) 和结束 (1) 处返回精确值,但在两者之间添加了一个抖动。
你也可以使用常规的线性缓动,但结果看起来会更加平淡。这正是我在开头提到的------这个小细节为整体效果的流畅度和令人满意的体验贡献了 90%!
感谢你的阅读!如果你觉得我的实验有趣且我的解释对你有帮助,欢迎加入我的 Telegram 频道 或在 Twitter (X) 上关注我。这个项目只是我的一个爱好,说实话,我的动力很大程度上取决于收到的反馈------所以我非常高兴在频道里见到你。如果你愿意的话,请将这篇文章分享到你的社交媒体上,我将不胜感激。祝你撸码愉快!
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!