Exploring Shared Element Transition with Navigation in Compose

CanopasCanopas
7 min read

Exciting News! Our blog has a new Home! 🚀

Background

Shared element transitions provide a smooth move between composables that share common content, creating a visual link as users navigate through different screens. These transitions are frequently employed in navigation to ensure a cohesive user experience.

Transitions play a vital role in mobile app design, providing a sense of continuity and improving user engagement.

Jetpack Compose simplifies the process of implementing animations, including shared element transitions when navigating between different composables.

In this blog post, we will explore how to implement shared element transitions in Jetpack Compose using Navigation.

⚠ Experimental: Shared element support is available from Compose 1.7.0-beta01, and is experimental, the APIs may change in future.

What we’ll implement in this blog?

Shared Element Sample Video

The source code is available on GitHub.

The demonstrated UI is inspired by the Flutter library example for a shoe store, which can be found here.


APIs for Creating Shared Elements in Jetpack Compose

In Compose, there are several high-level APIs to facilitate the creation of shared elements:

  • SharedTransitionLayout: This is the outermost layout necessary for implementing shared element transitions. It provides a SharedTransitionScope. Composables must be within a SharedTransitionScope to utilize shared element modifiers.

  • Modifier.sharedElement(): This modifier indicates to the SharedTransitionScope that the composable should be matched with another composable for the transition.

  • Modifier.sharedBounds(): This modifier signals to the SharedTransitionScope that the composable's bounds should be used as the container bounds for the transition. Unlike sharedElement(), sharedBounds() is intended for visually different content.

Required Dependencies

Start by creating a new Compose project in Android Studio. If you already have a project, ensure you’ve added the necessary dependencies for Jetpack Compose and Navigation.

dependencies {
    implementation "androidx.compose.foundation:foundation:1.7.0-alpha07"
    implementation "androidx.navigation:navigation-compose:2.7.7"
}

Creating an Immersive Navigation Experience with Shared Element Transitions

Let’s begin our sample implementation with the navigation setup. In this blog, we’ll use SharedTransitionLayout and Modifier.sharedElement.

As per the API instructions, the outermost layer should consist of the SharedTransitionLayout:

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ShoesHomeView() {
    // Sample data stored in Utils object
    val brandsList by remember {
        mutableStateOf(Utils.brandList)
    }

    // Sample data stored in Utils object
    val shoesList by remember {
        mutableStateOf(Utils.shoeList)
    }

    // SharedTransitionLayout wraps the content to enable shared element transitions
    SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {

        // NavController for navigation within the app
        val navController = rememberNavController()

        // NavHost defines the navigation graph and manages navigation
        NavHost(navController = navController, startDestination = "home") {

            // Home screen composable
            composable("home") {
                ShoesView(
                    navController = navController,
                    brandsList = brandsList,
                    shoesList = shoesList,
                    sharedTransitionScope = this@SharedTransitionLayout, // SharedTransitionLayout provides the SharedTransitionScope
                    animatedVisibilityScope = this@composable // This composable provides the AnimatedVisibilityScope
                )
            }

            // Detail screen composable
            composable(
                "shoe_detail/{index}",
                arguments = listOf(navArgument("index") { type = NavType.IntType })
            ) { backStackEntry ->
                val index = backStackEntry.arguments?.getInt("index")
                val shoeDetails = shoesList.getOrNull(index ?: 0)                // Display shoe detail if available
                if (shoeDetails != null) {
                    ShoesDetailView(
                        index = index ?: 0,
                        shoe = shoeDetails,
                        sharedTransitionScope = this@SharedTransitionLayout, // SharedTransitionLayout provides the SharedTransitionScope
                        animatedVisibilityScope = this@composable // This composable provides the AnimatedVisibilityScope
                    ) {
                        navController.popBackStack() // Navigate back when back icon is clicked
                    }
                }
            }
        }
    }
}

With this, we are all set with our navigation that comprises two screens, i.e., ShoesView(Home Screen) and ShoesDetailView.

If you want to use predictive back with shared elements, use the latest navigation-compose dependency, using the snippet from the preceding section.

