How to Use Render Effects in Jetpack Compose for Stunning Visuals
Learning by Example: How to Use Render Effects to Transform UIs
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
Table of Contents
- The BlurContainer
- Applying Render Effect to Parent Container
- Applying the Rendering Effect
- ExtendedFabRenderEffect
- Smooth Animation
- Combining Effects
- ButtonComponent Anatomy
- TextRenderEffect
- Animating the Text
- Complete Integration with ShaderContainer
- ImageRenderEffect
- Dynamic Image Transitions
- Crafting Engaging Effects
- Seamless Integration with ShaderContainer
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 usingRenderEffect.createBlurEffect
. ThegraphicsLayer
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)
: Themain
function is the entry point of the shader. It takes a 2D coordinate (cord
) and returns a color in the form ofhalf4
, 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 aRenderEffect
using thecreateRuntimeShaderEffect
method. This method takes ourruntimeShader
and the name "composable," which corresponds to the shader variable in ourShaderSource
..asComposeRenderEffect()
: We convert theRenderEffect
into a Compose-friendly format usingasComposeRenderEffect()
.
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:
- 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. - Text Transition: We will use the
LaunchedEffect
to cycle through the list of phrases, displaying each for a certain duration. When thetextToDisplay
changes, an animationscaleIn
occurs, presenting the new text with a scale-in effect, and as it transitions out, ascaleOut
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 afadeIn
andscaleIn
effect for the image entering the scene and afadeOut
andscaleOut
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.
The post is originally published on canopas.com.
Related Useful Articles
Thanks for the love you’re showing!
If you like what you read, be sure you won’t miss a chance to give 👏 👏👏 below — as a writer it means the world!
Feedback and suggestions are most welcome, add them in the comments section.
Follow Canopas to get updates on interesting articles!