How To Create a Parallax Movie Pager In Jetpack Compose

CanopasCanopas
6 min read

Introduction

Parallax animations can make any app interface stand out by adding depth and interactivity. In this blog post, we'll build a movie pager with a cool parallax effect using Jetpack Compose.

You'll learn how to create smooth scrolling effects, synchronize two pagers, and animate movie cards and images in a way that brings your app to life.

Parallax Movie Pager In Jetpack Compose

The source code is available on GitHub.

Overview of the Implementation

We will create a movie pager that displays a background image that moves at a different speed than the foreground content, creating a depth effect. Our implementation will consist of two main components:

  • Background Image Pager: Displays the movie poster images.

  • Movie Cards Pager: Shows movie details over the background images.

Let’s start implementing it step-by-step…

Step 1: Setting Up Your Jetpack Compose Project

First, we need to create a new Jetpack Compose project.

  • Open Android Studio: Select “New Project,” and choose the “Empty Compose Activity” template.

  • Name Your Project: Give your project a suitable name that reflects its purpose.

  • Ensure Dependencies: Make sure you have the latest dependencies for Jetpack Compose in your project.

1. Update libs.versions.toml

In your libs.versions.toml file, ensure you have the following lines under the [versions] section to specify the Coil version:

coil = "2.7.0"

Next, add the Coil libraries under the [libraries] section:

coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }

2. Update build.gradle.kts

In your build.gradle.kts file, include the Coil dependencies by adding the following lines in the dependencies block:

dependencies {
    // Other dependencies...

    implementation(libs.coil) // Add Coil for image loading
    implementation(libs.coil.compose) // Add Coil for Compose support
}

3. Sync Your Project

After adding the dependencies, make sure to sync your Gradle files so that the changes take effect.

Step 2: Gradient Overlay and Utility Functions

We’ll create a gradient overlay for better readability and define utility functions for calculating offsets and loading images.

@Composable
private fun GradientOverlay() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(
                Brush.verticalGradient(
                    listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent),
                    startY = 0f,
                    endY = 500f
                )
            )
    )
}

fun calculatePageOffset(state: PagerState, currentPage: Int): Float {
    return (state.currentPage + state.currentPageOffsetFraction - currentPage).coerceIn(-1f, 1f)
}

Translation and Scaling Calculations

We’ll be using below concepts for calculating translation values and scaling values:

  1. calculatePageOffset: This function calculates the offset of the current page relative to the state of the pager. It considers both the current page and the fraction of the page being scrolled, normalizing the value to a range between -1 and 1.

  2. Translation Calculations: lerp(30f, 0f, 1f - currentPageOffset): This line interpolates between 30 and 0 based on the current page offset, allowing the background image to move from right to left as you scroll. For movie cards, lerp(100f, 0f, 1f - currentPageOffset) calculates how much to translate the card based on its position in the pager.

  3. Scale Calculations: lerp(0.8f, 1f, 1f - currentPageOffset.absoluteValue.coerceIn(0f, 1f)): This calculates the scale of the movie card, scaling it from 0.8 to 1.0 as it approaches the center of the screen.

  4. Parallax Effect: The parallax effect is achieved by multiplying the currentPageOffset by screenWidth * 2f to create a greater movement effect, making the background image scroll slower than the foreground content.

Step 3: Setting Up the UI Structure

We start by creating a MoviePager composable function, which will house both the background and foreground elements.

@Composable
fun MoviePager(paddingValues: PaddingValues) {
    val backgroundPagerState = rememberPagerState(pageCount = { movies.size })
    val movieCardPagerState = rememberPagerState(pageCount = { movies.size })

    // Derived state to track scrolling status
    val scrollingFollowingPair by remember {
        derivedStateOf {
            when {
                backgroundPagerState.isScrollInProgress -> backgroundPagerState to movieCardPagerState
                movieCardPagerState.isScrollInProgress -> movieCardPagerState to backgroundPagerState
                else -> null
            }
        }
    }

    // Synchronizing scrolling of two pagers
    LaunchedEffect(scrollingFollowingPair) {
        scrollingFollowingPair?.let { (scrollingState, followingState) ->
            snapshotFlow { scrollingState.currentPage + scrollingState.currentPageOffsetFraction }
                .collect { pagePart ->
                    val (page, offset) = BigDecimal.valueOf(pagePart.toDouble())
                        .divideAndRemainder(BigDecimal.ONE)
                        .let { it[0].toInt() to it[1].toFloat() }

                    followingState.requestScrollToPage(page, offset)
                }
        }
    }

    // Layout for both pagers
    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues),
        contentAlignment = Alignment.TopCenter
    ) {
        BackgroundImagePager(backgroundPagerState)
        GradientOverlay()
        MovieCardsPager(movieCardPagerState)
    }
}

Step 4: Implementing the Background Image Pager

The BackgroundImagePager displays the background images and applies a translation effect based on the current page offset.

