Material Motion for Jetpack Compose

Jetpack Compose에서 사용할 수 있는 Material Motion 라이브러리를 소개합니다. 🎭

Sungyong An
14 min readApr 21, 2021

--

Go to English version

요즘 Jetpack Compose에 관심을 가지는 분들이 많은데요. 대부분은 Android Developers에 정리된 글이나 코드랩을 통해서, 혹은 간단한 샘플을 만들어보면서 학습하고 계실 것 같습니다. 저도 마찬가지구요.

하지만 자세하게 알 수 없는 것도 군데군데 보여서, 그런 것들 중에 한가지를 다룰 생각입니다. 앱은 보통 화면 단위로 UI 작업을 나누게 되는데요. 오늘은 Jetpack Compose에서 화면 전환 효과를 구현하는 방법을 살펴보겠습니다.

많은 앱에서 하단 탭 형태의 UX를 제공합니다.

Bottom Navigation with no transition

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)
}
}
Bottom Navigation with Crossfade

문득 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가지 전환 패턴이 있습니다.

4 transition patterns in material motion.

이 중에 한 가지를 구현해 볼까요? 🙋‍♂️
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)
}
}

짠! 완성입니다. 참 쉽죠? 😉

Bottom Navigation with Fade Through
material-motion-compose

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)
MaterialSharedAxis (X, Y, Z)

MaterialFadeThrough / MaterialFade

  • materialFadeThrough()
  • materialFade()
MaterialFadeThrough / MaterialFade

ElevationScale / Hold

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

Navigation Demo 🧑‍💻

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

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

A transition between LibraryScreen and AlbumScreen

간단합니다. 전환 효과가 필요한 부분을 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) })
}
}
}
A transition in LibraryScreen

동일합니다. 목록 형태 혹은 순서가 변경되는 부분을 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 ♻️

without-saveable / saveable

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 저장소이슈로 생성해주세요. 🧑‍💻

--

--

Sungyong An
Sungyong An

Written by Sungyong An

Android Developer in South Korea, Android GDE

No responses yet

Write a response