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?
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.
In Compose, there are several high-level APIs to facilitate the creation of shared elements:
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"
}
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 your AndroidManifest.xml file to enable predictive back. Here is the visual.
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
}
With the above code, we are all set for home screen content with pager animations as well.
On Detail View, we will be applying the same keyed sharedElement()
in our composable so that transition is applied for that particular item.
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class,
ExperimentalAnimationSpecApi::class
)
@Composable
fun ShoesDetailView(
index: Int,
shoe: Shoe,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onClick: () -> Unit = {}
) {
with(sharedTransitionScope) {
// Define bounds transform for text elements
val textBoundsTransform = { _: Rect, _: Rect -> tween<Rect>(550) }
// Scaffold composable for the main layout
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
// TopAppBar for displaying title and navigation icon
TopAppBar(
title = {
Text(
text = shoe.name,
color = MaterialTheme.colorScheme.onPrimary,
// Apply shared element transition to the title
// with same key as sent from HomeView
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "${Constants.KEY_SHOE_TITLE}-$index"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = textBoundsTransform
)
)
},
navigationIcon = {
// Back button
IconButton(
onClick = {
onClick()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onPrimary
)
}
},
actions = {
// Favorite icon button
IconButton(
onClick = { /*TODO*/ },
// Apply shared element transition to the favorite icon
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "${Constants.KEY_FAVOURITE_ICON}-$index"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = textBoundsTransform
)
) {
Icon(
imageVector = Icons.Filled.FavoriteBorder,
contentDescription = "Favorite",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(30.dp)
)
}
},
modifier = Modifier,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = shoe.color
)
)
},
containerColor = shoe.color,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
// Define bounds transform for shared element transition
val boundsTransform = BoundsTransform { initialBounds, targetBounds ->
keyframes {
durationMillis = 1000
initialBounds at 0 using ArcMode.ArcBelow using FastOutSlowInEasing
targetBounds at 1000
}
}
// Main content area
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(shoe.color)
// Apply shared element transition to the background
.sharedElement(
rememberSharedContentState(key = "${Constants.KEY_BACKGROUND}-$index"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = boundsTransform
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.verticalScroll(rememberScrollState())
) {
// Shoe image
Image(
painter = painterResource(id = shoe.image),
contentDescription = "Shoe Image",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.5f)
// Apply shared element transition to the shoe image
.sharedElement(
rememberSharedContentState(key = "${Constants.KEY_SHOE_IMAGE}-$index"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = boundsTransform
)
)
// Detail text views
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surface)
) {
ShoeDetailTextView(text = shoe.details.description)
ShoeDetailTextView(text = "Size: ${shoe.details.size}")
ShoeDetailTextView(text = "Price: $${shoe.details.price}")
ShoeDetailTextView(text = "Ratings: ${shoe.details.ratings}")
// Available colors
Row(verticalAlignment = Alignment.CenterVertically) {
ShoeDetailTextView(text = "Available Colors:")
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
// Color boxes for available colors
shoe.details.availableColors.forEach { color ->
Box(
modifier = Modifier
.height(20.dp)
.width(40.dp)
.border(
1.dp,
MaterialTheme.colorScheme.onSurface,
RoundedCornerShape(4.dp)
)
.background(color, RoundedCornerShape(4.dp))
.padding(8.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}
}
}
}
}
@Composable
fun ShoeDetailTextView(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(16.dp)
)
}
ShoesDetailView
composable represents the detailed view of a shoe. It utilizes shared element transitions for smooth transitions between elements.rememberSharedContentState()
function is crucial for defining the shared element keys, which are essential for coordinating transitions between composables.BoundsTransform
function is used to define the transformation of bounds during the shared element transition, providing customization for the transition effect.With this setup, we are finally done with the exploration of shared element transition in compose with navigation!
In this blog post, we delved into shared element transitions in Compose with Navigation, exploring their significance and implementation.
Through key APIs and practical examples, we learned how to seamlessly navigate between screens while maintaining visual continuity.
Shared element transitions enhance user engagement and provide a cohesive experience, contributing to a more immersive mobile app design.