@Composable
private fun BackgroundImagePager(state: PagerState) {
    HorizontalPager(
        modifier = Modifier.fillMaxSize(),
        state = state
    ) { currentPage ->
        // Get the current page offset
        val currentPageOffset = calculatePageOffset(state, currentPage)
        // Calculate the translation for the background image
        val translationX = lerp(30f, 0f, 1f - currentPageOffset)

        Box(Modifier.fillMaxSize()) {
            Image(
                painter = rememberAsyncImagePainter(movies[currentPage].url),
                contentDescription = movies[currentPage].title,
                contentScale = ContentScale.FillBounds,
                modifier = Modifier
                    .fillMaxSize()
                    .graphicsLayer { this.translationX = translationX } // Apply translation
            )
        }
    }
}

Movie Pager Background

Step 5: Creating the Movie Cards Pager

The MovieCardsPager shows the details of the movies on top of the background images. Each movie card has its own scaling and translation based on the current page offset.

@Composable
private fun MovieCardsPager(state: PagerState) {
    HorizontalPager(
        modifier = Modifier.fillMaxSize(),
        state = state,
        verticalAlignment = Alignment.Bottom
    ) { currentPage ->
        val context = LocalContext.current
        val coroutineScope = rememberCoroutineScope()
        var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }

        LaunchedEffect(currentPage) {
            loadImageBitmap(context, coroutineScope, movies[currentPage].url) {
                imageBitmap = it.asImageBitmap()
            }
        }

        // Get the current page offset
        val currentPageOffset = calculatePageOffset(state, currentPage)
        MovieCard(currentPage, imageBitmap, currentPageOffset)
    }
}

Creating the Movie Cards Pager

Step 6: Designing the Movie Card

The MovieCard composable displays the movie image and details while applying transformations for the parallax effect.

@Composable
private fun MovieCard(currentPage: Int, imageBitmap: ImageBitmap?, currentPageOffset: Float) {
    // Calculate translation and scaling based on the current page offset
    // Translate the card on the X-axis
    val cardTranslationX = lerp(100f, 0f, 1f - currentPageOffset)
    // Scale the card on the X-axis
    val cardScaleX = lerp(0.8f, 1f, 1f - currentPageOffset.absoluteValue.coerceIn(0f, 1f))

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(0.7f)
            .graphicsLayer {
                scaleX = cardScaleX // Apply scaling
                translationX = cardTranslationX // Apply translation
            }
            .background(Color.Black, shape = MaterialTheme.shapes.large)
    ) {
        imageBitmap?.let {
            ParallaxImage(imageBitmap, currentPageOffset)
        }
        MovieCardOverlay(currentPage, currentPageOffset)
    }
}

Step 7: Implementing the Parallax Image Effect

The ParallaxImage composable uses a Canvas to draw the image with a parallax offset based on the current page offset.

@Composable
private fun ParallaxImage(imageBitmap: ImageBitmap, currentPageOffset: Float) {
    val drawSize = IntSize(imageBitmap.width, imageBitmap.height)
    val screenWidth = LocalConfiguration.current.screenWidthDp
    // Calculate parallax offset
    val parallaxOffset = currentPageOffset * screenWidth * 2f

    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .clip(MaterialTheme.shapes.large)
            .border(2.dp, Color.White, MaterialTheme.shapes.large)
            .graphicsLayer { translationX = lerp(10f, 0f, 1f - currentPageOffset) } // Apply translation
    ) {
        translate(left = parallaxOffset) {
            drawImage(
                image = imageBitmap,
                srcSize = drawSize,
                dstSize = size.toIntSize(),
            )
        }
    }
}

Movie Card with Parallax

To read the complete guide including the overlaying movie details, then please visit this blog.


The source code is available on GitHub.

Show your support and help us to grow by giving the repository a star!⭐️

Your support means the world!

0
Subscribe to my newsletter

Read articles from Canopas directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Canopas
Canopas

Unless you’re a Multimillion or a Billion dollar company, you probably don’t have a multimillion-dollar ad budget or professional Spinners. Your product needs to stand out on its own merits like App Quality, Performance, UI design, and User Experience. Most companies don't care about you, your product, and your vision or dreams. They don't give a damn about either their work helped you to get more business, revenue, users, or solving a problem. That's where CANOPAS comes into the picture. Whether you have a GREAT IDEA and you want to turn it into a DIGITAL PRODUCT. OR You need a team that can turn your NIGHTMARES into SWEET DREAMS again by improving your existing product. We help Entrepreneurs, startups, and small companies to bring their IDEA to LIFE by developing digital products for their business. We prefer using Agile and Scrum principles in project management for flexibility and rapid review cycles. We are not bound by technology. We will learn new technology if it significantly improves the performance of your app. We will solve your tech-related problems even though we are not THE EXPERT in it. And we've done it multiple times in the last 7 years. In the last seven years, we helped... A STARTUP to expand its users from 2500 to over 100000 by developing mobile apps for them. An enterprise client to redevelop their app that has 1M+ monthly paid users and 10M+ app downloads. Another enterprise client(5M+ app downloads in each store) to fix bugs and broken parts in the app and as a result, they had over 98% crash-free users. We offer a 100% MONEY BACK GUARANTEE if you don't like our work. No questions asked. Visit : https://canopas.com/blog