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.
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
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()
}
)
blur
parameter, which specifies the intensity of the blur effect.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.
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.
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.
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.
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 = ""
)
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.
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!
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.
To make the text display engaging, we will utilize a couple of key animations:
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.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.@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
)
}
}
) {}
}
}
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.
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.
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))
}
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 = ""
)
}
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")
}
}
}
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.
Whether you need...