본문 바로가기
개발 이야기/안드로이드 개발

Navigation, 아주 간단하게 Fragment간의 전환을 구현하는 방법.

by 정선한 2022. 10. 20.
728x90
반응형

안드로이드 앱은 개발자의 구현에 따라 다양한 화면 구성을 가집니다.

어떤 사람은 하나의 Activity에 여러 Fragment를 쌓아 관리하기도 하고, Activity만을 두어 처리하기도 하죠.

각 개발자의 관점과 그때그때의 상황에 따라 우리는 우리가 원하는 기능들을 표현하기 위한 화면들을 구현합니다.

언제나 그렇듯 개발에서의 명확한 답은 없습니다. 가장 합리적인 방법을 선택하는 것이 개발자의 역할이라고 생각합니다. 다만 더 빠르게 더 편리하게 코드를 구성할 수 있다면 우리는 그것들을 선택하고 적용해야 합니다.

그래서 오늘은 안드로이드 내에서 편리하게 화면들을 구성하고 연결할 수 있는 Navigation Componenet에 대한 이야기를 해보려고 합니다.

Navigation Library, Navigation SafeArgs 등록

현시점, navigation version은 2.5.2 버전이 최신입니다.

Project의 build.gradle 등록

classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.5.2"

Module의 build.gradle 등록

id 'androidx.navigation.safeargs.kotlin'
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.5.2'

Navigation의 가장 기본만 사용한다면 implementation의 종속성만 추가해주면 되지만, 혹시 Fragment의 전환 사이에 argument를 통해 데이터를 옮겨야 하는 일이 생길 수도 있어서 SafeArgs에 대한 내용들도 등록은 해두도록 합니다.

Navigation Component 기본 구조

먼저 Navigation Component를 사용하기 위해서는 NavHost와 Navigation Graph가 우선적으로 필요합니다.

  • NavHost는 Navigation Graph의 내용들을 표시하기 위한 Container View 로서 navHost에서 지정하는 NavHostFragment에서 각 Fragment에 대한 변환을 담당하며 Container에 Fragment가 표현될 수 있도록 합니다.
  • Navigation Graph는 말 그대로 tree구조로 표현된 Navigation의 정보들입니다. 해당 xml 파일을 구성하면 아래와 같이 각 Fragment의 정보와 등록된 action들을 확인할 수 있습니다.

이런 구조들만 잘 만들어주면 쉽게 Navigation Component에서 지원하는 각 기능들을 사용하여 Framgnet의 전환을 구현할 수 있습니다.

먼저 MainActivity위에 NavHost를 등록합니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fragment
        android:id="@+id/nav_host_fragment_content_main"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/nav_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>
  • 코드에서 app:defaultNavHost=”true”를 통해 NavHost임을 명시하고 android:name="androidx.navigation.fragment.NavHostFragment"을 통하여 NavHostFragment를 연결하여 줍니다.
  • package androidx.navigation.fragmentNavHostFragmet Open Class 가 등록되어있습니다.
/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      <http://www.apache.org/licenses/LICENSE-2.0>
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.navigation.fragment

import android.content.Context
import android.content.ContextWrapper
import android.os.Bundle
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.annotation.CallSuper
import androidx.annotation.NavigationRes
import androidx.annotation.RestrictTo
import androidx.core.content.res.use
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.navigation.NavController
import androidx.navigation.NavHost
import androidx.navigation.NavHostController
import androidx.navigation.Navigation
import androidx.navigation.Navigator
import androidx.navigation.plusAssign

/**
 * NavHostFragment provides an area within your layout for self-contained navigation to occur.
 *
 * NavHostFragment is intended to be used as the content area within a layout resource
 * defining your app's chrome around it, e.g.:
 *
 * ```
 * 
 * 
 * ;
 * 
 * ```
 *
 * Each NavHostFragment has a [NavController] that defines valid navigation within
 * the navigation host. This includes the [navigation graph][NavGraph] as well as navigation
 * state such as current location and back stack that will be saved and restored along with the
 * NavHostFragment itself.
 *
 * NavHostFragments register their navigation controller at the root of their view subtree
 * such that any descendant can obtain the controller instance through the [Navigation]
 * helper class's methods such as [Navigation.findNavController]. View event listener
 * implementations such as [android.view.View.OnClickListener] within navigation destination
 * fragments can use these helpers to navigate based on user interaction without creating a tight
 * coupling to the navigation host.
 */