Add android:enableOnBackInvokedCallback="true" to yourAndroidManifest.xml file to enable predictive back. Here is the visual.

Home View

Here, we are going to see the implementation for ShoesListView and element transition, for detailed UI implementation, you can visit GitHub repository.

From the above navigation setup, we have sharedTransitionScope and animatedVisibilityScope along with the shoes list. We will use HorizontalPager for applying animation while scrolling items along with the combination of currentPageOffsetFraction, pageOffset, and graphicsLayer modifier.

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun LazyItemScope.ShoesListView(
    navController: NavController,
    shoesList: List<Shoe>,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    // Pager state to manage horizontal paging through the shoe list
    val pagerState = rememberPagerState(pageCount = { shoesList.size })

    // HorizontalPager composable for displaying shoes horizontally
    HorizontalPager(state = pagerState, modifier = Modifier.padding(vertical = 8.dp)) { currentPage ->
        // Calculate current page offset for animation purposes
        val currentPageOffset =
            (pagerState.currentPage + pagerState.currentPageOffsetFraction - currentPage).coerceIn(-1f, 1f)

        // Calculate animations for shoe and card transformations
        val shoeRotationZ = lerp(-45f, 0f, 1f - currentPageOffset)
        val shoeTranslationX = lerp(150f, 0f, 1f - currentPageOffset)
        val shoesAlpha = lerp(0f, 1f, 1f - currentPageOffset)
        val shoesOffsetX = lerp(30f, 0f, 1f - currentPageOffset)

        val pageOffset = ((pagerState.currentPage - currentPage) + pagerState.currentPageOffsetFraction)
        val cardAlpha = lerp(0.4f, 1f, 1f - pageOffset.absoluteValue.coerceIn(0f, 1f))
        val cardRotationY = lerp(0f, 40f, pageOffset.coerceIn(-1f, 1f))
        val cardScale = lerp(0.5f, 1f, 1f - pageOffset.absoluteValue.coerceIn(0f, 1f))

        // ShoeItemView composable for displaying individual shoes
        ShoeItemView(
            shoe = shoesList[currentPage],
            cardScale = cardScale,
            shoeRotationZ = shoeRotationZ,
            shoeTranslationX = shoeTranslationX,
            shoesAlpha = shoesAlpha,
            shoesOffsetX = shoesOffsetX,
            cardAlpha = cardAlpha,
            cardRotationY = cardRotationY,
            sharedTransitionScope = sharedTransitionScope,
            animatedVisibilityScope = animatedVisibilityScope,
            currentPage = currentPage
        ) {
            navController.navigate("shoe_detail/${currentPage}") // Navigate to shoe detail screen
        }
    }
}

