Magic Wavy Button in Jetpack Compose. Unleashed the Power of AGSL Shaders in Android.

Kappdev
7 min readJan 26, 2024

--

Welcome👋! In this article, we’ll create a dynamic Wavy Button in Jetpack Compose. Watch as animated waves ripple across the button when pressed under specific conditions. Not just for buttons! This special tool works with any Compose view. It’s a creative spark that opens up lots of possibilities! 🌟. Let’s dive in and explore the magic 🔮!

The shader

To achieve this stunning effect on Android, we'll use a custom AGSL (Android Graphics Shader Language) shader. Let’s explore further.

Unfortunately, AGSL is only supported by Android 13 (API 33) and above.😔

To implement the shader script in the Compose view, it must be written as a String. Thus, we create a string constant, WavyShader.

const val WavyShader = """
// The shader script goes here...
"""

Alright, let’s dive into the script now! 💻

// Shader Input
uniform float progress; // Animation progress
uniform float time; // Time
uniform float2 size; // View size
uniform shader composable; // Input texture

// Constants
const float speed = 10;
const float frequency = 8;
const float sharpness = 0.99;
const float depth = 0.2;

// Target animation variables
const float targetWaveAmplitude = 6;
const float targetYStretching = 4.5;

// Animation variables
float waveAmplitude = 0;
float yStretching = 0;

// Distortion Constants
const float margin = 0.4;
const float waveFrequency = 0.02;

// Function to distort the coordinate based on wave deformations
float2 distortCoord(in float2 originalCoord) {
// Normalize the coordinates to [-1;1], with 0 at the center
float2 uv = originalCoord / size * 2 - 1;

// Calculate smoothstep values for the x and y coordinates
float edgeX = 1 - smoothstep(0.0, margin, abs(uv.x)) * smoothstep(1.0 - margin, 1.0, abs(uv.x));
float edgeY = 1 - smoothstep(0.0, margin, abs(uv.y)) * smoothstep(1.0 - margin, 1.0, abs(uv.y));

// Combine the smoothstep values to create a smooth margin
float edge = min(edgeX, edgeY);

// Calculate the wave distortion offset based on the length of the distorted coordinate
float waveOffset = sin(length(originalCoord) * waveFrequency + time * speed);

// Apply the wave distortion to the fragment coordinate
return originalCoord + (waveOffset * waveAmplitude * edge);
}

float4 main(in float2 fragCoord) {
// Update animation variables based on the progress
waveAmplitude = targetWaveAmplitude * progress;
yStretching = targetYStretching * progress;

// Evaluate the Composable shader at the distorted coordinate
float2 distortedCoord = distortCoord(fragCoord);
float4 baseColor = composable.eval(distortedCoord);

// Normalize the coords
float2 uv = fragCoord / size;

// Center and stretch the UV coordinates
uv -= 0.5;
uv *= float2(2, yStretching);

// Calculate y-coordinate
float y = sqrt(1 - uv.x * uv.x * uv.x * uv.x);

// Add dynamic offset based on time
float offset = sin(frequency * uv.x + time * speed) * depth;

// Calculate upper and lower y-coordinates with offset
float upperY = y + offset;
float lowerY = -y + offset;

// Calculate edge and mid values for smoothstep operation
float edge = abs(upperY - lowerY);
float mid = (upperY + lowerY) / 2;

// Apply smoothstep to create the final color
return baseColor * smoothstep(edge, edge * sharpness, abs(uv.y - mid));
}

Shader Inputs

The shader incorporates several input parameters:

  • composable ➜ Represents our Jetpack Compose view, providing access to pixel presentation.
  • size ➜ Denotes the size of the rendering area (Compose view).
  • progress ➜ Indicates the progress of the enter/exit animation, with values ranging from 0 to 1.
  • time ➜ A dynamic value used to animate the shader.

Shader Constants

These are essential constants that control the visual aspects of the effect. Adjust them to achieve your desired output:

  • speed ➜ Determines the speed of the waves; higher values result in faster movement.
  • frequency ➜ Sets the frequency of the dynamic animation, influencing the number of oscillations in the waves.
  • sharpness ➜ Governs the sharpness of the edges; lower values lead to smoother transitions.
  • depth ➜ Defines the depth of the oscillations in the wave; higher values result in greater depth.

Distortion Constants

The shader combines two effects: one creates a wavy shape, and the other introduces a distortion effect on pixels.

  • margin ➜ Specifies padding from the edges in UV coordinates for the distortion effect, preventing distortion at the edges.
  • waveFrequency ➜ Sets the frequency of the distortion waves.
  • targetWaveAmplitude ➜ Defines the amplitude of the oscillations in the distortion waves.