public open class NavHostFragment : Fragment(), NavHost {
    private var navHostController: NavHostController? = null
    private var isPrimaryBeforeOnCreate: Boolean? = null
    private var viewParent: View? = null

    // State that will be saved and restored
    private var graphId = 0
    private var defaultNavHost = false

    /**
     * The [navigation controller][NavController] for this navigation host.
     * This method will return null until this host fragment's [onCreate]
     * has been called and it has had an opportunity to restore from a previous instance state.
     *
     * @return this host's navigation controller
     * @throws IllegalStateException if called before [onCreate]
     */
    final override val navController: NavController
        get() {
            checkNotNull(navHostController) { "NavController is not available before onCreate()" }
            return navHostController as NavHostController
        }

    @CallSuper
    public override fun onAttach(context: Context) {
        super.onAttach(context)
        // TODO This feature should probably be a first-class feature of the Fragment system,
        // but it can stay here until we can add the necessary attr resources to
        // the fragment lib.
        if (defaultNavHost) {
            parentFragmentManager.beginTransaction()
                .setPrimaryNavigationFragment(this)
                .commit()
        }
    }

    @CallSuper
    public override fun onCreate(savedInstanceState: Bundle?) {
        var context = requireContext()
        navHostController = NavHostController(context)
        navHostController!!.setLifecycleOwner(this)
        while (context is ContextWrapper) {
            if (context is OnBackPressedDispatcherOwner) {
                navHostController!!.setOnBackPressedDispatcher(
                    (context as OnBackPressedDispatcherOwner).onBackPressedDispatcher
                )
                // Otherwise, caller must register a dispatcher on the controller explicitly
                // by overriding onCreateNavHostController()
                break
            }
            context = context.baseContext
        }
        // Set the default state - this will be updated whenever
        // onPrimaryNavigationFragmentChanged() is called
        navHostController!!.enableOnBackPressed(
            isPrimaryBeforeOnCreate != null && isPrimaryBeforeOnCreate as Boolean
        )
        isPrimaryBeforeOnCreate = null
        navHostController!!.setViewModelStore(viewModelStore)
        onCreateNavHostController(navHostController!!)
        var navState: Bundle? = null
        if (savedInstanceState != null) {
            navState = savedInstanceState.getBundle(KEY_NAV_CONTROLLER_STATE)
            if (savedInstanceState.getBoolean(KEY_DEFAULT_NAV_HOST, false)) {
                defaultNavHost = true
                parentFragmentManager.beginTransaction()
                    .setPrimaryNavigationFragment(this)
                    .commit()
            }
            graphId = savedInstanceState.getInt(KEY_GRAPH_ID)
        }
        if (navState != null) {
            // Navigation controller state overrides arguments
            navHostController!!.restoreState(navState)
        }
        if (graphId != 0) {
            // Set from onInflate()
            navHostController!!.setGraph(graphId)
        } else {
            // See if it was set by NavHostFragment.create()
            val args = arguments
            val graphId = args?.getInt(KEY_GRAPH_ID) ?: 0
            val startDestinationArgs = args?.getBundle(KEY_START_DESTINATION_ARGS)
            if (graphId != 0) {
                navHostController!!.setGraph(graphId, startDestinationArgs)
            }
        }

        // We purposefully run this last as this will trigger the onCreate() of
        // child fragments, which may be relying on having the NavController already
        // created and having its state restored by that point.
        super.onCreate(savedInstanceState)
    }

