Jetpack Navigation 에서 Fragment 를 show/hide 하는 방법

8 분 소요

문제

하단 탭 구현

요즘의 앱들은 대부분 하단 탭이 있다. Android 에서는 Material 라이브러리에 있는 BottomNavigationView 를 이용해서 구현한다.

직관적인 구현으로는 Fragment 들을 replace 하면 될 것으로 보이지만, 실제로는 그렇지 않은 경우가 있다.

탭 전환시 이전 탭의 화면이 저장되기를 원한다면 replace 로는 구현할 수 없다. 이 경우, show/hide 를 이용해서 구현한다.

Jetpack Navigation 에서는?

Jetpack Navigation(이하 Navigation) 을 사용하면 아주 간편하게 BottomNavigationViewNavController 를 붙일 수 있어서 실제 Fragment 제어를 할 필요가 없어진다.

하지만 기본 동작(FragmentNavigator)이 replace 이기 때문에 show/hide 를 쓰고 싶은 경우에는 사용할 수 없다.

필자의 경우 탭이 있는 시작화면에 NavHostFragment 를 두고 싶었는데, 그러기 위해선 BottomNavigationViewNavController 를 붙일 수 밖에 없었다.

그럼 Navigation 에서 show/hide 를 사용하고 싶은 경우, 어떻게 해야하는걸까?

Navigation 에는 Navigator 들이 정의되어있다. 우리가 Navigation XML 에서 <fragment ...> 이렇게 정의할 수 있는 이유는, fragment 이름을 제어할 수 있는 FragmentNavigator 가 있기 때문이다. 그리고 FragmentNavigator 의 기본 동작은 replace 이다.

1
2
3
4
5
6
7
8
9
10
11
12
@Navigator.Name("fragment")
public class FragmentNavigator extends Navigator<FragmentNavigator.Destination> {
  @Nullable
  @Override
  public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
          @Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {
    // ...
    ft.replace(mContainerId, frag);
    // ...
  }
  // ...
}

이미 fragment 에 대해서 Navigator 가 정의되어 있기 때문에, 우리는 따로 Navigator 를 정의해볼까 한다.

Custom Navigator

Navigator 는 실제로 이동을 하고 백스택을 관리하는 로직을 담고 있다. 당연한 이야기일 수도 있지만, Activity, Fragment, DialogFragment 로 이동하는 방식은 다르다. 그런 의미로 Navigator 가 모두 따로 정의되어있다.

실제로 Fragment 를 제어할 예정이므로 FragmentNavigator 를 참고해서 구현하면 된다. 핵심 코드는 navigate 구현과 NavDestination 구현이다.

NavDestination 은 실제 navigate 가 되었을 때 이동될 노드의 정보가 담겨져온다. 우리는 기본적인 정보외에 Fragment 정보도 함꼐 담아야한다. 이를 위해 FragmentNavigator 구현과 동일하게 navigation 리소스로부터 Fragment 정보를 함께 담는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@NavDestination.ClassType(Fragment::class)
class Destination(navigator: BottomNavigator) : NavDestination(navigator) {
    internal var className: String? = null
        private set

    override fun onInflate(context: Context, attrs: AttributeSet) {
        super.onInflate(context, attrs)

        className = context.resources.obtainAttributes(attrs, R.styleable.BottomNavigator)
            .use {
                it.getString(R.styleable.BottomNavigator_android_name)
            }
    }
}

그리고 Navigator 를 정의하고 createDestination 를 통해 객체를 생성하게 한다.

1
2
3
4
5
6
7
8
9
10
11
@Navigator.Name("bottom")
class BottomNavigator(
    @IdRes private val fragmentContainerId: Int,
    private val fragmentManager: FragmentManager
) : Navigator<BottomNavigator.Destination>() {
    private val backStack: Deque<String> = ArrayDeque()

    override fun createDestination(): Destination = Destination(this)

    // ...
}

탐색, 그리고 백스택

이제 실제 이동하는 로직을 구현하면 된다. 이는 Fragment 를 제어할 줄 알면 크게 어렵지 않다. 클래스 이름으로부터 Fragment 를 생성한다는 것외에는 기본적인 Fragment 제어와 동일하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
override fun navigate(
    destination: Destination,
    args: Bundle?,
    navOptions: NavOptions?,
    navigatorExtras: Extras?
): NavDestination? {
    val className = destination.className ?: return null
    val tag = className.split('.').last()

    if (backStack.peekLast() == tag) {
        return null
    }

    if (backStack.peekLast() != tag) {
        backStack.addLast(tag)
    }

    val current = fragmentManager.findFragmentByTag(tag)
    fragmentManager.commit {
        if (current == null) {
            val fragment = fragmentManager.fragmentFactory.instantiate(
                ClassLoader.getSystemClassLoader(),
                className
            )
            add(fragmentContainerId, fragment, tag)
        } else {
            show(current)
        }

        hideOthers(tag)
    }

    return destination
}

