How to Use Render Effects in Jetpack Compose for Stunning Visuals

Explore the use of render effects in Jetpack Compose for captivating visual enhancements in your Android apps.
Oct 31 2023 · 9 min read

Background

Jetpack Compose provides a wide range of tools and components to build engaging UIs, and one of the lesser-known gems in Compose is the RenderEffect. 

In this blog post, we'll explore RenderEffect by creating some cool examples with a rendering effect.

What is RenderEffect?

RenderEffect allows you to apply visual effects to your UI components. These effects can include blurs, custom shaders, or any other visual transformations you can imagine. However, it's available for API 31 and above.

In our example, we'll use RenderEffect to create a blur and shader effect for our expandable floating button and some bonus components.

What we’ll implement in this blog?

The source code is available on GitHub

Sponsored

Get Started…

The BlurContainer

In the first stage, let’s start by introducing the ‘BlurContainer.’ This unique component adds an extra layer of visual elegance and captivation to our user interface, creating a stunning visual effect.

It houses a custom blur modifier that takes our rendering effect to the next level.

@Composable
fun BlurContainer(
    modifier: Modifier = Modifier,
    blur: Float = 60f,
    component: @Composable BoxScope.() -> Unit,
    content: @Composable BoxScope.() -> Unit = {},
) {
    Box(modifier, contentAlignment = Alignment.Center) {
        Box(
            modifier = Modifier
                .customBlur(blur),
            content = component,
        )
        Box(
            contentAlignment = Alignment.Center
        ) {
            content()
        }
    }
}

fun Modifier.customBlur(blur: Float) = this.then(
    graphicsLayer {
        if (blur > 0f)
            renderEffect = RenderEffect
                .createBlurEffect(
                    blur,
                    blur,
                    Shader.TileMode.DECAL,
                )
                .asComposeRenderEffect()
    }
)
  • The ‘customBlur’ modifier extension takes a blur parameter, which specifies the intensity of the blur effect.
  • It is used to apply a graphicsLayer to the Composable, which, in turn, applies a blur effect using RenderEffect.createBlurEffect. The graphicsLayer is used to apply rendering effects to the Composable.

Here is how the blur effect looks:

With this modifier, we can easily add blur effects to any Composable by chaining it to your existing modifiers.

Applying Render Effect to Parent Container

For this, we will use a custom shader — RuntimeShader, and Jetpack Compose's graphicsLayer to achieve the desired visual effect in the parent container.

Before we dive into how the rendering effect is applied, let’s understand how the RuntimeShader is initialized.

@Language("AGSL")
const val ShaderSource = """
    uniform shader composable;
    
    uniform float visibility;
    
    half4 main(float2 cord) {
        half4 color = composable.eval(cord);
        color.a = step(visibility, color.a);
        return color;
    }
"""

val runtimeShader = remember {
    RuntimeShader(ShaderSource)
}

In this code snippet, we create an instance of RuntimeShader. The remember function ensures that the shader is only initialized once, preventing unnecessary overhead. We pass our custom shader source code (ShaderSource) to the RuntimeShader constructor.

Our ShaderSource is a crucial part of the rendering effect. It's written in a shader language called AGSL (Android Graphics Shading Language). Let's take a closer look at it:

  • uniform shader composable: This line declares a uniform shader variable named "composable". This variable is used to sample the colors of the Composable elements if we want to apply the rendering effect to.
  • uniform float visibility: We declare a uniform float variable called "visibility". This variable controls the intensity of the shader effect by specifying a threshold.
  • half4 main(float2 cord): The main function is the entry point of the shader. It takes a 2D coordinate (cord) and returns a color in the form of half4, which represents a color with red, green, blue, and alpha components.
  • half4 color = composable.eval(cord): Here, we sample the color from the "composable" shader uniform variable at the given coordinate.
  • color.a = step(visibility, color.a): We apply the shader effect by setting the alpha component (color.a) to 0 or 1 based on the "visibility" threshold.
  • return color: Finally, we return the modified color.

Check out AGSL Shader in the JetLagged app from compose-samples.

Applying the Rendering Effect

With our RuntimeShader and ShaderSource ready, we can now apply the rendering effect using the graphicsLayer:

Box(
    modifier
        .graphicsLayer {
            runtimeShader.setFloatUniform("visibility", 0.2f)
            renderEffect = RenderEffect
                .createRuntimeShaderEffect(
                    runtimeShader, "composable"
                )
                .asComposeRenderEffect()
        },
    content = content,
)