    /**
     * Callback for when the [NavHostController] is created. If you
     * support any custom destination types, their [Navigator] should be added here to
     * ensure it is available before the navigation graph is inflated / set.
     *
     * This provides direct access to the host specific methods available on
     * [NavHostController] such as
     * [NavHostController.setOnBackPressedDispatcher].
     *
     * By default, this adds a [DialogFragmentNavigator] and [FragmentNavigator].
     *
     * This is only called once in [onCreate] and should not be called directly by
     * subclasses.
     *
     * @param navHostController The newly created [NavHostController] that will be
     * returned by [getNavController] after
     */
    @Suppress("DEPRECATION")
    @CallSuper
    protected open fun onCreateNavHostController(navHostController: NavHostController) {
        onCreateNavController(navHostController)
    }

    /**
     * Callback for when the [NavController][getNavController] is created. If you
     * support any custom destination types, their [Navigator] should be added here to
     * ensure it is available before the navigation graph is inflated / set.
     *
     * By default, this adds a [DialogFragmentNavigator] and [FragmentNavigator].
     *
     * This is only called once in [onCreate] and should not be called directly by
     * subclasses.
     *
     * @param navController The newly created [NavController].
     */
    @Suppress("DEPRECATION")
    @CallSuper
    @Deprecated(
        """Override {@link #onCreateNavHostController(NavHostController)} to gain
      access to the full {@link NavHostController} that is created by this NavHostFragment."""
    )
    protected open fun onCreateNavController(navController: NavController) {
        navController.navigatorProvider +=
            DialogFragmentNavigator(requireContext(), childFragmentManager)
        navController.navigatorProvider.addNavigator(createFragmentNavigator())
    }

    @CallSuper
    public override fun onPrimaryNavigationFragmentChanged(isPrimaryNavigationFragment: Boolean) {
        if (navHostController != null) {
            navHostController?.enableOnBackPressed(isPrimaryNavigationFragment)
        } else {
            isPrimaryBeforeOnCreate = isPrimaryNavigationFragment
        }
    }

    /**
     * Create the FragmentNavigator that this NavHostFragment will use. By default, this uses
     * [FragmentNavigator], which replaces the entire contents of the NavHostFragment.
     *
     * This is only called once in [onCreate] and should not be called directly by
     * subclasses.
     * @return a new instance of a FragmentNavigator
     */
    @Deprecated("Use {@link #onCreateNavController(NavController)}")
    protected open fun createFragmentNavigator(): Navigator {
        return FragmentNavigator(requireContext(), childFragmentManager, containerId)
    }

