ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Swipeable Image Carousel with Smooth Animations in Jetpack Compose

Hello Folks,

  • Jetpack Compose is seriously taking over, and it’s only getting bigger! Today, we’re about to create something awesome — our own Swipeable Image Carousel using Horizontal Pager in Compose. Cool, right?
  • Let’s jump in and design this whole thing from the ground up! 🔥How are we going to do that?

Alright, let’s get into it!

@Composable
fun ImageCarousel(
modifier: Modifier = Modifier
)
{
val imageList = listOf(
R.drawable.image_banner_1,
R.drawable.image_banner_2,
R.drawable.image_banner_3,
R.drawable.image_banner_4
)

val pagerState = rememberPagerState { imageList.size }

Column(
modifier
.defaultMinSize(minHeight = 300.dp)
.fillMaxWidth()
) {

HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { page ->
Image(
painter = painterResource(id = imageList[page]),
contentDescription = "",
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentScale = ContentScale.Crop
)
}

}

}

Here, the Output is a simple, swipable image corousel, which many people might know already.

To ensure that Page2 is partially visible alongside Page1, and both Page1 and Page3 are partially visible with Page2, we need to use contentPadding. Let’s add it and observe the result.

@Composable
fun ImageCarousel(
modifier: Modifier = Modifier
)
{
val imageList = listOf(
R.drawable.image_banner_1,
R.drawable.image_banner_2,
R.drawable.image_banner_3,
R.drawable.image_banner_4
)

val pagerState = rememberPagerState { imageList.size }

Column(
modifier
.defaultMinSize(minHeight = 300.dp)
.fillMaxWidth()
) {

HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 30.dp)
) { page ->
Image(
painter = painterResource(id = imageList[page]),
contentDescription = "",
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentScale = ContentScale.Crop
)
}

}

}

Output:-

What contentPadding does: In a HorizontalPager, contentPadding adds extra space to the left and right edges of the content area. With PaddingValues(horizontal = 100.dp), you’re adding:

  • 100dp of padding to the left of the first page.
  • 100dp of padding to the right of the last page.

Impact on Layout: This padding shifts the entire content inward, meaning the first page doesn’t start at the left edge of the screen, and the last page doesn’t end at the right edge. As a result, when one page is centered, the pages on either side can become partially visible within the screen bounds.

Now, we also need to add spacing between the two cards. How can we achieve this?

We’ll use pageSpacing to accomplish this.

 @Composable
fun ImageCarousel(
modifier: Modifier = Modifier
)
{
val imageList = listOf(
R.drawable.image_banner_1,
R.drawable.image_banner_2,
R.drawable.image_banner_3,
R.drawable.image_banner_4
)

val pagerState = rememberPagerState { imageList.size }

Column(
modifier
.defaultMinSize(minHeight = 300.dp)
.fillMaxWidth()
) {

HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
pageSpacing = 10.dp,
contentPadding = PaddingValues(horizontal = 30.dp)
) { page ->
Image(
painter = painterResource(id = imageList[page]),
contentDescription = "",
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentScale = ContentScale.Crop
)
}

}

}

Output:-

We’re very close to achieving the desired behavior! But before moving forward, let’s take a moment to understand what this is and how it has helped us so far.

What it does: pageSpacing defines the gap (or spacing) between consecutive pages in the HorizontalPager.

  • Effect: This means there will be a horizontal gap of 70 density-independent pixels (dp) between the edges of one page’s content (the Image) and the next page’s content.

How it helps:

  • Visual Separation: The 70dp spacing ensures that images don’t feel cramped or overlap as the user scrolls through the pager. It gives each image its own “breathing room,” making the UI cleaner and easier to navigate.
  • Focus on the Current Page: Combined with the scaling effect from lerp, this spacing emphasizes the centered image (which scales to 100.dp) while pushing adjacent images further apart, reducing visual clutter.
  • Smooth Scrolling: The spacing contributes to a natural scrolling feel, as pages aren’t too tightly packed, allowing the user to distinguish between them during transitions.

Now, for the final touch! We want to add animation when moving from left to right, ensuring that partially visible images scale down compared to the centered image.

To achieve this, we’ll manipulate the image itself rather than HorizontalPager. We'll use graphicsLayer and lerp to create the desired effect.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImageCarousel(
modifier: Modifier = Modifier
)
{
val imageList = listOf(
R.drawable.image_banner_1,
R.drawable.image_banner_2,
R.drawable.image_banner_3,
R.drawable.image_banner_4
)

val pagerState = rememberPagerState { imageList.size }

Column(
modifier
.defaultMinSize(minHeight = 300.dp)
.fillMaxWidth()
) {

HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
pageSpacing = 10.dp,
contentPadding = PaddingValues(horizontal = 30.dp)
) { page ->
Image(
painter = painterResource(id = imageList[page]),
contentDescription = "",
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.graphicsLayer {
val pageOffset =
(pagerState.currentPage - page + pagerState.currentPageOffsetFraction).absoluteValue

lerp(
start = 75.dp,
stop = 100.dp,
fraction = 1f - pageOffset.coerceIn(0f, 1f)
).also { scale ->
scaleY = scale / 100.dp
}
},
contentScale = ContentScale.Crop
)
}

}

}

In the context of your code snippet, lerp stands for linear interpolation. It’s a mathematical function commonly used in graphics, animations, and UI programming to smoothly transition between two values based on a fraction or percentage.

Definition of lerp

Linear interpolation calculates an intermediate value between a start and a stop value, based on a fraction (typically a value between 0 and 1). The formula is:

lerp(start, stop, fraction) = start + (stop - start) * fraction
  • When fraction = 0, the result is start.
  • When the fraction = 1, the result is stop.
  • When the fraction is between 0 and 1, the result is a value proportionally between start and stop.
lerp(
start = 75.dp,
stop = 100.dp,
fraction = 1f - pageOffset.coerceIn(0f, 1f)
).also { scale ->
scaleY = scale / 100.dp
}

start = 75.dp: The minimum scale value (75 density-independent pixels).

stop = 100.dp: The maximum scale value (100 density-independent pixels).

fraction = 1f — pageOffset.coerceIn(0f, 1f):

  • pageOffset represents how far the current page is from being fully centered in the HorizontalPager. It’s a value that can range beyond 0 to 1, but coerceIn(0f, 1f) clamps it to the range [0, 1].
  • 1f — pageOffset inverts this value, so when the page is fully centered (pageOffset = 0), the fraction is 1, and when the page is fully off-screen (pageOffset = 1), the fraction is 0.

lerp Result: The result is a smooth transition between 75.dp and 100.dp. For example:

  • If fraction = 1 (page centered), the result is 100.dp.
  • If fraction = 0 (page fully offset), the result is 75.dp.
  • If fraction = 0.5 (halfway), the result is 87.5.dp.

If you have any questions, just drop a comment, and I’ll get back to you ASAP. We’ll dive deeper into Jetpack Compose soon. Until then, happy coding!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Published in ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

No responses yet

Write a response