Shared Element Transtion 은 썸네일과 같이 어떤 아이템을 클릭(선택)하여 화면 전환이 이뤄질 때, 클릭(선택)한 아이템이 전환되는 화면내에 배치된 위치로 자연스럽게 연결되어 이동하는 방식의 애니메이션이다.
이번 글에선 Shared Element Transition 을 Compose Navigation 에선 어떻게 구현하면 되는지 알아보고, 구현할 때 주의해야할 점들을 살펴보도록 하겠다.
구현 하는 방법의 경우 크게 2가지가 있으며, 이는 공식 문서에도 잘 설명되어 있기 때문에, 빠르게 넘어가도록 하겠다.
이번 예제에서는 작품 목록 화면에서 작품 상세 화면을 넘어갈 때, 작품 이미지(ArtworkImageUrl)와 작품 제목(ArtworkTitle)을 전달해보도록 하겠다.
1) NavHost 를 SharedTranstionLayout 으로 감싸줌
Scaffold(
...
) { padding ->
SharedTransitionLayout {
NavHost(
modifier = modifier.fillMaxSize(),
navController = mainNavController.navController,
startDestination = mainNavController.startDestination,
) {
// 전달하는 화면에 sharedTranstionScope 파라미터로 전달
artworksScreen(
padding = padding,
navigateToArtworkDetail = mainNavController::navigateToArtworkDetail,
sharedTransitionScope = this@SharedTransitionLayout,
)
// 전달받는 화면에 sharedTransitionScope 파라미터로 전달
artworkDetailScreen(
padding = padding,
popBackStack = mainNavController::popBackStackIfNotArtworks,
sharedTransitionScope = this@SharedTransitionLayout,
)
...
}
}
}
2) 각 화면 Route 에 animatedVisiblityScope 파라미터 추가
@OptIn(ExperimentalSharedTransitionApi::class)
fun NavGraphBuilder.artworksScreen(
padding: PaddingValues,
navigateToArtworkDetail: (Int, String, String) -> Unit,
sharedTransitionScope: SharedTransitionScope,
) {
composable<MainTabRoute.Artworks> {
ArtworksRoute(
padding = padding,
navigateToArtworkDetail = navigateToArtworkDetail,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@composable,
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
fun NavGraphBuilder.artworkDetailScreen(
padding: PaddingValues,
popBackStack: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
) {
composable<Route.ArtworkDetail> {
ArtworkDetailRoute(
padding = padding,
popBackStack = popBackStack,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@composable,
)
}
}
3) 전달할 컴포넌트까지 sharedTransitionScope 와 animatedVisibilityScope 을 전달 또 전달
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun ArtworkItem(
artwork: UiArtwork,
onArtworkItemSelect: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier,
) {
with(sharedTransitionScope) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Box(
modifier = Modifier
// 작품 이미지 공유
.sharedElement(
rememberSharedContentState(key = artwork.artworkImageUrl),
animatedVisibilityScope = animatedVisibilityScope,
)
.fillMaxWidth()
.heightIn(max = 900.dp)
.clip(RoundedCornerShape(16.dp))
.border(
width = 1.5.dp,
color = Gray0.copy(alpha = 0.1f),
shape = RoundedCornerShape(16.dp),
)
.clickable(onClick = onArtworkItemSelect),
) {...}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, bottom = 20.dp)
.align(Alignment.BottomStart),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = artwork.title,
modifier = Modifier
// 작품 제목 공유
.sharedBounds(
sharedContentState = rememberSharedContentState(key = artwork.title),
animatedVisibilityScope = animatedVisibilityScope,
),
color = Gray0,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = Heading4,
)
}
}
}
}
// 전달받는 컴포넌트
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun ArtworkDetailItem(
artworkImageUrl: String,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier,
) {
with(sharedTransitionScope) {
Box(
modifier = modifier
// 작품 이미지 전달받음
.sharedElement(
rememberSharedContentState(key = artworkImageUrl),
animatedVisibilityScope = animatedVisibilityScope,
)
.fillMaxWidth()
.heightIn(max = 900.dp),
) {
NetworkImage(
imageUrl = artworkImageUrl,
contentDescription = "Artwork Image",
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FillWidth,
)
}
}
}
// 전달받는 컴포넌트
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun ArtworkDescription(
artworkDetail: UiArtworkDetail,
onShareClick: (String) -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier,
) {
with(sharedTransitionScope) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(MaterialTheme.colorScheme.background),
) {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
Text(
text = artworkDetail.title,
// 작품 제목 전달받음
modifier = Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(key = artworkDetail.title),
animatedVisibilityScope = animatedVisibilityScope,
)
.weight(1f),
color = MaterialTheme.colorScheme.onBackground,
style = Heading4,
)
...
}
}
}
1) LocalSharedTransitionScope, LocalNavAnimatedVisiblityScope 정의
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ZiineApp(modifier: Modifier = Modifier) {
...
Scaffold(
...
) { padding ->
SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout,
) {
NavHost(
modifier = modifier.fillMaxSize(),
navController = mainNavController.navController,
startDestination = mainNavController.startDestination,
) {
artworksScreen(
padding = padding,
navigateToArtworkDetail = mainNavController::navigateToArtworkDetail,
)
artworkDetailScreen(
padding = padding,
popBackStack = mainNavController::popBackStackIfNotArtworks,
)
...
}
}
}
}
}
단일 모듈이나, presentation 모듈의 방식으로 구성한다면 위의 방식으로, feature 기반 모듈화를 하였다면 전달을 주고 받는 두 feature 모듈이 모두 의존하는 모듈(ex. core:navigation, core:ui)등에 선언해주면 될 듯하다.
2) CompositionLocal 을 통해 각 화면 Route 에 AnimatedVisibilityScope 제공
// 전달하는 Route
fun NavGraphBuilder.artworksScreen(
padding: PaddingValues,
navigateToArtworkDetail: (Int, String, String) -> Unit
) {
composable<MainTabRoute.Artworks> {
CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this@composable) {
ArtworksRoute(
padding = padding,
navigateToArtworkDetail = navigateToArtworkDetail,
)
}
}
}
// 전달받는 Route
fun NavGraphBuilder.artworkDetailScreen(
padding: PaddingValues,
popBackStack: () -> Unit,
) {
composable<Route.ArtworkDetail> {
CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this@composable) {
ArtworkDetailRoute(
padding = padding,
popBackStack = popBackStack,
)
}
}
}
3) 컴포넌트 내에서 Local.current 방식으로 sharedTransitionScope, animatedVisibilityScope 를 참조
// 전달하는 컴포넌트
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun ArtworkItem(
artwork: UiArtwork,
onArtworkItemSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val sharedTransitionScope = LocalSharedTransitionScope.current
?: throw IllegalStateException("No SharedElementScope found")
val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
?: throw IllegalStateException("No AnimatedVisibilityScope found")
with(sharedTransitionScope) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Box(
modifier = Modifier
// 작품 이미지 공유
.sharedElement(
rememberSharedContentState(key = artwork.artworkImageUrl),
animatedVisibilityScope = animatedVisibilityScope,
)
.fillMaxWidth()
.heightIn(max = 900.dp)
.clip(RoundedCornerShape(16.dp))
.border(
width = 1.5.dp,
color = Gray0.copy(alpha = 0.1f),
shape = RoundedCornerShape(16.dp),
)
.clickable(onClick = onArtworkItemSelect),
) {
....
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, bottom = 20.dp)
.align(Alignment.BottomStart),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = artwork.title,
modifier = Modifier
// 작품 제목 공유
.sharedBounds(
sharedContentState = rememberSharedContentState(key = artwork.title),
animatedVisibilityScope = animatedVisibilityScope,
),
color = Gray0,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = Heading4,
)
}
}
}
}
// 전달받는 컴포넌트
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun ArtworkDetailItem(
artworkImageUrl: String,
modifier: Modifier = Modifier,
) {
val sharedTransitionScope = LocalSharedTransitionScope.current
?: throw IllegalStateException("No SharedElementScope found")
val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
?: throw IllegalStateException("No AnimatedVisibilityScope found")
with(sharedTransitionScope) {
Box(
modifier = modifier
.sharedElement(
rememberSharedContentState(key = artworkImageUrl),
animatedVisibilityScope = animatedVisibilityScope,
)
.fillMaxWidth()
.heightIn(max = 900.dp),
) {
NetworkImage(
imageUrl = artworkImageUrl,
contentDescription = "Artwork Image",
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FillWidth,
)
}
}
}
// 전달받는 컴포넌트
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun ArtworkDescription(
artworkDetail: UiArtworkDetail,
onShareClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val sharedTransitionScope = LocalSharedTransitionScope.current
?: throw IllegalStateException("No SharedElementScope found")
val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
?: throw IllegalStateException("No AnimatedVisibilityScope found")
with(sharedTransitionScope) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(MaterialTheme.colorScheme.background),
) {
...
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
Text(
text = artworkDetail.title,
modifier = Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(key = artworkDetail.title),
animatedVisibilityScope = animatedVisibilityScope
)
.weight(1f),
color = MaterialTheme.colorScheme.onBackground,
style = Heading4,
)
...
}
...
}
}
}
둘 중에서 뭐가 더 나은 방법 같은지 묻는다면, 개인적으론 보일러플레이트가 적은 2번이라고 생각은 하는데, CompositionLocal 을 통해 암시적으로 데이터를 전달하는 방식은 코드를 읽을 때 의존성이 명확하게 보이지 않아 이해 및 유지보수의 어려움이 발생할 수 있기 때문에, 남발하여 사용하는 것은 지양할 필요가 있다.
https://x.com/JimSproch/status/1395801409229033478
위의 구현 방법을 소개할 때 확인했던 것 처럼, 애니메이션을 적용할 컴포넌트, 해당 컴포넌트가 포함된 화면 모두에게 sharedElementScope, animatedVisibilityScope 를 전달해줘야하기 때문에,
기존에 작성해둔 Preview 들도 수정을 해줘야 한다.
@OptIn(ExperimentalSharedTransitionApi::class)
@DevicePreview
@Composable
private fun ArtworksScreenPreview(
@PreviewParameter(ArtworksPreviewParameterProvider::class)
uiState: ArtworksUiState,
) {
ZiineTheme {
SharedTransitionLayout {
// 임의의 Animation
AnimatedVisibility(visible = true) {
ArtworksScreen(
padding = PaddingValues(),
uiState = uiState,
onAction = {},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedVisibility,
)
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@ComponentPreview
@Composable
private fun ArtworkItemPreview() {
ZiineTheme {
SharedTransitionLayout {
// // 임의의 Animation
AnimatedVisibility(visible = true) {
ArtworkItem(
artwork = UiArtwork(
id = 1,
artworkImageUrl = "",
artist = UiArtist(
id = 1,
name = "Artist Name",
profileImageUrl = "",
),
title = "Artwork Name",
),
onArtworkItemSelect = {},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedVisibility,
)
}
}
}
}
// 화면 프리뷰
@OptIn(ExperimentalSharedTransitionApi::class)
@DevicePreview
@Composable
private fun ArtworksScreenPreview(
@PreviewParameter(ArtworksPreviewParameterProvider::class)
uiState: ArtworksUiState,
) {
ZiineTheme {
SharedTransitionLayout {
// 임의의 Animation
AnimatedVisibility(visible = true) {
// 반드시 CompositionLocalProvider 를 통해 LocalSharedTransitionScope, LocalNavAnimatedVisibilityScope 를 주입해야 함
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout,
LocalNavAnimatedVisibilityScope provides this@AnimatedVisibility,
) {
ArtworksScreen(
padding = PaddingValues(),
uiState = uiState,
onAction = {},
)
}
}
}
}
}
// 컴포넌트 프리뷰
@OptIn(ExperimentalSharedTransitionApi::class)
@ComponentPreview
@Composable
private fun ArtworkItemPreview() {
ZiineTheme {
SharedTransitionLayout {
// 임의의 Animation
AnimatedVisibility(visible = true) {
// 반드시 CompositionLocalProvider 를 통해 LocalSharedTransitionScope, LocalNavAnimatedVisibilityScope 를 주입해야 함
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout,
LocalNavAnimatedVisibilityScope provides this@AnimatedVisibility,
) {
ArtworkItem(
artwork = UiArtwork(
id = 1,
artworkImageUrl = "",
artist = UiArtist(
id = 1,
name = "Artist Name",
profileImageUrl = "",
),
title = "Artwork Name",
),
onArtworkItemSelect = {},
)
}
}
}
}
}
Preview 를 어떻게 구성해야 하는지는 공식 문서에 별다른 언급이 없어 시행착오를 통해 겨우 겨우 프리뷰들을 성공적으로 띄울 수 있었다.
지금까지의 일반적인 목록 화면 -> 상세 화면의 전환 플로우는 다음과 같았다.
이때 shared element transtion 을 적용하려한다면, shared element 의 대상이 되는 데이터는 상세 화면으로 전달하여, 데이터가 API 를 통해서 매핑(다시 로드)되지 않도록 해야한다.
일반적으로 목록 화면에서 상세 화면으로 전달해야할 데이터들은 대략적으로 imageUrl, title, 그밖에 description 등이 있을텐데, 이들을 navigation 의 argument 로 전달하여 데이터가 새로고침되지 않도록 하자.
그렇지 않으면 다음과 같이 매우 부자연스러운 애니메이션이 동작하게 된다.
Shared Element Transition 관련 전체 코드는 아래 레포에서 확인할 수 있습니다.
https://github.com/Nexters/ziine-android
레퍼런스)
https://developer.android.com/develop/ui/compose/animation/shared-elements?hl=ko
https://github.com/WonJoongLee/FridayMovie
https://stackoverflow.com/questions/78656716/jetpack-compose-preview-for-screen-that-have-sharedtransitionscope-animatedvisi
https://github.com/wisemuji/compose-would-you-rather-game
https://x.com/JimSproch/status/1395801409229033478