These constants can easily be transformed into uniforms, allowing dynamic control of animation appearance directly from the Composable.

Modifier function

In this section, we’ll delve into the application of the shader script to the Compose view.

Let’s start by examining the function signature.

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun Modifier.onPressWavy(
interactionSource: InteractionSource,
releaseDelay: Long = 500,
spec: AnimationSpec<Float> = tween(durationMillis = 400),
confirmWaving: () -> Boolean = { true }
) = composed {
// Implementation...
}

The @RequiresApi(Build.VERSION_CODES.TIRAMISU) annotation indicates that the function requires a minimum Android API level of 33 (TIRAMISU).

Parameters breakdown

  • interactionSource ➜ Represents the source of user interactions, crucial for detecting composable presses.
  • releaseDelay ➜ Specifies the delay (in milliseconds) after releasing the press before the animation stops.
  • spec ➜ Defines an enter/exit* animation spec.
  • confirmWaving ➜ A lambda function determines whether the wavy animation should be confirmed.

* Enter/exit refers to the animation played as the shader transitions from idle to running and then back to idle.

Implementation

Now, let’s explore the implementation of the function.

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun Modifier.onPressWavy(
// Paramaters...
) = composed {
// Initialize a runtime shader with the WavyShader and state variables
val shader = remember { RuntimeShader(WavyShader) }
var time by remember { mutableStateOf(0f) }
var playAnimation by remember { mutableStateOf(false) }

// Collect the state of whether the composable is currently pressed
val isPressed by interactionSource.collectIsPressedAsState()

// Animate the progress of the enter/exit animation
val animationProgress by animateFloatAsState(
targetValue = if (playAnimation) 1f else 0f,
animationSpec = spec,
label = "Wavy Animation Progress"
)

// Coroutine to simulate frame updates for the wavy animation
LaunchedEffect(playAnimation) {
while (playAnimation) {
delay(16) // Delay to simulate frame rate, adjust as needed
time += 0.016f // Increase time by 0.016 seconds (60 FPS simulation)
}
}

// Coroutine to handle user interaction and start/stop the animation
LaunchedEffect(isPressed) {
if (isPressed && confirmWaving()) {
playAnimation = true
} else if (!isPressed && playAnimation) {
delay(releaseDelay)
playAnimation = false
}
}

this
// Set the shader's uniform values based on the composable's size
.onSizeChanged { size ->
shader.setFloatUniform(
"size",
size.width.toFloat(),
size.height.toFloat()
)
}
// Apply graphics layer with clipping and runtime shader effect
.graphicsLayer {
clip = true

// Set shader parameters for time and animation progress
shader.setFloatUniform("time", time)
shader.setFloatUniform("progress", animationProgress)

// Apply the runtime shader
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "composable")
.asComposeRenderEffect()
}
}

Congratulations🥳! We’ve successfully built it👏. For the complete code implementation, you can access it on GitHub Gist🧑‍💻. In the next section, we’ll explore the usage of the modifier.

Advertisement

Are you learning a foreign language and struggling with new vocabulary? Then, I strongly recommend you check out this words-learning app, which will make your journey easy and convenient!

WordBook

Usage

Let’s create a simple example with a TextField and a Button. When the button is pressed and the TextField is empty, we’ll unleash a captivating animation. Otherwise, the button will gracefully carry out its normal behavior.

// Get the current context
val context = LocalContext.current

// Define mutable states for the entered text and error tracking
var text by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }

// Create a MutableInteractionSource to handle button interactions
val interactionSource = remember { MutableInteractionSource() }

TextField(
value = text,
isError = isError,
onValueChange = { text = it },
label = {
Text("Name")
},
placeholder = {
Text("Enter your name")
}
)

// Spacer to add vertical space between TextField and Button
Spacer(Modifier.height(32.dp))

Button(
onClick = {
// Display a Toast message if there's no error
if (!isError) {
Toast.makeText(context, "Valid String", Toast.LENGTH_SHORT).show()
}
},
// Apply the wavy animation modifier to the Button
modifier = Modifier.onPressWavy(interactionSource) {
// Check if the text is empty and update the error state accordingly
isError = text.isEmpty()
isError
},
interactionSource = interactionSource,
) {
Text(text = "Press ME!", fontSize = 24.sp)
}

Output:

You might also like 👇

Thank you for reading this article!❤️ I hope you’ve found it enjoyable and valuable. Feel free to show your appreciation by hitting the clap👏 if you liked it or follow Kappdev for more exciting articles😊.

Happy coding!

--

--

Kappdev

💡 Curious Explorer 🧭 Kotlin and Compose enthusiast 👨‍💻 Passionate about self-development and growth ❤️‍🔥 Push your boundaries 🚀