@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalAnimationSpecApi::class)
@Composable
fun LazyItemScope.ShoeItemView(
    shoe: Shoe,
    cardScale: Float,
    shoeRotationZ: Float,
    shoeTranslationX: Float,
    shoesAlpha: Float,
    shoesOffsetX: Float,
    cardAlpha: Float,
    cardRotationY: Float,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope,
    currentPage: Int,
    onClick: () -> Unit
) {
    with(sharedTransitionScope) {
        // Transformation for bounds of shared elements to customize how 
        //  the shared element transition animation runs
        val boundsTransform = BoundsTransform { initialBounds, targetBounds ->
            keyframes {
                durationMillis = 1000
                initialBounds at 0 using ArcMode.ArcBelow using FastOutSlowInEasing
                targetBounds at 1000
            }
        }

        // Transformation for text bounds
        val textBoundsTransform = { _: Rect, _: Rect -> tween<Rect>(550) }

        // Main container for shoe item
        Box(
            modifier =
            Modifier
                .fillMaxWidth()
                .padding(horizontal = 60.dp)
                .clickable {
                    onClick()
                },
            contentAlignment = Alignment.Center
        ) {
            // Container for card
            Box(
                modifier =
                Modifier
                    .fillMaxWidth()
                    .graphicsLayer {
                        rotationY = cardRotationY
                        alpha = cardAlpha
                        cameraDistance = 8 * density
                        scaleX = cardScale
                        scaleY = cardScale
                    }
                    .aspectRatio(0.8f)
            ) {
                // Background box for the shoe
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .clip(RoundedCornerShape(16.dp))
                        .background(shoe.color)
                        .sharedElement(
                            rememberSharedContentState(key = "${Constants.KEY_BACKGROUND}-$currentPage"),
                            animatedVisibilityScope = animatedVisibilityScope,
                            boundsTransform = boundsTransform
                        )
                )

                // Text displaying shoe name
                Text(
                    text = shoe.name,
                    style =
                    MaterialTheme.typography.titleLarge.copy(
                        color = MaterialTheme.colorScheme.onPrimary
                    ),
                    modifier = Modifier
                        .fillMaxWidth(0.7f)
                        .padding(16.dp)
                        .align(Alignment.TopStart)
                        .sharedElement(
                            rememberSharedContentState(key = "${Constants.KEY_SHOE_TITLE}-$currentPage"),
                            animatedVisibilityScope = animatedVisibilityScope,
                            boundsTransform = textBoundsTransform
                        )
                )

                // Favorite icon button
                IconButton(
                    onClick = { /*TODO*/ },
                    modifier = Modifier
                        .align(Alignment.TopEnd)
                        .padding(8.dp)
                        .sharedElement(
                            rememberSharedContentState(key = "${Constants.KEY_FAVOURITE_ICON}-$currentPage"),
                            animatedVisibilityScope = animatedVisibilityScope,
                            boundsTransform = textBoundsTransform
                        )
                ) {
                    Icon(
                        imageVector = Icons.Default.FavoriteBorder,
                        contentDescription = "Favorite",
                        tint = MaterialTheme.colorScheme.onPrimary,
                        modifier = Modifier.size(30.dp)
                    )
                }

                // Details icon button
                IconButton(onClick = { /*TODO*/ }, modifier = Modifier.align(Alignment.BottomEnd)) {
                    Icon(
                        imageVector = Icons.AutoMirrored.Filled.ArrowForward,
                        contentDescription = "Details",
                        tint = MaterialTheme.colorScheme.onPrimary,
                    )
                }
            }

            // Image displaying shoe image
            Image(
                painter = painterResource(id = shoe.image),
                contentDescription = "Shoe Image",
                modifier =
                Modifier
                    .fillParentMaxWidth()
                    .zIndex(1f)
                    .graphicsLayer {
                        rotationZ = shoeRotationZ
                        translationX = shoeTranslationX
                        alpha = shoesAlpha
                    }
                    .offset(x = shoesOffsetX.dp, y = 0.dp)
                    .sharedElement(
                        rememberSharedContentState(key = "${Constants.KEY_SHOE_IMAGE}-$currentPage"),
                        animatedVisibilityScope = animatedVisibilityScope,
                        boundsTransform = boundsTransform
                    )
            )
        }
    }
}

/**
* Function to interpolate between two values with a given amount.
* */
fun lerp(start: Float, stop: Float, amount: Float): Float {
    return start + (stop - start) * amount
}
  • To enable shared element transition, we used Modifier.sharedElement() API that takes an AnimatedVisibilityScope as a parameter.

  • The key parameter in rememberSharedContentState() is the important thing that helps in shared element transition, ensuring that the transition is applied consistently across screens.

  • The boundTransformation is used for customizing how the shared element transition animation runs.

  • To change the animation spec used for the size and position movement, you can specify a different boundsTransform parameter on Modifier.sharedElement(). This provides the initial Rect position and target Rect position.

  • You can explore more customization from the official doc.

With the above code, we are all set for home screen content with pager animations as well.

This post only has implementation until HomeView, to read the complete guide including shared element usage at DetailScreen, please visit this blog.

The post is originally published on canopas.com.


If you like what you read, be sure to hit
💖 button below! — as a writer it means the world!

I encourage you to share your thoughts in the comments section below. Your input not only enriches our content but also fuels our motivation to create more valuable and informative articles for you.

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