Here’s a breakdown of how this works:

  • runtimeShader.setFloatUniform("visibility", 0.2f): We set the "visibility" uniform variable in our shader to control the intensity of the effect. In this case, we set it to 0.2f, but you can adjust this value to achieve your desired effect.
  • renderEffect = RenderEffect.createRuntimeShaderEffect(...): We create a RenderEffect using the createRuntimeShaderEffect method. This method takes our runtimeShader and the name "composable," which corresponds to the shader variable in our ShaderSource.
  • .asComposeRenderEffect(): We convert the RenderEffect into a Compose-friendly format using asComposeRenderEffect().

By applying this rendering effect within the graphicsLayer, we achieve the shader effect on the UI components contained within the Box.

To bring all of these elements together and apply our rendering effect seamlessly, we will create a ShaderContainer composable like this:

@Language("AGSL")
const val Source = """
    uniform shader composable;
    
    uniform float visibility;
    
    half4 main(float2 cord) {
        half4 color = composable.eval(cord);
        color.a = step(visibility, color.a);
        return color;
    }
"""

@Composable
fun ShaderContainer(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit,
) {
    val runtimeShader = remember {
        RuntimeShader(Source)
    }
    Box(
        modifier
            .graphicsLayer {
                runtimeShader.setFloatUniform("visibility", 0.2f)
                renderEffect = RenderEffect
                    .createRuntimeShaderEffect(
                        runtimeShader, "composable"
                    )
                    .asComposeRenderEffect()
            },
        content = content
    )
}

Here is the visual effect of BlurContainer wrapped inside ShaderContainer:

Now that we’ve successfully built the foundation for our rendering effect with the ShaderContainer and BlurContainer, it's time to bring it all together by crafting the ExtendedFabRenderEffect. This Composable will be the centerpiece of our expandable floating button with dynamic rendering effects.

ExtendedFabRenderEffect

The ExtendedFabRenderEffect composable is responsible for orchestrating the entire user interface, animating the button's expansion, and handling the rendering effect. Let's dive into how it works and how it creates a visually appealing user experience.

Smooth Animation

Creating a smooth and fluid animation is essential for a polished user experience. We apply alpha animation to achieve this:

The alpha animation manages the transparency of the buttons. When expanded is true, the buttons become fully opaque; otherwise, they fade out. Like the offset animation, we use the animateFloatAsState function with appropriate parameters to ensure smooth transitions.

var expanded: Boolean by remember {
    mutableStateOf(false)
}

val alpha by animateFloatAsState(
  targetValue = if (expanded) 1f else 0f,
  animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
  label = ""
)

Combining Effects

Now, we combine the rendering effect, the ShaderContainer, with our buttons to create a coherent user interface. Inside the ShaderContainer, we place several ButtonComponent Composables, each representing a button with a specific icon and interaction.

ShaderContainer(
    modifier = Modifier.fillMaxSize()
) {

    ButtonComponent(
        Modifier.padding(
            paddingValues = PaddingValues(
                bottom = 80.dp
            ) * FastOutSlowInEasing
                .transform((alpha))
        ),
        onClick = {
            expanded = !expanded
        }
    ) {
        Icon(
            imageVector = Icons.Default.Edit,
            contentDescription = null,
            tint = Color.White,
            modifier = Modifier.alpha(alpha)
        )
    }

    ButtonComponent(
        Modifier.padding(
            paddingValues = PaddingValues(
                bottom = 160.dp
            ) * FastOutSlowInEasing.transform(alpha)
        ),
        onClick = {
            expanded = !expanded
        }
    ) {
        Icon(
            imageVector = Icons.Default.LocationOn,
            contentDescription = null,
            tint = Color.White,
            modifier = Modifier.alpha(alpha)
        )
    }

    ButtonComponent(
        Modifier.padding(
            paddingValues = PaddingValues(
                bottom = 240.dp
            ) * FastOutSlowInEasing.transform(alpha)
        ),
        onClick = {
            expanded = !expanded
        }
    ) {
        Icon(
            imageVector = Icons.Default.Delete,
            contentDescription = null,
            tint = Color.White,
            modifier = Modifier.alpha(alpha)
        )
    }

    ButtonComponent(
        Modifier.align(Alignment.BottomEnd),
        onClick = {
            expanded = !expanded
        },
    ) {
        val rotation by animateFloatAsState(
            targetValue = if (expanded) 45f else 0f,
            label = "",
            animationSpec = tween(1000, easing = FastOutSlowInEasing)
        )
        Icon(
            imageVector = Icons.Default.Add,
            contentDescription = null,
            modifier = Modifier.rotate(rotation),
            tint = Color.White
        )
    }
}

