Compose Navigation Shared Element Transition

이지훈·2025년 2월 6일
0

Navigation

목록 보기
6/6
post-thumbnail

서두

Shared Element Transtion 은 썸네일과 같이 어떤 아이템을 클릭(선택)하여 화면 전환이 이뤄질 때, 클릭(선택)한 아이템이 전환되는 화면내에 배치된 위치로 자연스럽게 연결되어 이동하는 방식의 애니메이션이다.

이번 글에선 Shared Element Transition 을 Compose Navigation 에선 어떻게 구현하면 되는지 알아보고, 구현할 때 주의해야할 점들을 살펴보도록 하겠다.

본론

구현 하는 방법의 경우 크게 2가지가 있으며, 이는 공식 문서에도 잘 설명되어 있기 때문에, 빠르게 넘어가도록 하겠다.

이번 예제에서는 작품 목록 화면에서 작품 상세 화면을 넘어갈 때, 작품 이미지(ArtworkImageUrl)와 작품 제목(ArtworkTitle)을 전달해보도록 하겠다.

1. Props Drilling 형태로 sharedElementScope, animatedVisiblityScope 를 파라미터로 전달

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,
                )
             ...
        }
    }
}

2. CompositionLocal 을 통해 sharedElementScope, animatedVisiblityScope 를 제공

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

Preview

위의 구현 방법을 소개할 때 확인했던 것 처럼, 애니메이션을 적용할 컴포넌트, 해당 컴포넌트가 포함된 화면 모두에게 sharedElementScope, animatedVisibilityScope 를 전달해줘야하기 때문에,
기존에 작성해둔 Preview 들도 수정을 해줘야 한다.

1. Props Drilling 형태로 sharedElementScope, animatedVisiblityScope 를 파라미터로 전달한 경우

@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,
                )
            }
        }
    }
}

2. CompositionLocal 을 통해 sharedElementScope, animatedVisiblityScope 를 제공한 경우

// 화면 프리뷰
@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 transition 으로 넘길 데이터는 navigation 의 argument 로 전달해야한다.

지금까지의 일반적인 목록 화면 -> 상세 화면의 전환 플로우는 다음과 같았다.

  1. 목록 화면 API 조회
  2. 목록 중에 한 아이템을 선택(클릭)
  3. 선택된 아이템의 고유 값(id)을 전환되는 화면(상세 화면)에 전달
  4. 상세 화면에 진입하여 전달 받은 id 를 통해 상세 조회 API 호출

이때 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

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글

관련 채용 정보