[Android] Jetpack Navigation — Migrate to Compose — Animation

Kenneths
Kenneth Android
Published in
16 min readDec 26, 2022
Photo by Pawel Czerwinski on Unsplash

이번 글에서는 Navigation에서 Destination간의 애니메이션 전환을 알아봅니다. Navigation에서 기본으로 제공하는 트랜지션을 사용하면 좋겠지만 실제 비즈니스에서 디자인팀의 요구사항을 맞추기 위해서는 커스텀 애니메이션 전환 효과가 필수적입니다. 😄

Fragment Navigaiton

Navigation 컴포넌트에서 기본적으로 애니메이션이 포함되어 있지만 Actions에 View 애니메이션 프로퍼티를 추가할 수 있습니다. 애니메이션을 만드는 과정은 애니메이션 리소스를 확인해주세요.

애니메이션을 추가하는 대상에는 4가지가 있습니다.

  • enterAnim : 탐색하는 Destination의 진입 Enter 애니메이션입니다.
  • exitAnim : 탐색이 시작하는 위치의 Destination에 대한 Exit 애니메이션입니다.
  • popExitAnim : Pop 액션을 통해 종료되는 Destination에 대한 Exit 애니메이션 입니다.
  • popEnterAnim : Pop 액션을 통해 종료되는 Destination 뒤에 표시되는 Enter 애니메이션입니다.
<fragment
android:id="@+id/mainFragment"
android:name="com.kennethss.android.navigation.ui.MainFragment"
android:label="@string/label_main"
tools:layout="@layout/fragment_main">

<action
android:id="@+id/actionHomeFragmentToSettingFragment"
app:destination="@id/setting_navigation"
app:enterAnim="@anim/slide_from_right"
app:exitAnim="@anim/stay"
app:popEnterAnim="@anim/stay"
app:popExitAnim="@anim/slide_to_right">
<argument
android:name="id"
android:defaultValue="0" />
</action>
</fragment>
<!-- slide_from_right.xml -->
<translate
xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="200"
android:fromXDelta="100%p"
android:toXDelta="0%p">
</translate>

<!-- slide_to_right.xml -->
<translate
xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="200"
android:fromXDelta="0%p"
android:toXDelta="100%p">
</translate>

<!-- stay.xml -->
<translate
xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="150"
android:fromXDelta="0%p"
android:toXDelta="0%p">
</translate>

결과

Destination간의 shared element 트랜지션 추가하기

2개의 Destination간의 View를 공유해야 하는 경우 shared element transition을 사용하여 Destination 간의 View가 전환되는 element를 정의할 수 있습니다.

만약 shared element transition을 사용할 경우 애니메이션 프레임워크를 사용하면 안됩니다.( exterAnim, exitAnim 과 같은) shared element transition은 트랜지션 프레임워크의 일부입니다.

Shared element는 xml파일이 아닌 코드방식으로 제공됩니다. 액티비티나 프래그먼트는 각각 Navigator.Extras 인터페이스를 상속한 서브클래스를 가지고 있습니다. 이를 이용해서 navigate()를 호출할 때 Extra 객체를 전달할 수 있습니다.

// Fragment
public fun FragmentNavigatorExtras(
vararg sharedElements: Pair<View, String>
): FragmentNavigator.Extras = FragmentNavigator.Extras.Builder().apply {
sharedElements.forEach { (view, name) ->
addSharedElement(view, name)
}
}.build()

// Activity
public fun ActivityNavigatorExtras(
activityOptions: ActivityOptionsCompat? = null,
flags: Int = 0
): ActivityNavigator.Extras = ActivityNavigator.Extras.Builder().apply {
if (activityOptions != null) {
setActivityOptions(activityOptions)
}
addFlags(flags)
}.build()

shared element transitions애니메이션을 이용한 Fragment간의 탐색을 참고하세요.​

프래그먼트 Destination 간의 Shared element 전환

FragmentNavigator.Extras 클래스를 사용하면 FragmentTransaction.addSharedElement(). 와 유사하게 Destination간의 Shared element를 트랜지션 Name을 통해 공유하고 매핑할 수 있습니다.

val extras = FragmentNavigatorExtras(binding.btnSettingWithTransition to "setting_transition")
findNavController().navigate(
resId = R.id.actionHomeFragmentToSettingFragment,
args = Bundle().apply {
putInt("id", navigator.id)
},
navOptions = null,
navigatorExtras = navigator.extras
)

결과

Compose Navigation

컴포즈 Navigation에서 컴포저블에서 기본으로 제공되는 NavHost 함수는 별도의 애니메이션 처리를 지원하지 않습니다. Destination간의 애니메이션을 지정하려면 각 Route나 Screen에서 컴포지션이 실행될 때 적절한 애니메이션 처리를 해주어야 하지만 화면이 많아질수록 보일러플레이트 코드가 생산이되는 단점이 있습니다. 이와 같은 문제를 해결하기 위해 구글의 Accompanist에서는 Jetpack Navigation Compose Animation 라이브러리를 지원하고 있습니다. 이번 글에서는 해당 라이브러리를 이용하여 어떻게 애니메이션을 처리하는지 알아보려고 합니다.

의존성 추가

repositories {
mavenCentral()
}

dependencies {
implementation "com.google.accompanist:accompanist-navigation-animation:0.28.0"
}

사용방법

AnimatedNavHost 컴포저블은 각각의 composable Destination에 접근할 때 커스텀 트랜지션을 파라미터로서 전달하여 애니메이션을 제공합니다.

각 람다함수는 slideIntoContainerslideOutOfContainer와 같은 특별한 트랜지션을 AnimatedContentScope를 수신자 스코프로 가지고 있고 initialStatetargetState 프로퍼티를 주고있으며