With this setup, the ShaderContainer acts as a backdrop for our buttons and the rendering effect is seamlessly applied to the buttons through the ButtonComponent Composables. The alpha modifier ensures that the buttons become visible or invisible based on the expansion state, creating a polished and dynamic user interface.

ButtonComponent Anatomy

The ButtonComponent is designed to encapsulate each button within the expandable menu. It offers the flexibility to customize the button's appearance and behavior.

Here’s how the ButtonComponent is structured:

@Composable
fun BoxScope.ButtonComponent(
    modifier: Modifier = Modifier,
    background: Color = Color.Black,
    onClick: () -> Unit,
    content: @Composable BoxScope.() -> Unit
) {
    // Applying the Blur Effect with the BlurContainer
    BlurContainer(
        modifier = modifier
            .clickable(
                interactionSource = remember {
                    MutableInteractionSource()
                },
                indication = null,
                onClick = onClick,
            )
            .align(Alignment.BottomEnd),
        component = {
            Box(
                Modifier
                    .size(40.dp)
                    .background(color = background, CircleShape)
            )
        }
    ) {
        // Content (Icon or other elements) inside the button
        Box(
            Modifier.size(80.dp),
            content = content,
            contentAlignment = Alignment.Center,
        )
    }
}

And that’s it, we have achieved the desired effect from the above code!

TextRenderEffect

The heart of the TextRenderEffect is the dynamic text display. We’ll use a list of motivating phrases and quotes that will be presented to the user. These phrases will include sentiments like "Reach your goals," "Achieve your dreams," and more.

val animateTextList =
    listOf(
        "\"Reach your goals\"",
        "\"Achieve your dreams\"",
        "\"Be happy\"",
        "\"Be healthy\"",
        "\"Get rid of depression\"",
        "\"Overcome loneliness\""
    )

We will create textToDisplay state variable to hold and display these phrases, creating an animated sequence.

Animating the Text

To make the text display engaging, we will utilize a couple of key animations:

  1. Blur Effect: We will apply a blur effect to the text. The blur value animates from 0 to 30 and back to 0, using a linear easing animation. This creates a subtle and mesmerizing visual effect that enhances the text's appearance.
  2. Text Transition: We will use the LaunchedEffect to cycle through the list of phrases, displaying each for a certain duration. When the textToDisplay changes, an animation scaleIn occurs, presenting the new text with a scale-in effect, and as it transitions out, a scaleOut effect is applied. This provides a visually pleasing way to introduce and exit the text.

Complete Integration with ShaderContainer

@Composable
fun TextRenderEffect() {

    val animateTextList =
        listOf(
            "\"Reach your goals\"",
            "\"Achieve your dreams\"",
            "\"Be happy\"",
            "\"Be healthy\"",
            "\"Get rid of depression\"",
            "\"Overcome loneliness\""
        )

    var index by remember {
        mutableIntStateOf(0)
    }

    var textToDisplay by remember {
        mutableStateOf("")
    }
    
    val blur = remember { Animatable(0f) }

    LaunchedEffect(textToDisplay) {
        blur.animateTo(30f, tween(easing = LinearEasing))
        blur.animateTo(0f, tween(easing = LinearEasing))
    }

    LaunchedEffect(key1 = animateTextList) {
        while (index <= animateTextList.size) {
            textToDisplay = animateTextList[index]
            delay(3000)
            index = (index + 1) % animateTextList.size
        }
    }

    ShaderContainer(
        modifier = Modifier.fillMaxSize()
    ) {
        BlurContainer(
            modifier = Modifier.fillMaxSize(),
            blur = blur.value,
            component = {
                AnimatedContent(
                    targetState = textToDisplay,
                    modifier = Modifier
                        .fillMaxWidth(),
                    transitionSpec = {
                        (scaleIn()).togetherWith(
                            scaleOut()
                        )
                    }, label = ""
                ) { text ->
                    Text(
                        modifier = Modifier
                            .fillMaxWidth(),
                        text = text,
                        style = MaterialTheme.typography.headlineLarge,
                        color = MaterialTheme.colorScheme.onPrimaryContainer,
                        textAlign = TextAlign.Center
                    )
                }
            }
        ) {}
    }
}

ImageRenderEffect

Our exploration of RenderEffect in Jetpack Compose continues with the intriguing ImageRenderEffect. This Composable takes image rendering to a new level by introducing dynamic image transitions and captivating rendering effects. Let's delve into how it's constructed and how it enhances the visual experience.

Dynamic Image Transitions

