Android: Shimmer Effect using Jetpack Compose

In this short tutorial, we explore how to create a shimmering effect using Jetpack Compose.

Shimmer effect is mainly used in skeleton loading, and sometimes, it improve the overall user experience to a certain extent... but... let's admit. Skeleton loading is the new way to show loading state to the users!

💡
I am using v1.1.0-beta01 for Jetpack compose related libaries

Let's define the colors for the shimmering effect.

val colors = listOf(
        Color(0xFFEAEAEA), // grey
        Color(0xFFD34545), // red
        Color(0xFFEAEAEA) // grey
)

Since the shimmering effect is an infinite loop, we can make use of the rememberInfiniteTransition (provided by the Compose library) where it returns a InfiniteTransition. We can use the InfiniteTransition to that creates the animation that runs infinitely.

val translateAnim = transition.animateFloat(
            initialValue = 0f,
            targetValue = 500f,
            animationSpec = infiniteRepeatable(
                animation = tween(
                    durationMillis = 1000,
                    easing = FastOutLinearInEasing
                ),
                repeatMode = RepeatMode.Restart
            )
        )

And let's create a linear gradient brush with colors and translateAnim

val brush = Brush.linearGradient(
    colors = colors,
    start = Offset(10f, 10f),
    end = Offset(translateAnim, translateAnim)
)

Lastly, let's define a Spacer (or you can use a Box as well)

Spacer(
    modifier = modifier
    	.background(brush = brush)
)

Let's put together

@Composable
fun ShimmerSpacer(
    modifier: Modifier = Modifier
        .fillMaxWidth()
        .height(50.dp)
){
    val colors = listOf(
        Color(0xFFEAEAEA),
        Color(0xFFD34545),
        Color(0xFFEAEAEA)
    )

    val transition = rememberInfiniteTransition()

    val translateAnim = transition.animateFloat(
        initialValue = 0f,
        targetValue = 500f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000,
                easing = FastOutSlowInEasing
            ),
            repeatMode = RepeatMode.Restart
        )
    )

    val brush = Brush.linearGradient(
        colors,
        start = Offset(10f,10f),
        end = Offset(translateAnim.value,translateAnim.value)
    )


    Spacer(
        modifier = modifier
            .background(brush = brush)
    )
}
The shimmer effect (ew...)

And here you go... the shimmer effect.

Clearly, there are a few things went wrong with the shimmer effect

  1. The colour is red. 💩
  2. The shimmer effect stops mid way
  3. The shimmer light expands weirdly (doesn't look good either...)

Let's break it down.

The colour is red. 💩

I think you can just change the colors inside the colors list. 😅

The shimmer effect stops mid way

To allow the shimmer effect travels to the end of the Spacer, we must calculate the width of the Spacer. To do so, we can use the BoxWithConstraints to get the maxWidth and maxHeight.

BoxWithConstraints {

	// get max width of the box
	val spaceMaxWidth = with(LocalDensity.current){
    		maxWidth.toPx() 
    	}
    
    	val spaceMaxHeight = with(LocalDensity.current){
    		maxHeight.toPx() 
    	}
    
	val translateAnim = transition.animateFloat(
        initialValue = 0f,
        targetValue = spaceMaxWidth,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000,
                easing = FastOutSlowInEasing
            ),
            repeatMode = RepeatMode.Restart
        )
    )
}
Using pixels as targetValue

