Jetpack Compose 에서 Shared element transitions 적용기

shin_stealer·2025년 5월 25일
0

이번 포스팅에서는 Jetpack Compose에서 SharedTransitionLayout과 sharedElement()를 이용해 Shared Element Transition을 구현한 경험을 공유합니다. 공식 사이트에 예제가 잘 올라와 있지만, 막상 적용하는데 시행착오가 있었습니다. 간단한 구현에 필요한 엑기스들만 요약해서 올려봅니다.

먼저 예시에 사용된 프로젝트는 Google 공식 샘플 앱인 nowinandroid 의 아키텍처를 채용하고 있는 점 참고부탁드립니다.

  • MVVM 아키텍쳐
  • Multi Modules

🎯 구현 목표

책 리스트 화면 -> 상세 화면으로 전환 시,
책 표지 이미지가 자연스럽게 전환되도록 Shared Element Transition 구현.

📝 구현 포인트 (딱 이것만 기억합시다.)

✅ 1. 전체 UI를 SharedTransitionLayout으로 감싸기

✅ 2. 공유할 요소에 sharedTransitionScope, animatedVisibilityScope 전달

✅ 3. 요소마다 고유 key와 애니메이션 효과 설정

📝 구현 과정

1. 애니메이션 적용할 전체 부분을 SharedTransitionLayout 로 씌우기.

본 예시의 경우, 두 개의 화면이 존재합니다.

  • 리스트 화면(SearchBookScreen),
  • 상세 화면(SearchBookDetailScreen).

그리고 화면 이동 시 공유될 element 가 있습니다.

  • 리스트 화면 item의 이미지 (element A)
  • 상세 화면의 이미지 (element B)

리스트 화면의 item 을 클릭했을 때, 상세 화면으로 이동하며
가 공유됩니다.

먼저 애니메이션 적용할 전체 부분은 Navigation Graph 에 적용했습니다.

// MainActivity
 SharedTransitionLayout {
   NavigationGraph(navController = navController, sharedTransitionScope = this)
 }
    

2. 애니메이션을 주고자 하는 각 element 에 sharedTransitionScope, animatedVisibilityScope 전달하기.

각 Screen -> element 까지 sharedTransitionScope을 전달합니다.
sharedTransitionScope은 SharedTransitionLayout 에서 받아올 수 있습니다.

// MainActivity
@SuppressLint("UnusedSharedTransitionModifierParameter")
@OptIn(ExperimentalAnimationApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun NavigationGraph(navController: NavHostController, sharedTransitionScope: SharedTransitionScope) {
    NavHost(
        navController = navController,
        startDestination = MY_LIBRARY_ROUTE
    ) {
        myLibraryScreen(
            onNavigateToSearch = { navController.navigateToSearch() }
        )

        searchBookScreen(
            sharedTransitionScope = sharedTransitionScope,
            onNavigateBack = { navController.popBackStack()},
            onNavigateToDetail = { isbn, cover ->
                navController.navigateToSearchBookDetail(isbn, cover)
            }
        )

        searchBookDetailScreen(
            sharedTransitionScope = sharedTransitionScope,
            onNavigateBack = { navController.popBackStack() }
        )
    }
}
// 
@OptIn(ExperimentalSharedTransitionApi::class)
fun NavGraphBuilder.searchBookScreen(
    sharedTransitionScope: SharedTransitionScope,
    onNavigateBack: () -> Unit,
    onNavigateToDetail: (isbn: String, cover: String) -> Unit,
) {
    composable(SEARCH_ROUTE) {
        SearchBookScreen(
            sharedTransitionScope = sharedTransitionScope,
            animatedVisibilityScope = this,
            onNavigateBack = onNavigateBack,
            onNavigateToDetail = onNavigateToDetail,
        )
    }
}

이번에는 animatedVisibilityScope 를 전달합니다. animatedVisibilityScope 은 composable로 부터 받아올 수 있습니다.

SearchScreen 의 매개변수로 sharedTransitionScope, animatedVisibilityScope 을 넘겨주었습니다.

3. 각 element 에 key 제공, 애니메이션 효과 적용하기

이제 넘겨받은 sharedTransitionScope, animatedVisibilityScope 변수를 원하는 element 에 연결해줍니다.

### SEARCH BOOK SCREEN
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class, ExperimentalSharedTransitionApi::class)
@Composable
fun SearchBookScreen(
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope,
    onNavigateBack: () -> Unit,
    onNavigateToDetail: (isbn: String, cover: String) -> Unit,
    viewModel: SearchViewModel = hiltViewModel(),
)
... 화면 구성..
### SEARCH BOOK SCREEN
        with(sharedTransitionScope) {
            SubcomposeAsyncImage(
                model = book.cover,
                contentDescription = null,
                modifier = Modifier
                    .sharedElement(
                        rememberSharedContentState(key = "image/${book.cover}"),
                        animatedVisibilityScope = animatedVisibilityScope,
                        boundsTransform = {initial, taget -> tween(durationMillis = 1000)}
                    )
                    .size(width = 90.dp, height = 140.dp)
                    .padding(start= 10.dp, bottom= 10.dp)

마찬가지로 SearchBookDetailScreen 의 매개변수로 sharedTransitionScope, animatedVisibilityScope 을 넘겨주었습니다.

### SEARCH BOOK DETAIL SCREEN
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
fun SearchBookDetailScreen(
    isbn: String,
    cover: String,
    onNavigateBack: () -> Unit,
    viewModel: SearchDetailViewModel = hiltViewModel(),
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
... 화면 구성 소스
### SEARCH BOOK DETAIL SCREEN
 with(sharedTransitionScope) {
         SubcomposeAsyncImage(
            model = cover,
            contentDescription = "책 표지",
            modifier = Modifier
            .sharedElement(
                rememberSharedContentState(key = "image/$cover"),
                animatedVisibilityScope = animatedVisibilityScope,
                boundsTransform = {initial, taget -> tween(durationMillis = 1000)}
			)
            ... 

저의 경우에는 애니메이션을 적용할 sharedElements는 SubcomposeAsyncImage 였습니다.

애니메이션이 공유될 두 가지의 elements 에
with(sharedTransitionScope) {} 블럭으로 SubcomposeAsyncImage 을 감쌉니다.

그러면 modifier.sharedElement() 속성을 사용할 수 있게됩니다.

.sharedElement() 안에는 이제 unique 한 key 값을 넣어야 합니다.
그리고 animatedVisibilityScope 속성에는 매개변수로 받아온 변수를 세팅합니다.

마지막으로 원하는 애니메이션 효과를 적용합니다. 이 부분은 생략해도 기본 boundsTransform 애니메이션이 적용되었습니다.

더 자세한 소스는 아래 링크에서 확인 가능합니다.
https://github.com/stevey-sy/odok-compose

더 자세한 동작 원리와 효과들은 공식 가이드에서 확인 가능합니다.
https://developer.android.com/develop/ui/compose/animation/shared-elements

profile
I am a Blacksmith.

0개의 댓글