Material Motion for Jetpack Compose
Jetpack Compose에서 사용할 수 있는 Material Motion 라이브러리를 소개합니다. 🎭
요즘 Jetpack Compose에 관심을 가지는 분들이 많은데요. 대부분은 Android Developers에 정리된 글이나 코드랩을 통해서, 혹은 간단한 샘플을 만들어보면서 학습하고 계실 것 같습니다. 저도 마찬가지구요.
하지만 자세하게 알 수 없는 것도 군데군데 보여서, 그런 것들 중에 한가지를 다룰 생각입니다. 앱은 보통 화면 단위로 UI 작업을 나누게 되는데요. 오늘은 Jetpack Compose에서 화면 전환 효과를 구현하는 방법을 살펴보겠습니다.
많은 앱에서 하단 탭 형태의 UX를 제공합니다.

Compose로 작성하면 간단합니다.
Scaffold(bottomBar = { ... }) { innerPadding ->
BottomTabsContents(selectedTab)
}
탭을 선택하면 컨텐츠가 변경되는데, 이 때 전환 효과를 줄 순 없을까요?
Crossfade ✨
마침 Compose에서 제공하는 High-level Animation API 중에 전환 효과가 있었습니다. Crossfade
를 사용하면 간단한 fade 효과를 넣을 수 있었어요.
Scaffold(bottomBar = { ... }) { innerPadding ->
Crossfade(
targetState = selectedTab,
modifier = Modifier.padding(innerPadding)
) { currentTab ->
BottomTabsContents(currentTab)
}
}

문득 Crossfade가 어떻게 동작하는지 궁금해졌습니다. 🤔

그림으로 보면 간단합니다. (1) 컨텐츠가 그려지는 부분을 Box로 두고, 이전/현재 탭의 컨텐츠를 child Box에 그려줍니다. (2) 그리고나서 각 child Box의 alpha 속성을 조절하는 방식입니다.
이전/현재 탭의 컨텐츠는, 상태가 변경될 때마다 CrossfadeAnimationItem
라는 클래스를 통해서 alpha 애니메이션을 수행합니다.
@Composable
fun <T> Crossfade(
targetState: T,
...
content: @Composable (T) -> Unit
) {
... if (targetChanged || items.isEmpty()) {
... items.clear()
keys.mapTo(items) { key ->
CrossfadeAnimationItem(key) {
val alpha by transition.animateFloat(
transitionSpec = { animationSpec }
) { if (it == key) 1f else 0f }
Box(Modifier.graphicsLayer { this.alpha = alpha }) {
content(key)
}
}
}
} else if (transitionState.currentState ==
transitionState.targetState) {
items.removeAll { it.key != transitionState.targetState }
}
Box(modifier) {
items.fastForEach {
key(it.key) {
it.content()
}
}
}
}
≒
Box(modifier) {
Box(Modifier.graphicsLayer { this.alpha = 1f -> 0f }) {
BottomTabsContents(previousTab)
}
Box(Modifier.graphicsLayer { this.alpha = 0f -> 1f }) {
BottomTabsContents(currentTab)
}
}
좀 더 간단한 코드로 변환하면, 위처럼 생각할 수도 있습니다.

그런데 적용된 효과를 보면, 조금 아쉽지 않나요? 👀Crossfade
의 내부구조를 확인해 봤으니, 더 나은 전환 효과를 직접 구현해 볼 수 있을 것 같습니다.
Material Motion in MDC ☄️
전환 효과를 구현하려면 디자인에 대한 감각이 있어야 할 것 같은데요. 다행히 Material Design에는 Motion System도 정의되어 있습니다.
아래에 보이는 것처럼, 4가지 전환 패턴이 있습니다.

이 중에 한 가지를 구현해 볼까요? 🙋♂️Crossfade
대신 Fade Through 패턴을 적용해봅시다!
Fade Through
CrossfadeAnimationItem
를 기억하시나요?CrossfadeAnimationItem
를 제외한 Crossfade
의 나머지 부분은, 다른 전환 효과에도 동일하게 사용할 수 있습니다.
즉, Fade Through는 아래 부분만 다르게 작성해도 구현할 수 있습니다.
MaterialAnimationItem(key) {
... val disappearAnimationSpec = tween(
delayMillis = 0,
durationMillis = outgoingDurationMillis
)
val appearAnimationSpec = tween(
delayMillis = outgoingDurationMillis,
durationMillis = incomingDurationMillis
) val alpha by transition.animateFloat(
transitionSpec = { ... }
) { if (it == key) 1f else 0f }
val scale by transition.animateFloat(
transitionSpec = { ... }
) { if (it == key) 1f else 0.92f } Box(Modifier.alpha(alpha = alpha)
.scale(scale = scale)) {
content(key)
}
}
그러고나서 호출하는 부분만 변경하면…
Scaffold(bottomBar = { ... }) { innerPadding ->
MaterialFadeThrough( // from Crossfade
targetState = selectedTab,
modifier = Modifier.padding(innerPadding)
) { currentTab ->
BottomTabsContents(currentTab)
}
}
짠! 완성입니다. 참 쉽죠? 😉


