필자가 회사에서 업무를 진행하다 보면, 디자이너로부터 "리스트 아이템을 클릭하면 그 아이템이 그대로 상세 화면으로 확장되는 애니메이션을 넣어주세요" 같은 요청을 받게 된다. iOS에서는 Hero Animation으로 비교적 익숙하게 만들 수 있는 효과인데, 안드로이드에서는 예전 View 시스템 시절에 Shared Element Transition을 구현하느라 한참 헤맸던 기억이 있다.
최근 안드로이드 관련 기술 블로그를 확인하다가 Compose의 Shared Element Transitions 가이드를 보게 되었고, Compose에서는 SharedTransitionLayout 한 줄로 매우 깔끔하게 처리된다는 것을 알게 되었다.
이번 글에서는 Jetpack Compose의 SharedTransitionLayout과 sharedElement/sharedBounds Modifier를 사용해, 이미지 확대·리스트→상세·텍스트 변환·다중 요소 전환 4가지 패턴을 구현해 보고자 한다.
핵심 API 살펴보기
Shared Element Transition을 구성하는 핵심 API는 다음 4가지이다.
1. SharedTransitionLayout
공유 전환의 최상위 컨테이너이다. 모든 공유 요소는 반드시 이 레이아웃 안쪽에 있어야 하며, SharedTransitionScope를 제공해 하위 컴포저블에서 sharedElement Modifier를 사용할 수 있게 해준다.
2. Modifier.sharedElement()
실제로 "공유"할 요소에 붙이는 Modifier. 가장 중요한 건 key이다. 양쪽 화면에서 동일한 key를 사용해야 Compose가 "이 두 요소는 동일한 것"이라고 인식하고 부드럽게 보간해준다.
3. Modifier.sharedBounds()
sharedElement가 요소 자체를 보간한다면, sharedBounds는 영역(경계)을 보간한다. 컨테이너 카드처럼 크기와 위치가 바뀌면서 안쪽 콘텐츠는 다른 경우에 쓰인다.
4. AnimatedContent
Shared Element는 반드시 AnimatedContent 또는 AnimatedVisibility 내부에서만 동작한다. 그래서 this@AnimatedContent로 Scope를 전달해 주는 패턴이 거의 모든 예제에서 반복된다.
1. Image Expansion - 썸네일에서 전체 화면으로
가장 직관적인 예제부터 시작해보자. 작은 썸네일을 클릭하면 큰 이미지로 확장되는 형태이다.
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun ImageExpansionExample() {
var isExpanded by remember { mutableStateOf(false) }
SharedTransitionLayout {
AnimatedContent(
targetState = isExpanded,
transitionSpec = {
fadeIn(tween(300)) togetherWith fadeOut(tween(300))
},
label = "image_expansion"
) { expanded ->
if (expanded) {
ExpandedImageView(
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@AnimatedContent,
onClose = { isExpanded = false }
)
} else {
ThumbnailImageView(
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@AnimatedContent,
onClick = { isExpanded = true }
)
}
}
}
}
이 예제에서 가장 중요한 부분은 양쪽 뷰에서 동일한 key를 사용한다는 점이다. 썸네일의 컨테이너와 확장된 컨테이너 모두 "image_container"라는 key로 묶여 있어야 Compose가 둘을 동일 객체로 인식한다.
// 썸네일
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.sharedBounds(
rememberSharedContentState(key = "image_container"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
}
)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1976D2))
) {
Image(
// ...
modifier = Modifier
.size(120.dp)
.sharedElement(
rememberSharedContentState(key = "image"),
animatedVisibilityScope = animatedContentScope
)
)
}
컨테이너에는 sharedBounds를, 안쪽 이미지에는 sharedElement를 사용한 점에 주목하자. 컨테이너는 크기·모양이 변하면서 보간되어야 하고, 이미지 자체는 1:1 대응이라 sharedElement로 충분하다.
boundsTransform에 spring(DampingRatioMediumBouncy, StiffnessLow)를 적용하면 단순한 선형 보간이 아니라 약간 튀어 오르는 느낌의 자연스러운 애니메이션이 만들어진다. 필자가 직접 여러 값을 시도해 본 결과, 이미지 확대 같은 동작에는 이 조합이 가장 만족스러웠다.
2. List to Detail - 아이템마다 다른 진입 애니메이션
리스트에서 아이템을 클릭하면 상세 화면으로 펼쳐지는 패턴이다. 여기서는 한 가지 재미있는 응용을 추가했는데, 아이템 ID에 따라 진입 방향을 다르게 만들었다.
SharedTransitionLayout {
AnimatedContent(
targetState = selectedItem,
transitionSpec = {
// 아이템 ID에 따라 다른 애니메이션 적용
val enterTransition = when (targetState) {
1 -> fadeIn(tween(300)) + slideInVertically(tween(300)) { -it / 2 }
2 -> fadeIn(tween(300)) + slideInHorizontally(tween(300)) { -it }
3 -> fadeIn(tween(300)) + slideInVertically(tween(300)) { it }
4 -> fadeIn(tween(300)) + scaleIn(tween(300), initialScale = 0.8f)
else -> fadeIn(tween(300))
}
val exitTransition = when (initialState) {
1 -> fadeOut(tween(200)) + slideOutVertically(tween(200)) { it / 2 }
2 -> fadeOut(tween(200)) + slideOutHorizontally(tween(200)) { it }
3 -> fadeOut(tween(200)) + slideOutVertically(tween(200)) { -it }
4 -> fadeOut(tween(200)) + scaleOut(tween(200), targetScale = 0.8f)
else -> fadeOut(tween(200))
}
enterTransition togetherWith exitTransition
},
label = "list_detail"
) { selected ->
// ...
}
}
transitionSpec 안에서 targetState와 initialState를 조건으로 사용할 수 있다는 점이 흥미로웠다. 동일한 컴포넌트 안에서도 어떤 상태로 전환되느냐에 따라 애니메이션을 완전히 다르게 적용할 수 있는 것이다.
리스트 아이템과 상세 카드는 같은 key로 묶었다. 이미지는 "image_${item.id}", 제목은 "title_${item.id}", 카드 컨테이너는 "card_${item.id}" 식으로 ID를 key에 포함시키는 게 핵심이다.
// 리스트 아이템 안의 동그란 썸네일
Box(
modifier = Modifier
.size(60.dp)
.sharedElement(
rememberSharedContentState(key = "image_${item.id}"),
animatedVisibilityScope = animatedContentScope
)
.clip(CircleShape)
.background(item.color)
)
// 상세 화면의 큰 헤더 이미지
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.sharedElement(
rememberSharedContentState(key = "image_${item.id}"), // 동일 key
animatedVisibilityScope = animatedContentScope
)
.clip(RoundedCornerShape(8.dp))
.background(item.color)
)
둘 다 "image_${item.id}" 라는 동일한 key를 쓰고 있다. 한쪽은 60dp 원형, 다른쪽은 200dp 사각형인데도 Compose가 자연스럽게 보간해서 모양과 크기가 함께 변환되어가는 모습이 매우 매끄럽다.
3. Text Transformation - 텍스트 크기 변환
텍스트도 똑같이 공유 요소로 사용할 수 있다. 16sp 텍스트와 32sp 텍스트가 동일한 key를 가지면, 글자 크기가 부드럽게 보간된다.
// Collapsed
Text(
text = "Jetpack Compose",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "text_title"),
animatedVisibilityScope = animatedContentScope
)
)
// Expanded
Text(
text = "Jetpack Compose",
fontSize = 32.sp, // 크기만 다름
fontWeight = FontWeight.Bold,
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "text_title"), // 동일 key
animatedVisibilityScope = animatedContentScope
)
)
큰 작업 없이 단지 같은 key를 양쪽에 붙여주는 것만으로도 자연스러운 폰트 사이즈 변환 애니메이션이 만들어진다. 직접 animateFloatAsState로 sp를 보간해본 적이 있는데, 비교조차 안 될 정도로 이쪽이 훨씬 간결하다는 생각이 든다.
4. Multiple Elements - 여러 요소 동시 전환
마지막은 한 화면에 여러 개의 공유 요소가 동시에 움직이는 패턴이다. 프로필 리스트에서 아이템을 클릭하면 아바타·이름·역할 세 가지가 함께 전환된다.
// 리스트 아이템
Box(
modifier = Modifier
.size(50.dp)
.sharedElement(
rememberSharedContentState(key = "avatar_${profile.id}"),
animatedVisibilityScope = animatedContentScope
)
.clip(CircleShape)
.background(profile.color)
)
Text(
text = profile.name,
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "name_${profile.id}"),
animatedVisibilityScope = animatedContentScope
)
)
Text(
text = profile.role,
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "role_${profile.id}"),
animatedVisibilityScope = animatedContentScope
)
)
상세 화면에서도 동일한 3개의 key를 그대로 사용한다. 차이는 단지 크기·위치·정렬뿐이다. 50dp 아바타가 120dp로 커지고, 16sp 이름이 24sp로 커지면서 위치가 화면 중앙으로 이동하는 흐름이 한 번에 일어난다.
여러 요소가 동시에 움직일 때 가장 신경 쓰이는 부분은 z-index인데, 기본 동작으로도 매우 자연스러운 결과가 나와서 별도 조정 없이도 충분했다. 필요하면 zIndexInOverlay로 미세 조정이 가능하다.
구현하면서 신경 써야 했던 부분
1. key는 양쪽에서 정확히 동일해야 한다
처음에 한쪽은 "image", 다른쪽은 "image_main"으로 다르게 줬더니 당연하게도 애니메이션이 동작하지 않았다. 동적 리스트에서는 반드시 아이템 ID를 key에 포함시켜서 다른 아이템과 충돌하지 않도록 해야 한다.
2. AnimatedVisibilityScope를 정확히 전달
this@SharedTransitionLayout, this@AnimatedContent 형태로 Scope를 전달하는 패턴이 다소 장황하긴 하지만, 이 부분을 잘못 넘기면 컴파일은 통과해도 런타임에 애니메이션이 작동하지 않는다. 헬퍼 함수로 한 번 감싸두면 반복 작업을 줄일 수 있다.
3. sharedElement vs sharedBounds 선택
- sharedElement: 동일한 콘텐츠인데 크기·위치만 다른 경우 (이미지, 텍스트 등)
- sharedBounds: 영역은 같지만 안쪽 콘텐츠가 다른 경우 (컨테이너 카드 등)
이 둘을 헷갈리지 않도록 처음부터 잘 구분해서 적용하는 게 좋은 것 같다.
4. ExperimentalSharedTransitionApi
아직 Experimental API라서 @OptIn(ExperimentalSharedTransitionApi::class)를 매번 붙여줘야 한다. API가 안정화되면 사라질 부분이긴 하지만 현재로서는 어쩔 수 없다.
마무리
이번 글에서는 Jetpack Compose의 SharedTransitionLayout을 사용해 이미지 확대·리스트→상세·텍스트 변환·다중 요소 전환 4가지 패턴을 구현해 보았다.
View 시스템 시절의 Shared Element Transition을 떠올려보면 매번 Transition 설정, transitionName 매칭, postpone/start 호출 등을 신경 써야 했는데, Compose에서는 같은 key 하나만 잘 맞춰주면 알아서 보간된다는 점이 정말 인상 깊었던 것 같다.
다음에는 Navigation Compose와 함께 사용하는 패턴, 그리고 LazyColumn 안에서의 Shared Element 사용 시 주의점에 대해서도 한번 정리해볼 예정이다.
필자도 아직 Compose의 Shared Element API에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.
해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com
'Android > Jetpack Compose' 카테고리의 다른 글
| [Compose] 스티커 캔버스를 만들어보자 - 복합 제스처, Spring 물리, 필오프(Peel-Off) 애니메이션 (0) | 2026.06.14 |
|---|---|
| [Compose] Canvas로 원형 다이얼(Dial) 컴포넌트를 직접 구현해보자 (1) | 2026.06.07 |
| [Compose] Material 3의 SwipeToDismissBox를 구현해보자 (0) | 2026.05.25 |
| [Compose] TextMeasurer와 Canvas로 커스텀 텍스트 효과를 만들어보자 - Faded, Warped, Typewriter (0) | 2026.05.02 |
| [Compose] TabRow와 ScrollableTabRow 사이에서 고민될 때, SubcomposeLayout으로 반응형 탭을 만들어보자 (0) | 2026.05.01 |