The shimmer light expands weirdly  (doesn't look good either...)

Let's understand how the linearGradient works...

val brush = Brush.linearGradient(
        colors,
        start = Offset(10f,10f),
        end = Offset(translateAnim.value,translateAnim.value)
    )

I draw (poorly using Adobe-owned Figma software 🙃) a state illustration as a reference of how those the start and end works in the linearGradient method, with the use of value in translateAnim.

For illustration purpose, the linearGradient will be shown as a whole

If transitionAnim's value is at 20% (20% of 500f)

If you recalled, the start parameter have an offset of (10f, 10f) where it refers to (x1 and y1) in the illustration above. The start will be at 10f, 10f (x,y) throughout the run of the animation.

As for the end parameter, it also holds an offset but with the transition value at 100f (20% times 500f) for both x and y. Hence, it is expanding weirdly throughout the animation run.

If transitionAnim's value is at 50% (50% of 500f)
If transitionAnim's value is at 100% (100% of 500f)

To fix this weirdly expandable shimmer, we have to the translateAnim's value in both start and end parameters of the linearGradient brush. In addition, we also must give a fake width to the shimmer.

// The linearGradient will hold 30% of the total width
val shimmerWidthPercentage = 0.3 

// targetValue will have 1.3x of the total width
val translateAnim = transition.animateFloat(
            initialValue = 0f,
            targetValue = spaceMaxWidth * (1 + shimmerWidthPercentage),
            animationSpec = infiniteRepeatable(
                animation = tween(
                    durationMillis = 1000,
                    easing = FastOutSlowInEasing
                ),
                repeatMode = RepeatMode.Restart
            )
        )

val brush = Brush.linearGradient(
            colors,
            start = Offset(
            	translateAnim.value - 
                (spaceMaxWidth *shimmerWidthPercentage)
                ,spaceMaxHeight),
            end = Offset(translateAnim.value,spaceMaxHeight)
        )
New linearGradient

With reference to the above code block, we have to make use of the translateAnim.value for both x in the start and end parameter so the shimmer effect will flow from start to end point of the box.

If you noticed, I also deduct the start's x by 30% because this is considered as we the "width" of the shimmer effect. Another reason why I deduct is the shimmer should "start" before ending the visible view of the Spacer.

Final code

Here is the final code for the shimmer spacer.

@Composable
fun NewShimmerSpacer(
    modifier: Modifier = Modifier
        .fillMaxWidth()
        .height(50.dp)
){
    val colors = listOf(
        Color(0xFFEAEAEA),
        Color(0xFFF6F6F6),
        Color(0xFFEAEAEA)
    )


    val transition = rememberInfiniteTransition()

    val shimmerWidthPercentage = 0.3f

    BoxWithConstraints {
        val spaceMaxWidth = with(LocalDensity.current) { maxWidth.toPx() }
        val spaceMaxHeight = with(LocalDensity.current) { maxHeight.toPx() }

        val translateAnim = transition.animateFloat(
            initialValue = 0f,
            targetValue = spaceMaxWidth * (1 + shimmerWidthPercentage),
            animationSpec = infiniteRepeatable(
                animation = tween(
                    durationMillis = 1000,
                    easing = FastOutSlowInEasing
                ),
                repeatMode = RepeatMode.Restart
            )
        )

        val brush = Brush.linearGradient(
            colors,
            start = Offset(translateAnim.value - (spaceMaxWidth * shimmerWidthPercentage),spaceMaxHeight),
            end = Offset(translateAnim.value,spaceMaxHeight)
        )


        Spacer(
            modifier = modifier
                .background(brush = brush)
        )

    }
}
Column(
   Modifier.fillMaxHeight(),
   verticalArrangement = Arrangement.Center,
   horizontalAlignment = Alignment.CenterHorizontally,
 ){
     NewShimmerSpacer(
   		Modifier.fillMaxWidth(0.5f)
     	.height(80.dp)
     	.clip(RoundedCornerShape(5.dp))
     )
     Spacer(Modifier.fillMaxWidth().height(15.dp))
     NewShimmerSpacer(
     	Modifier
     	.fillMaxWidth(0.5f)
     	.height(50.dp)
     )
     Spacer(Modifier.fillMaxWidth().height(15.dp))
     NewShimmerSpacer(
     	Modifier
     	.size(100.dp)
     	.clip(CircleShape)
     )
}

Final result

Shimmer effect using Jetpack Compose

Feel free to connect with me

I think there are better ways to do it. It can be cleaner as well (using 0f to 1f instead of actual values).

But you know what, it works for me!

I love to hear your thoughts, so please feel free to contact via my linkedIn or twitter 🤟

Please feel free to subscribe for weird blog post in the future. It's free. I promise!