탐색은 구현이 완료되었고, 이제 백스택 구현이 필요하다. Fragment 하나만 show 되고 나머지는 모두 hide 하고 있으니 백스택은 별도로 관리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private val backStack: Deque<String> = ArrayDeque()

override fun popBackStack(): Boolean {
    val tag = backStack.pollLast() ?: return true
    val newCurrentTag = backStack.peekLast() ?: return true
    val newCurrent = fragmentManager.findFragmentByTag(newCurrentTag)
    fragmentManager.commit {
        newCurrent?.let {
            show(it)
            hideOthers(newCurrentTag)
        }
    }
    return true
}

이제 Navigator 를 등록해보자. 기존에는 Activity의 XML에 navGraph 를 정의했었다. 이제 navGraph 가 로드되기 전에 Navigator 를 등록해야하므로, XML에 navGraph 정의는 하지말고 아래와 같이 코드를 작성하자.

1
2
3
4
5
6
7
8
9
10
11
12
navController.apply {
    navigatorProvider.addNavigator(
        BottomNavigator(
            R.id.fragment_container,
            supportFragmentManager
        )
    )
    // set a graph at code not XML, because add a custom navigator
    setGraph(R.navigation.bottom_navigation)

    binding.bottomNavigation.setupWithNavController(this)
}

XML에서의 사용

이제 Navigation XML 을 정의해보자. fragment 태그 대신 Navigator 정의시 달아둔 어노테이션의 이름으로 태그를 달아보자.

필자는 @Navigator.Name("bottom") 으로 정의했으므로 <bottom> 태그를 사용했다.

1
2
3
4
5
<bottom
    android:id="@+id/home_menu"
    android:name="io.viewpoint.moviedatabase.ui.home.HomeFragment"
    android:label="HomeFragment"
    tools:layout="@layout/fragment_home" />

주의:

BottomNavigationView 을 위해서 정의한 menu 의 android:id 와 Navigation 의 android:id 는 동일해아한다.

예) menu.xml

1
2
3
4
<item
    android:id="@+id/home_menu"
    android:icon="@drawable/ic_home"
    android:title="@string/menu_home" />

navigation.xml

1
2
3
4
5
<bottom
    android:id="@+id/home_menu"
    android:name="io.viewpoint.moviedatabase.ui.home.HomeFragment"
    android:label="HomeFragment"
    tools:layout="@layout/fragment_home" />

백스택의 기본 동작에 대하여

BottomNavgationViewsetupWithNavController 를 통해 NavController 를 설정하게 된다. 이 때, 백스택의 기본 동작은 첫 탭으로 이동하는 것이다.

즉, 1 -> 2 -> 3 -> ... 로 이동하면 백스택이 쌓일 것 같지만 실제로는 그렇지 않다. 여기서 백키를 누르면 최초 진입점이었던 1 로 이동하게 된다.

만약 모든 탭 이동을 백스택에 쌓고 싶으면, 메뉴 XML 에서 모든 메뉴에 android:menuCategory="secondary" 속성을 부여하자. 그러면 NavigationUI 의 아래 코드가 무시되면서 백스택이 모두 쌓이게 된다.

1
2
3
4
5
6
7
8
9
// NavigationUI.java
public static boolean onNavDestinationSelected(@NonNull MenuItem item,
        @NonNull NavController navController) {
    // ...
    if ((item.getOrder() & Menu.CATEGORY_SECONDARY) == 0) {
        builder.setPopUpTo(findStartDestination(navController.getGraph()).getId(), false);
    }
    // ...
}

결론

위와 같이 replace 대신 show/hide 를 이용한 Fragment 제어를 Navigatior 를 구현하는 것으로 만들어보았다.

예제로 쓰인 Navigator 의 구현은 이 곳 에서 볼 수 있고, 구현이 포함된 프로젝트 리포지토리는 이 곳 을 참고하면 된다.

실제로 리포지토리에 들어가서 보면 재생성시 대응을 위해 onSaveState, onRestoreState 구현도 포함되어있으니 참고하면 좋을 것 같다.

참조

댓글남기기