The core of the ImageRenderEffect lies in its ability to transition between images in a visually appealing way. To demonstrate this, we'll set up a basic scenario where two images, ic_first and ic_second, will alternate on a click event.

var image by remember {
    mutableIntStateOf(R.drawable.ic_first)
}

The image state variable holds the currently displayed image, and with a simple button click, users can switch between the two.

Crafting Engaging Effects

  • Blur Effect: Just like in our previous examples, we apply a blur effect to the images. The blur value animates from 0 to 100 and back to 0, creating a mesmerizing visual effect that enhances the image transition.
val blur = remember { Animatable(0f) }
LaunchedEffect(image) {
    blur.animateTo(100f, tween(easing = LinearEasing))
    blur.animateTo(0f, tween(easing = LinearEasing))
}
  • Image Transition: The heart of the image transition is the AnimatedContent Composable. It handles the smooth transition between images, combining a fadeIn and scaleIn effect for the image entering the scene and a fadeOut and scaleOut effect for the image exiting the scene.
AnimatedContent(
    targetState = image,
    modifier = Modifier.fillMaxWidth(),
    transitionSpec = {
        (fadeIn(tween(easing = LinearEasing)) + scaleIn(
            tween(1_000, easing = LinearEasing)
        )).togetherWith(
            fadeOut(
                tween(1_000, easing = LinearEasing)
            ) + scaleOut(
                tween(1_000, easing = LinearEasing)
            )
        )
    }, label = ""
) { image ->
    Image(
        painter = painterResource(id = image),
        modifier = Modifier.size(200.dp),
        contentDescription = ""
    )
}

Seamless Integration with ShaderContainer

Just like our previous examples, the ImageRenderEffect is integrated within a ShaderContainer. This allows us to blend the image transitions and rendering effects, creating a captivating and immersive visual experience.

@Composable
fun ImageRenderEffect() {

    var image by remember {
        mutableIntStateOf(R.drawable.ic_first)
    }

    val blur = remember { Animatable(0f) }

    LaunchedEffect(image) {
        blur.animateTo(100f, tween(easing = LinearEasing))
        blur.animateTo(0f, tween(easing = LinearEasing))
    }

    Column(
        modifier = Modifier
            .wrapContentSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {

        ShaderContainer(
            modifier = Modifier
                .animateContentSize()
                .clipToBounds()
                .fillMaxWidth()
        ) {

            BlurContainer(
                modifier = Modifier.fillMaxWidth(),
                blur = blur.value,
                component = {
                    AnimatedContent(
                        targetState = image,
                        modifier = Modifier
                            .fillMaxWidth(),
                        transitionSpec = {
                            (fadeIn(tween(easing = LinearEasing)) + scaleIn(
                                tween(
                                    1_000,
                                    easing = LinearEasing
                                )
                            )).togetherWith(
                                fadeOut(
                                    tween(
                                        1_000,
                                        easing = LinearEasing
                                    )
                                ) + scaleOut(
                                    tween(
                                        1_000,
                                        easing = LinearEasing
                                    )
                                )
                            )
                        }, label = ""
                    ) { image ->
                        Image(
                            painter = painterResource(id = image),
                            modifier = Modifier
                                .size(200.dp),
                            contentDescription = ""
                        )
                    }
                }) {}
        }

        Spacer(modifier = Modifier.height(20.dp))

        Button(
            onClick = {
                image =
                    if (image == R.drawable.ic_first) R.drawable.ic_second else R.drawable.ic_first
            },
            colors = ButtonDefaults.buttonColors(
                containerColor = Color.Black
            )
        ) {
            Text("Change Image")
        }
    }
}

Conclusion

By understanding the ShaderContainer, BlurContainer, ShaderSource, and the customBlur modifier, you have the tools to create stunning rendering effects in your Jetpack Compose applications. These elements provide a foundation for exploring and experimenting with various visual effects and custom shaders, opening up a world of creative possibilities for your UI designs.

Happy coding!

The source code is available on GitHub.

Related Articles


Code, Build, Repeat.
Stay updated with the latest trends and tutorials in Android, iOS, and web development.
megh-l image
Megh Lath
Android developer | Sharing knowledge of Jetpack Compose & android development
megh-l image
Megh Lath
Android developer | Sharing knowledge of Jetpack Compose & android development
canopas-logo
We build products that customers can't help but love!
Get in touch

Whether you need...

  • *
    High-performing mobile apps
  • *
    Bulletproof cloud solutions
  • *
    Custom solutions for your business.
Bring us your toughest challenge and we'll show you the path to a sleek solution.
Talk To Our Experts
footer
Follow us on
2024 Canopas Software LLP. All rights reserved.