Create a Pulsar effect with Jetpack Compose
Hi !
My name is Louis, I’m an Android Developper who love trying new things and share with others.
Today, I would like to show you a nice and quick animation written in Compose.
What is a pulsar ?
A pulsar is an animation that will reproduce a wave on a water. It’s mostly used with a map to display the user current position, or to reveal an important action (such as “tap to confirm”)
Setup
First of all, we need the main content, we will use a simple Floating Action Button :
@Composable
fun PulsarFab() {
FloatingActionButton(
shape = FloatingActionButtonDefaults.largeShape,
containerColor = MaterialTheme.colorScheme.primary,
onClick = { },
) { Icon(imageVector = Icons.Default.Search, contentDescription = "") }
}
Draw the pulse effect
Now, we need to draw a circle behind our FAB,
@Composable
fun PulsarFab() {
Box(contentAlignment = Companion.Center) {
Canvas(Modifier.fillMaxSize(), onDraw = {
drawCircle(color = Color.Red)
})
FloatingActionButton(
shape = FloatingActionButtonDefaults.largeShape,
containerColor = MaterialTheme.colorScheme.primary,
onClick = { },
) { Icon(imageVector = Icons.Default.Search, contentDescription = "") }
}
}
Animate the pulse
Jetpack Compose comes with a lot of new features, especially for animations. In our case, we need an infinite animation that increase (or decrease the pulse radius.
val infiniteTransition = rememberInfiniteTransition()
val radius by infiniteTransition.animateFloat(
initialValue = /*fab size or less to be hidden*/,
targetValue = /*maximum size of the pulse*/,
animationSpec = InfiniteRepeatableSpec(
animation = tween(3000),
repeatMode = Restart
)
)
The initial radius of the pulse must be equal that the FAB size. To know the FAB size, we will use onGloballyPositioned Modifier
Note : the radius is equal to half FAB’s width
For the target radius, this can be done by adding a random float value to the FAB width. Let’s try with 50.
And then, apply the radius to drawCircle.
@Composable
fun PulsarFab() {
var fabSize by remember { mutableStateOf(IntSize(0, 0)) }
val pulsarRadius = 50f
val infiniteTransition = rememberInfiniteTransition()
val radius by infiniteTransition.animateFloat(
initialValue = (fabSize.width / 2).toFloat(),
targetValue = fabSize.width + pulsarRadius,
animationSpec = InfiniteRepeatableSpec(
animation = tween(3000),
repeatMode = Restart
)
)
Box(contentAlignment = Companion.Center) {
Canvas(Modifier.fillMaxSize(), onDraw = {
drawCircle(color = Color.Red, radius)
})
FloatingActionButton(
modifier = Modifier
.padding(pulsarSize.dp)
.onGloballyPositioned {
if (it.isAttached) {
fabSize = it.size
}
},
shape = FloatingActionButtonDefaults.largeShape,
containerColor = MaterialTheme.colorScheme.primary,
onClick = { },
) { Icon(imageVector = Icons.Default.Search, contentDescription = "") }
}
}
Almost there !
The pulse should fade away when it reach his target value. And we will use the same animation
@Composable
fun PulsarFab() {
var fabSize by remember { mutableStateOf(IntSize(0, 0)) }
val pulsarSize = 50f
val infiniteTransition = rememberInfiniteTransition()
val radius by ...
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(3000),
initialStartOffset = StartOffset(100), // prevent a graphic issue when the animation restart
repeatMode = Restart
)
)
Box(contentAlignment = Companion.Center) {
Canvas(Modifier.fillMaxSize(), onDraw = {
drawCircle(color, radius, alpha)
})
...
}
}
Bonus : Add multiple pulse effect !
Before we begin, we need to change a few thing.
First, create a composable that will hold our animations
@Composable
fun pulsarBuilder(pulsarRadius: Float, size: Int, delay: Int): Pair<Float, Float> {
val infiniteTransition = rememberInfiniteTransition()
val radius by infiniteTransition.animateFloat(
initialValue = (size / 2).toFloat(),
targetValue = size + pulsarRadius,
animationSpec = InfiniteRepeatableSpec(
animation = tween(3000),
repeatMode = Restart
)
)
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(3000),
repeatMode = Restart
)
)
return radius to alpha
}
Note : Add another parameter named delay. This will delay the n+1 pulse
Now, we can adapt our composable :
@Composable
fun PulsarFab() {
var fabSize by remember { mutableStateOf(IntSize(0, 0)) }
val pulsarRadius = 50f
val (radius, alpha) = pulsarBuilder(pulsarRadius = pulsarRadius, size = fabSize.width, delay = 0)
Box(contentAlignment = Companion.Center) {
Canvas(Modifier.fillMaxSize(), onDraw = {
drawCircle(color = Color.Red, radius, alpha = alpha)
})
FloatingActionButton(
modifier = Modifier
.padding(pulsarSize.dp)
.onGloballyPositioned {
if (it.isAttached) {
fabSize = it.size
}
},
shape = FloatingActionButtonDefaults.largeShape,
containerColor = MaterialTheme.colorScheme.primary,
onClick = { },
) { Icon(imageVector = Icons.Default.Search, contentDescription = "") }
}
}
And finally, with a for loop, we can add as many pulse we want
Note : I put the fab in another composable to make the code smaller
@Composable
fun PulsarFab(
nbPulsar: Int = 2,
fab: @Composable (Modifier) -> Unit = {}
) {
var fabSize by remember { mutableStateOf(IntSize(0, 0)) }
val pulsarRadius = 50f
val effects: List<Pair<Float, Float>> = List(nbPulsar) {
pulsarBuilder(pulsarRadius = pulsarRadius, size = fabSize.width, it * 500)
}
Box(contentAlignment = Companion.Center) {
Canvas(Modifier, onDraw = {
for (i in 0 until nbPulsar) {
val (radius, alpha) = effects[i]
drawCircle(color = pulsarColor, radius = radius, alpha = alpha)
}
})
fab(
Modifier
.padding(pulsarRadius.dp)
.onGloballyPositioned {
if (it.isAttached) {
fabSize = it.size
}
}
)
}
}
Conclusion
Jetpack Compose make animation so more easy. In few steps, we manage to create a nice pulse effect around a FAB.
What do you think ? Don’t hesitate to reach me on Linkedin if you have any questions.
PS : You can get the code here
Louis.