material-motion-compose 📚
스펙에 맞춰 구현하는 것은 어렵지 않지만, 시간이 드는 귀찮은 일이죠.
그래서 제가 라이브러리를 만들어 뒀습니다. 🙌
사용방법을 간단하게 소개할게요.Crossfade
대신 MaterialMotion
을 사용하고,
enter/exit에 원하는 전환 효과 MotionSpec
을 정의해주면 끝입니다.
MaterialMotion(
targetState = state,
enterMotionSpec = ...,
exitMotionSpec = ...,
pop = false or true
) { newState ->
// composable according to screen
}
제공하는 MotionSpec
은 다음과 같습니다.
MaterialSharedAxis
materialSharedAxis(Axis.X, forward = true or false)
materialSharedAxis(Axis.Y, forward = true or false)
materialSharedAxis(Axis.Z, forward = true or false)

MaterialFadeThrough / MaterialFade
materialFadeThrough()
materialFade()

ElevationScale / Hold
materialElevationScale(growing = true or false)
hold()

Navigation Demo 🧑💻
앞서 탭 컨텐츠가 변경될 때의 전환 효과를 구현해봤는데요.
이번에는 화면 간에 전환 효과를 넣어봅시다.

Library, Album 두 개의 화면으로 구성되어 있고, Library에서는 목록의 형태를 Linear, Grid로 변경하거나 목록의 순서를 역순으로 변경할 수 있는, 간단한 Navigation 샘플을 구현했습니다. (코드는 DemoScreen입니다. 🔖)

간단합니다. 전환 효과가 필요한 부분을 MaterialMotion
으로 감싸주세요. 전환되는 동안, 이전 화면이 유지되어 보이도록 hold()
를 사용했습니다.
@Composable
fun DemoScreen() {
val (state, onStateChanged) = remember { ... }
MaterialMotion(
targetState = state,
enterMotionSpec = translateY(offset, 0f),
exitMotionSpec = hold(),
pop = state == null
) { currentId -> if (currentId != null) {
AlbumScreen(currentId)
} else {
LibraryScreen(onItemClick = { onStateChanged(it.id) })
}
}
}

동일합니다. 목록 형태 혹은 순서가 변경되는 부분을 MaterialMotion
로 감싸주세요. 여기서 목록 형태가 바뀔 때는 materialFadeThrough()
를, 목록 순서가 바뀔 때는 materialSharedAxis()
를 사용했습니다.
@Composable
fun LibraryScreen(...) {
val (state, onStateChanged) = remember {
mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid))
}
Scaffold(...) {
MaterialMotion(
targetState = state,
motionSpec = materialSharedAxis(Axis.Y, forward = true),
modifier = Modifier.padding(innerPadding)
) { currentDestination ->
LibraryContents(currentDestination, ...)
}
}
}
Save composer states ♻️

MaterialMotion
사용과 관계없이, 이전 화면으로 되돌아가면 상태가 초기화되는 현상을 볼 수 있습니다. 그 이유는 이전 화면이 Initial composition으로다뤄지기 때문인데요. 이런 경우에는 SaveableStateHolder
를 이용하여, 이전 화면의 상태를 복원할 수 있습니다. ✅
@Composable
fun DemoScreen() {
val saveableStateHolder = rememberSaveableStateHolder()
val (state, onStateChanged) = remember { ... }
MaterialMotion(...) { currentId ->
saveableStateHolder.SaveableStateProvider(currentId.toString()){
if (currentId != null) {
AlbumScreen(currentId)
} else {
LibraryScreen(onItemClick = { onStateChanged(it.id) })
}
}
}
}
공식 샘플인 SimpleNavigationWithSaveableStateSample도 참고해보세요.
Next Step…
하지만 여전히 아쉬운 부분들이 있습니다. 😅
Support ContainerTransform
기존 View System 기반의 Material Motion에서는 ContainerTransform이 Shared Elements를 이용하여 구현되어 있습니다. 따라서 Legacy가 없는 Jetpack Compose에서는, 다른 방법으로 ContainerTransform을 구현해야 합니다.
@Composable을 따로 받는다던지, 약간의 trick이 필요할 것 같은데요. 아직까지는 괜찮은 아이디어가 떠오르지 않습니다. 🤯

Support Navigation 🔁
아쉽게도 navigation-compose에서는 material-motion-compose를 사용할 수 없습니다. 추후에 화면을 전환하는 부분을 외부에서 주입할 수 있게 된다면, 가능할지도 모르겠습니다만, 그 전까지는 직접 navigation을 구현해야 할 것 같은데요. 라이브러리에서 제공할 수 있는 부분이 있을지 고민 중입니다.
마침 issue tracker를 뒤져보니, navigation-compose에 Transition을 지원해달라는 요청이 있고 개발 중인 것 같은데요. 저처럼 이 기능이 필요하다고 생각하시는 분들은 관련 issue tracker에 ⭐️를 눌러주시면, AndroidX 팀에서 우선순위를 판단하는데 도움이 될 것 같습니다. 🙇
지금까지 Material Motion을 Jetpack Compose에서 구현하는 방법과 만들어진 라이브러리 사용법에 대해 살펴봤습니다.
새로운 UI Toolkit에도 Transition을 적용하고 싶다는 마음으로 구현을 시작했는데요. 라이브러리를 만들고난 지금은, 조금 더 Jetpack Compose와 가까워진 느낌이 듭니다. 프로덕션에 Jetpack Compose를 사용할 수 있는 날이 얼른오면 재밌을 것 같네요.
아직까지는 Transition을 적용하기에 아쉬운 부분들이 있는데요. 관심이 가는 분들은 시험삼아 라이브러리를 사용해보시고, 궁금한 점이나 요청사항을 Github 저장소에 이슈로 생성해주세요. 🧑💻