targetState NavBackStackEntry 가 화면에 나타날때 실행되는 EnterTransition을 제어합니다.

  • enterTransition : targetState ,NavBackStackEntry 가 화면이 나타날 때 EnterTransition이 실행을 컨트롤합니다.
  • exitTransition : initialState ,NavBackStackEntry 가 화면에서 사라질 때 ExitTransition의 실행을 컨트롤합니다.
  • popEnterTransition : enterTransition 값을 기본적으로 가지며, pop 작업(popBackStack())이 수행이 될 때 targetState, NavBackStackEntry가 화면에 나타날 때 별도의 EnterTransition을 제공하도록 재정의할 수 있습니다
  • popExitTransition : exitTransition 값을 기본적으로 가지며, pop 작업(popBackStack())이 수행이 될 때 initialState, NavBackStackEntry가 화면에서 사라질 때 별도의 ExitTransition을 제공하도록 재정의할 수 있습니다

컴포저블 Destination의 각 트랜지션에 null을 리턴할 경우 부모 navigation element 트랜지션을 사용합니다. 따라서 Navigation 그래프 레벨에서 글로벌 트랜지션을 설정할 수 있고 모든 컴포저블 그래프는 동일하게 적용됩니다. 이 작업은 AniamtedNavHost에 도달할 때까지 계속되며 루트 AnimatedNavHost는 모든 Destination에 대해 글로벌 트랜지션을 이용해 제어할 수 있고 지정되지 않은 중첩 그래프까지 컨트롤합니다.

@Composable
private fun ExperimentalAnimationNav() {
val navController = rememberAnimatedNavController()
AnimatedNavHost(navController, startDestination = "Blue") {
composable(
"Blue",
enterTransition = {
when (initialState.destination.route) {
"Red" ->
slideIntoContainer(AnimatedContentScope.SlideDirection.Left, animationSpec = tween(700))
else -> null
}
},
exitTransition = {
when (targetState.destination.route) {
"Red" ->
slideOutOfContainer(AnimatedContentScope.SlideDirection.Left, animationSpec = tween(700))
else -> null
}
},
popEnterTransition = {
when (initialState.destination.route) {
"Red" ->
slideIntoContainer(AnimatedContentScope.SlideDirection.Right, animationSpec = tween(700))
else -> null
}
},
popExitTransition = {
when (targetState.destination.route) {
"Red" ->
slideOutOfContainer(AnimatedContentScope.SlideDirection.Right, animationSpec = tween(700))
else -> null
}
}
) { BlueScreen(navController) }
composable(
"Red",
enterTransition = {
when (initialState.destination.route) {
"Blue" ->
slideIntoContainer(AnimatedContentScope.SlideDirection.Left, animationSpec = tween(700))
else -> null
}
},
exitTransition = {
when (targetState.destination.route) {
"Blue" ->
slideOutOfContainer(AnimatedContentScope.SlideDirection.Left, animationSpec = tween(700))
else -> null
}
},
popEnterTransition = {
when (initialState.destination.route) {
"Blue" ->
slideIntoContainer(AnimatedContentScope.SlideDirection.Right, animationSpec = tween(700))
else -> null
}
},
popExitTransition = {
when (targetState.destination.route) {
"Blue" ->
slideOutOfContainer(AnimatedContentScope.SlideDirection.Right, animationSpec = tween(700))
else -> null
}
}
) { RedScreen(navController) }
}
}

저의 경우 enterTransition과 popExitTransition은 여러곳에서 사용할 수 있도록 확장함수로 작성하여 사용했습니다.

@OptIn(ExperimentalAnimationApi::class)
fun NavGraphBuilder.settingScreen(
navigator: SettingNavigator
) {
composable(
route = Setting.routeArgs,
arguments = Setting.arguments,
deepLinks = Setting.deepLinks,
enterTransition = { slideInFromRight() },
popExitTransition = { slideOutToRight() }
) { backStackEntry ->
val id = backStackEntry.arguments?.getInt(KEY_ID) ?: 0
SettingScreen(
id = id,
navigator = navigator
)
}
}

마이그레이션

기존에 작성되었던 Compose Navigation을 Navigation Compose Animation로 변경하려면 기존 API들을 라이브러리 API로 변경해야합니다.

  • rememberNavController() -> rememberAnimatedNavController()
  • NavHost -> AnimatedNavHost
  • import androidx.navigation.compose.navigation ->
    import com.google.accompanist.navigation.animation.navigation
  • import androidx.navigation.compose.composable ->
    import com.google.accompanist.navigation.animation.composable

결과

마무리

Navigation에서 어떻게 애니메이션 효과를 사용할 수 있는지 알아보았습니다. Compose Navigation에서 shared element 트랜지션의 경우 이안 레이크가 미디엄 글에서 다음으로 지원하려는 기능으로 말하고 있고 실제로도 Jetpack Compose 로드맵에서 포그라운드 상태로 곧 컴포즈에서 지원이 가능한 것으로 보입니다. 유능한 개발자들이 만들어준 라이브러리도있지만 공식적으로 지원해주길 기다려봅니다 🙏

Navigation 전체 소스는 아래 링크에서 확인할 수 있습니다.

목차

  1. [Android] Jetpack Navigation — Migrate to Compose — Overview
  2. [Android] Jetpack Navigation — Migrate to Compose — Graph & Destination
  3. [Android] Jetpack Navigation — Migrate to Compose — DeepLink
  4. [Android] Jetpack Navigation — Migrate to Compose — Animation
  5. [Android] Jetpack Navigation — Interaction

참고

--

--

Kenneths
Kenneth Android

사용자들에게 편리하고 AweSome UI, UX를 경험해주고 싶은 상위 티어 개발자가 되고싶어 달려가고있는 개발자입니다. 다양한 내용들을 공유하려고 합니다.