안드로이드 앱은 개발자의 구현에 따라 다양한 화면 구성을 가집니다.
어떤 사람은 하나의 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.fragment 에 NavHostFragmet 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간의 화면 전환에 대한 내용을 다루어 보았습니다. 아주 간단하고 편리하지 않나요?
![](https://t1.daumcdn.net/keditor/emoticon/niniz/large/010.gif)
'개발 이야기 > 안드로이드 개발' 카테고리의 다른 글
Android API Level 33 업데이트 적용 (1) | 2023.08.22 |
---|---|
Kotlin의 Coroutine, 코루틴 #1 (0) | 2023.01.31 |
[Android Build Issue] : [hilt] (2) | 2022.10.19 |
쉽고 빠르게 이미지 처리하는 방법, Glide Library (0) | 2022.10.18 |
DI(의존성 주입)은 무엇인가? 그리고 Koin DI를 적용하는 과정 (0) | 2022.09.14 |