This image is generated by Midjourney
As part of my daily routine, I often explore the latest developments on platforms like X and Medium. One morning, while scrolling through X, I stumbled upon a GitHub repository shared by KavSoft that immediately caught my attention. This project, hosted at KavSoft-Tutorials-iOS, showcased a mesmerizing parallax carousel. Its visual appeal and user experience were simply captivating.
Parallax Carousel Slider Using TabView in SwiftUI 2.0 by kenfai
In that moment, I knew I had to roll up my sleeves and recreate this magic in Jetpack Compose for Android. This article serves as a bridge between these two worlds, illustrating how we can transform the SwiftUI Parallax Carousel into its Jetpack Compose equivalent.
Sample project
Our journey begins with the examination of the core components, and for those interested in exploring the detailed code snippets and the complete source code, you can find them all here: ParallaxCarouselCompose.
Let’s commence this transformation by delving into the key components.
TabView → HorizontalPager
To bridge the gap between SwiftUI’s TabView and Jetpack Compose, we turn our attention to HorizontalPager. SwiftUI’s TabView offers a delightful way to swipe through a collection of views seamlessly. In the realm of Android, our transition starts with HorizontalPager.
While it’s important to note that HorizontalPager is still considered experimental, it proves to be a fitting choice for replicating the core functionality of TabView in SwiftUI. With HorizontalPager, we’re able to navigate through a series of images with grace and fluidity, maintaining the desired user experience.
// Padding values | |
private val cardPadding = 25.dp | |
private val imagePadding = 10.dp | |
// Shadow and shape values for the card | |
private val shadowElevation = 15.dp | |
private val borderRadius = 15.dp | |
private val shape = RoundedCornerShape(borderRadius) | |
// Offset for the parallax effect | |
private val xOffset = cardPadding.value * 2 | |
@OptIn(ExperimentalFoundationApi::class) | |
@Composable | |
fun ParallaxCarousel() { | |
// Get screen dimensions and density | |
val screenWidth = LocalConfiguration.current.screenWidthDp.dp | |
val screenHeight = LocalConfiguration.current.screenHeightDp.dp | |
val density = LocalDensity.current.density | |
// List of image resources | |
val images = listOf( | |
R.drawable.p1, | |
... | |
R.drawable.p7, | |
) | |
// Create a pager state | |
val pagerState = rememberPagerState { | |
images.size | |
} | |
// Calculate the height for the pager | |
val pagerHeight = screenHeight / 1.5f | |
// HorizontalPager composable: Swiping through images | |
HorizontalPager( | |
state = pagerState, | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(pagerHeight), | |
) { page -> | |
// Calculate the parallax offset for the current page | |
val parallaxOffset = pagerState.getOffsetFractionForPage(page) * screenWidth.value | |
// Call ParallaxCarouselItem with image resource and parameters | |
ParallaxCarouselItem( | |
images[page], | |
parallaxOffset, | |
pagerHeight, | |
screenWidth, | |
density | |
) | |
} | |
} |
Job Offers
The parallaxOffset
calculation is used to determine the position offset of the current page in the carousel. Here’s why:
pagerState.getOffsetFractionForPage(page): This function retrieves a fractional value representing how far the current page is from its snapped position. It varies between -0.5 (if the page is offset towards the start of the layout) to 0.5 (if the page is offset towards the end of the layout). When the current page is in the snapped position, this value is 0.0. It’s a useful indicator of the page’s position within the carousel.
screenWidth
: This is the width of the screen or the display area. Multiplying the fractional offset by the screen width helps scale the offset value to match the screen’s dimensions. This step ensures that the parallax effect moves the image proportionally across the screen, making the effect visually pleasing and responsive to the screen size.
GeometryReader → Canvas
Upon examining the SwiftUI code, I noticed the use of GeometryReader, a critical component in achieving the parallax effect. In the realm of Jetpack Compose, we enlist the assistance of Canvas to bring this effect to life.
However, there’s a subtle difference. Unlike SwiftUI, where you can place an image inside GeometryReader with .aspectRatio(contentMode: .fill) to achieve the correct image ratio, Jetpack Compose takes a slightly different approach. Within Canvas, we can’t directly use Compose Image. Instead, we employ the
drawBitmap function of Canvas to render the images.
To replicate the behavior observed on iOS — an image that spans the full width (equivalent to the screen size) while maintaining the correct height — we delve into some calculation. This involves ensuring that our drawn image maintains the correct aspect ratio.
For those curious, here’s a glimpse of the calculateDrawSize function that handles this calculation.
// Function to calculate draw size for the image | |
private fun ImageBitmap.calculateDrawSize(density: Float, screenWidth: Dp, pagerHeight: Dp): IntSize { | |
val originalImageWidth = width / density | |
val originalImageHeight = height / density | |
val frameAspectRatio = screenWidth / pagerHeight | |
val imageAspectRatio = originalImageWidth / originalImageHeight | |
val drawWidth = xOffset + if (frameAspectRatio > imageAspectRatio) { | |
screenWidth.value | |
} else { | |
pagerHeight.value * imageAspectRatio | |
} | |
val drawHeight = if (frameAspectRatio > imageAspectRatio) { | |
screenWidth.value / imageAspectRatio | |
} else { | |
pagerHeight.value | |
} | |
return IntSize(drawWidth.toIntPx(density), drawHeight.toIntPx(density)) | |
} | |
// Extension function to convert Float to Int in pixels | |
private fun Float.toIntPx(density: Float) = (this * density).roundToInt() |
Now, let’s demystify a crucial aspect: the usage of Canvas translate,
parallaxOffset and
xOffset. These elements are pivotal in creating the parallax effect we’re striving for in Jetpack Compose.
@Composable | |
fun ParallaxCarouselItem( | |
imageResId: Int, | |
parallaxOffset: Float, | |
pagerHeight: Dp, | |
screenWidth: Dp, | |
density: Float, | |
) { | |
// Load the image bitmap | |
val imageBitmap = ImageBitmap.imageResource(id = imageResId) | |
// Calculate the draw size for the image | |
val drawSize = imageBitmap.calculateDrawSize(density, screenWidth, pagerHeight) | |
// Card composable for the item | |
Card( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(cardPadding) | |
.background(Color.White, shape) | |
.shadow(elevation = shadowElevation, shape = shape) | |
) { | |
// Canvas for drawing the image with parallax effect | |
Canvas( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(imagePadding) | |
.clip(shape) | |
) { | |
// Translate the canvas for parallax effect | |
translate(left = parallaxOffset * density) { | |
// Draw the image | |
drawImage( | |
image = imageBitmap, | |
srcSize = IntSize(imageBitmap.width, imageBitmap.height), | |
dstOffset = IntOffset(-xOffset.toIntPx(density), 0), | |
dstSize = drawSize, | |
) | |
} | |
} | |
} | |
} |
xOffset is defined as an offset value for the parallax effect. It’s calculated as twice the value of cardPadding. This offset determines how much the image is shifted horizontally within the Canvas.
The translate function within the Canvas is used to shift the Canvas horizontally by an amount specified by parallaxOffset. This horizontal translation creates the parallax effect, making the image appear to move horizontally as the user interacts with the carousel.
Result: ParallaxCarousel with Jetpack Compose
Summary
With that, we’ve successfully bridged the gap between SwiftUI and Jetpack Compose, transforming a captivating parallax carousel from one platform to another. As we’ve discovered, while the tools and methods may differ, the end result is equally stunning. Jetpack Compose empowers us to bring our creative visions to life on the Android platform, and this journey is a testament to its flexibility and capabilities.
Before we conclude, it’s worth mentioning that the brilliant Chris Banes has also explored parallax effects in Jetpack Compose. In his insightful article, he demonstrates an alternative approach using alignment
. His solution elegantly tackles the parallax effect, and I highly recommend checking out his work for a deeper dive into this topic: Chris Banes – Parallax Effect in Jetpack Compose. It’s a testament to the flexibility and capabilities of Jetpack Compose in enabling creative visions on the Android platform.
This article was previously published on proandroiddev.com