Create a Pulsar effect with Jetpack Compose

Louis Gautier
4 min readJan 9, 2023

--

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.

--

--