    public override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val containerView = FragmentContainerView(inflater.context)
        // When added via XML, this has no effect (since this FragmentContainerView is given the ID
        // automatically), but this ensures that the View exists as part of this Fragment's View
        // hierarchy in cases where the NavHostFragment is added programmatically as is required
        // for child fragment transactions
        containerView.id = containerId
        return containerView
    }

    /**
     * We specifically can't use [View.NO_ID] as the container ID (as we use
     * [androidx.fragment.app.FragmentTransaction.add] under the hood),
     * so we need to make sure we return a valid ID when asked for the container ID.
     *
     * @return a valid ID to be used to contain child fragments
     */
    private val containerId: Int
        get() {
            val id = id
            return if (id != 0 && id != View.NO_ID) {
                id
            } else R.id.nav_host_fragment_container
            // Fallback to using our own ID if this Fragment wasn't added via
            // add(containerViewId, Fragment)
        }

    public override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        check(view is ViewGroup) { "created host view $view is not a ViewGroup" }
        Navigation.setViewNavController(view, navHostController)
        // When added programmatically, we need to set the NavController on the parent - i.e.,
        // the View that has the ID matching this NavHostFragment.
        if (view.getParent() != null) {
            viewParent = view.getParent() as View
            if (viewParent!!.id == id) {
                Navigation.setViewNavController(viewParent!!, navHostController)
            }
        }
    }

    @CallSuper
    public override fun onInflate(
        context: Context,
        attrs: AttributeSet,
        savedInstanceState: Bundle?
    ) {
        super.onInflate(context, attrs, savedInstanceState)
        context.obtainStyledAttributes(
            attrs,
            androidx.navigation.R.styleable.NavHost
        ).use { navHost ->
            val graphId = navHost.getResourceId(
                androidx.navigation.R.styleable.NavHost_navGraph, 0
            )
            if (graphId != 0) {
                this.graphId = graphId
            }
        }
        context.obtainStyledAttributes(attrs, R.styleable.NavHostFragment).use { array ->
            val defaultHost = array.getBoolean(R.styleable.NavHostFragment_defaultNavHost, false)
            if (defaultHost) {
                defaultNavHost = true
            }
        }
    }

    @CallSuper
    public override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        val navState = navHostController!!.saveState()
        if (navState != null) {
            outState.putBundle(KEY_NAV_CONTROLLER_STATE, navState)
        }
        if (defaultNavHost) {
            outState.putBoolean(KEY_DEFAULT_NAV_HOST, true)
        }
        if (graphId != 0) {
            outState.putInt(KEY_GRAPH_ID, graphId)
        }
    }

    public override fun onDestroyView() {
        super.onDestroyView()
        viewParent?.let { it ->
            if (Navigation.findNavController(it) === navHostController) {
                Navigation.setViewNavController(it, null)
            }
        }
        viewParent = null
    }

    public companion object {
        /**
         * @hide
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        public const val KEY_GRAPH_ID: String = "android-support-nav:fragment:graphId"

        /**
         * @hide
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        public const val KEY_START_DESTINATION_ARGS: String =
            "android-support-nav:fragment:startDestinationArgs"
        private const val KEY_NAV_CONTROLLER_STATE =
            "android-support-nav:fragment:navControllerState"
        private const val KEY_DEFAULT_NAV_HOST = "android-support-nav:fragment:defaultHost"

        /**
         * Find a [NavController] given a local [Fragment].
         *
         * This method will locate the [NavController] associated with this Fragment,
         * looking first for a [NavHostFragment] along the given Fragment's parent chain.
         * If a [NavController] is not found, this method will look for one along this
         * Fragment's [view hierarchy][Fragment.getView] as specified by
         * [Navigation.findNavController].
         *
         * @param fragment the locally scoped Fragment for navigation
         * @return the locally scoped [NavController] for navigating from this [Fragment]
         * @throws IllegalStateException if the given Fragment does not correspond with a
         * [NavHost] or is not within a NavHost.
         */
        @JvmStatic
        public fun findNavController(fragment: Fragment): NavController {
            var findFragment: Fragment? = fragment
            while (findFragment != null) {
                if (findFragment is NavHostFragment) {
                    return findFragment.navHostController as NavController
                }
                val primaryNavFragment = findFragment.parentFragmentManager
                    .primaryNavigationFragment
                if (primaryNavFragment is NavHostFragment) {
                    return primaryNavFragment.navHostController as NavController
                }
                findFragment = findFragment.parentFragment
            }

            // Try looking for one associated with the view instead, if applicable
            val view = fragment.view
            if (view != null) {
                return Navigation.findNavController(view)
            }

            // For DialogFragments, look at the dialog's decor view
            val dialogDecorView = (fragment as? DialogFragment)?.dialog?.window?.decorView
            if (dialogDecorView != null) {
                return Navigation.findNavController(dialogDecorView)
            }
            throw IllegalStateException("Fragment $fragment does not have a NavController set")
        }

        /**
         * Create a new NavHostFragment instance with an inflated [NavGraph] resource.
         *
         * @param graphResId Resource id of the navigation graph to inflate.
         * @param startDestinationArgs Arguments to send to the start destination of the graph.
         * @return A new NavHostFragment instance.
         */
        @JvmOverloads
        @JvmStatic
        public fun create(
            @NavigationRes graphResId: Int,
            startDestinationArgs: Bundle? = null
        ): NavHostFragment {
            var b: Bundle? = null
            if (graphResId != 0) {
                b = Bundle()
                b.putInt(KEY_GRAPH_ID, graphResId)
            }
            if (startDestinationArgs != null) {
                if (b == null) {
                    b = Bundle()
                }
                b.putBundle(KEY_START_DESTINATION_ARGS, startDestinationArgs)
            }
            val result = NavHostFragment()
            if (b != null) {
                result.arguments = b
            }
            return result
        }
    }
}
  • app:navGraph="@navigation/nav_graph" 를 통해 저는 nav_graph의 파일명을 가진 xml을 등록해주었습니다.

Navigation의 xml파일은 아래와 같이 등록하여 줍니다. 바로 열릴 MainFragment도 함께 등록하여 주었습니다.

NavHost는 등록이 되었는데 그 안에 아무것도 없이 실행을 시켜볼 수는 없으니까요 😊

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/MainFragment">

    <fragment
        android:id="@+id/MainFragment"
        android:name="com.seonhan_dev.imagepicker.ui.main.MainFragment"
        android:label="@string/main_fragment_label"
        tools:layout="@layout/fragment_main">

    </fragment>
/* 
	Add Fragment
*/

</navigation>

여기에 추가적으로 DetailFragment를 등록하여 페이지 전환까지 구현하는 내용을 적어보겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/MainFragment">

    <fragment
        android:id="@+id/MainFragment"
        android:name="com.seonhan_dev.imagepicker.ui.main.MainFragment"
        android:label="@string/main_fragment_label"
        tools:layout="@layout/fragment_main">

        <action
            android:id="@+id/action_MainFragment_to_DetailFragment"
            app:destination="@id/DetailFragment" />
    </fragment>

    <fragment
        android:id="@+id/DetailFragment"
        android:name="com.seonhan_dev.imagepicker.ui.detail.DetailFragment"
        android:label="@string/detail_fragment_label"
        tools:layout="@layout/fragment_detail">

    </fragment>
/* 
	Add Fragment
*/

</navigation>
  • nav_graph.xml에서 action태그를 통하여 DetailFragment로 이동할 것임을 명시해줍니다.
val action = MainFragmentDirections.actionMainFragmentToDetailFragment()
findNavController().navigate(action)
  • 그 후 MainFragment코드에서 화면 전환의 이벤트가 필요한 곳에 navigate(action) 메서드를 실행시킵니다. action에 등록된 내용으로 페이지를 전환할 수 있습니다.
  • 오픈소스의 navController내부에 구현되어있는 navigate메소드를 통해 페이지가 전환됩니다.
public fun Fragment.findNavController(): NavController =
    NavHostFragment.findNavController(this)
@MainThread
public open fun navigate(directions: NavDirections) {
    navigate(directions.actionId, directions.arguments, null)
}

이렇게 구현을 해주면 특별한 코드 없이…Fragment에 대한 화면전환을 해결할 수 있습니다.

기본 Fragment전환 시에는 backstack에 넣을건지 등 관련해서 길게 길게 구현해야 할 코드들이 xml과 메서드들로 간단하게 처리되는 것을 확인할 수 있습니다.

back처리도 간단하게 할 수 있는데, DetailFragment에서 다시 MainFragment로 돌아가고자 한다면 navController내부에 구현되어있는 navigateUp() 메서드를 사용하면 됩니다.

findNavController().navigateUp()
@MainThread
public open fun navigateUp(): Boolean {
    // If there's only one entry, then we may have deep linked into a specific destination
    // on another task.
    if (destinationCountOnBackStack == 1) {
        val extras = activity?.intent?.extras
        if (extras?.getIntArray(KEY_DEEP_LINK_IDS) != null) {
            return tryRelaunchUpToExplicitStack()
        } else {
            return tryRelaunchUpToGeneratedStack()
        }
    } else {
        return popBackStack()
    }
}

이렇게 해서 안드로이드의 Navigation Component를 통한 Fragment간의 화면 전환에 대한 내용을 다루어 보았습니다. 아주 간단하고 편리하지 않나요?

 

728x90
반응형