chacha's

[ Android ] Navigation 본문

Android/Concept

[ Android ] Navigation

Cha_Cha 2021. 4. 9. 18:30

목차

     

    시중에 나와있는 앱을 보면 복잡하게 이루어진 경우가 대부분으로 하나의 화면이나 두 개의 버튼으로 이루어진 화면처럼 간단하게 이루어진 앱을 찾아보기는 힘듭니다. Navigation은 화면을 구성하는 과정에서 도움이 되는 Android Jetpack Component 입니다.

    기존에 방법보다 신경써야 할 부분도 많이 있지만 시각적으로 view가 어떻게 연결되어 알 수 있기 때문에 도움이 됩니다. 만약 navigation graph를 봤을 때 모든 화면을 연결하는 선이 스파게티처럼 복잡하게 이루어져 있다면 사용자가 앱을 사용할 때 머릿속에 이러한 그림을 그려가며 앱을 사용해야 한다는 것을 의미합니다. 몇 개의 화면으로만 이루어져 있는 앱이더라도 navigation을 봤을 때 복잡하다면 사용자 또한 그렇게 느낄 확률이 높다는 것을 의미하므로 해당 기능을 사용하는 것은 개발자, 사용자 모두에게 도움이 됩니다.

     Migrate to the Navigation component - Docs
     을 참고하였습니다.
    아래에 사용된 코드들은 Trivia app에서 참고할 수 있습니다.

     Principles of navigation

    1. app은 고정 시작점(사용자가 런처로부터 앱을 실행할 때 표시되는 화면)을 가집니다. 이 고정 시작점은 뒤로가기 버튼을 누르며 런처로 돌아갈 때 사용자에게 표시되는 마지막 화면입니다.
    2. Stack ( first-in-last-out ) 구조로 쌓입니다.
    3. Back button을 눌렀을 때 User가 지나온 길대로 되돌아 가야 하며 AppBar의 Back button(the up button)과 system의 Back button은 app에서 독같이 작동해야 합니다. 사용자가 출발지에 있는 경우 AppBar의 백 버튼은 표시되지 않습니다.

    1. Stack 구조로 쌓인다. / 2. 출발지에서는 back button이 보이지 않습니다.

     

     Navigation 사용하기

    1. dependencies, Safe Args 추가

    // Kotlin
      implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
      implementation "androidx.navigation:navigation-ui-ktx:$navigation_version"

    2. Navigation Graph Resource 추가

    3. NavHostFragment 추가

    navigation을 제어하기 위해서 activity에서 어떤 fragment가 먼저 사용될 것인지 알려줘야 합니다. Navgation은 이를 위해 NavHostFragment라는 기능을 제공합니다. NavHostFragment는 activity naviagtion graph에서 fragment의 host와 같이 행동합니다. 

    <?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">
    
            <fragment
                android:id="@+id/navHostFragment"
                android:name="androidx.navigation.fragment.NavHostFragment"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:defaultNavHost="true"
                app:navGraph="@navigation/navigation"/>
        </LinearLayout>
    </layout>
    <?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">
    
            <androidx.fragment.app.FragmentContainerView
                android:id="@+id/navHostFragment"
                android:name="androidx.navigation.fragment.NavHostFragment"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:defaultNavHost="true"
                app:navGraph="@navigation/navigation"/>
        </LinearLayout>
    </layout>

    FragmentContainerView을 사용하는 경우 build.gradle에 아래의 dependencies를 추가해줘야 합니다.

    implementation 'androidx.fragment:fragment-ktx:1.3.2'
    • app:navGraph="@navigation/navigation" : 어떤 navigation grap resource가 사용되는지 알기 위해서 추가해주는 속성
    • app:defaultNavHost="true" : 해당 navigation host가 system back key를 인터셉트하게 해주는 속성 ( 여러 개의 navigation host를 생성할 수 있기 때문에 해당 설정을 명시적으로 해야합니다. 여러 개 중 하나의 NavHost만이 back 기능을 다룰 수 있게 하기 위해서 사용합니다. )

    또한 fragment를 사용하는 경우와 FragmentContainerView를 사용하는 경우 MainActivity에서 사용하는 코드가 달라집니다.

    // fragment를 사용하는 경우
    class MainActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            @Suppress
            val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
            val navController = this.findNavController(R.id.navHostFragment)
            NavigationUI.setupActionBarWithNavController(this, navController)
        }
    }
    // FragmentContainerView 사용하는 경우
    class MainActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            @Suppress
            val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
            val navHostFragment =
                supportFragmentManager.findFragmentById(R.id.navHostFragment) as NavHostFragment
            val navController = navHostFragment.navController
            NavigationUI.setupActionBarWithNavController(this, navController)
        }
    }

    4. Naviagtion Graph에 Fragment들을 추가

    fragment 추가하기

    5. Graph의 action으로 Fragment들을 연결

    6. onClickListener를 생성

    class TitleFragment : Fragment() {
        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            val binding: FragmentTitleBinding = DataBindingUtil.inflate(
                inflater, R.layout.fragment_title, container, false
            )
            binding.playButton.setOnClickListener { view: View ->
    
            }
            return binding.root
        }
    }

    7. Navigation Controller를 찾기

            binding.playButton.setOnClickListener { view: View ->
                Navigation.findNavController(view)
            }

    8. Navigate with Action

            binding.playButton.setOnClickListener { view: View ->
                Navigation.findNavController(view).navigate(R.id.action_titleFragment_to_gameFragment)
            }

     

    🏠 ID를 이용하여 navigate

    Koltin을 사용하는 경우 Android KTX로 알려진 Jetpack에 있는 navigation을 위한 특별한 version을 사용할 수 있습니다. Kotlin의 확장 함수를 사용하는 것입니다. KTX는 Android view class를 위한 확장 함수를 가지고 있기 때문에 위의 코드를 아래와 같이 변경할 수도 있습니다. 

            binding.playButton.setOnClickListener { view: View ->
                view.findNavController().navigate(R.id.action_titleFragment_to_gameFragment)
            }

    button의 경우, Navigation class에서 onClickListener를 생성할 수도 있는데 이를 이용하여 위의 코드를 아래와 같이 변경도 가능합니다. 하지만 이러한 사용은 Java를 사용하는 경우 유용합니다. Kotlin을 사용하는 경우 lambda를 사용하는 onClickListener를 사용하는 것이 createNavigateOnClickListener를 호출하는 것보다 좋습니다. ( stackOverFlow 참고 )

            binding.playButton.setOnClickListener {
                Navigation.createNavigateOnClickListener(R.id.action_titleFragment_to_gameFragment)
            }

     

     📚 Navigation과 Back stack

    일반적으로 Navigation Controller가 백 스택을 처리합니다. 목적지 fragment로 이동할 때 지나가면서 들리는 모든 fragment가 백 스택에 자동으로 추가되는 방식으로 동작합니다. navigation action을 이용하면 백 스택이 다음 대상으로 이동할 때 우리가 원하는 방식으로 동작하도록 하는 것도 가능합니다.

    popUpTo 속성을 사용하면 navigation component가 찾고자 하는 fragment를 찾을 때까지 backstack에서 fragment들을 꺼내라고 지시한 후, 원하던 fragment를 찾으면 해당 fragment transaction도 꺼냅니다. 하지만 이 속성을 사용하지 않는다면, 원하던 fragment를 찾았을 때 해당 fragment transaction을 실행합니다. 

    • 사용한 경우 : back stack에서 목적지 fragment transaction 까지 꺼냅니다.
    • 사용하지 않은 경우 : back stack에서 목적지 fragment transaction 전까지 꺼내고 해당 transaction을 실행합니다.

    popUpToInclusive 속성을 사용하면 popUpTo 속성에서 목적지로 정해두었던 fragment도 백스택에서 제거할 수 있습니다.

    • true로 설정한 경우 : 백 스택에서 목적지 fragment도 제거합니다.
    • false로 설정한 경우 : 백 스택에서 목적지 fragment 전까지만 제거합니다.

    PopUpTo 속성 사용하기 android:layout_height="match_parent"
    popUpTo를 gameFragment로 설정해 두었을 때 실행 전과 후


     🛵 Data 전달 ( Safe Args )

     Safe Args - Doc
     Pass data between destinations - Doc
     Android Codelabs 참고하기

    Fragment에서 Fragment로 데이터를 전달할 때 Bundle을 이용하여 데이터를 전달할 수 있습니다. 하지만 아래와 같이 Bundle을 사용할 경우 2가지의 문제가 발생할 수 있습니다. 

    1. Type missmatch error : 만약 fragment A가 string을 전달했는데 fragment B가 integer를 bundle에 요청한다면 해당 bundle은 default 값인 0을 반환할 것입니다. 하지만 0은 유효한 값(fragment A가 0을 넘길 수도 있기 때문에)이므로 앱이 컴파일 될 때 type missmatch error는 발생하지 않습니다. 그러나 이는 사용자가 앱을 실행할 때 오류로 인해 앱이 잘못 동작하거나 충돌할 수 있는 문제를 만들 수 있습니다.
    2. Missing key error : fragment B가 bundle에서 설정되지 않은 argument를 요청하면 null을 반환합니다.이는 앱 컴파일 시 오류가 발생하지는 않지만 사용자가 앱을 실행할 때 심각한 문제를 발생시킬 수 있습니다.

    Android Navigation Architecture Component의 Safe Args를 이용하여 위와 같은 버그 없이 데이터를 전달할 수 있습니다. 

    ▶ 사용 방법

    1. SafeArgs는 Gradle plugin으로 구현되어 있기 때문에 safe-args dependency를 project Gradle file에 추가합니다.

    classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"

    2. safeargs plugin을 app Gradle file에 추가합니다.

    plugins {
    	...
        id "androidx.navigation.safeargs.kotlin"
    }

    3. Game fragment에서 GameWon Fragment로 데이터를 전달하기 위해 navigate 코드에 GameFragmentDirections.actionGameFragmentToGameWonFragment(전달하고자 하는 데이터)를 추가합니다.

    view.findNavController().navigate(
    GameFragmentDirections.actionGameFragmentToGameWonFragment(numQuestions, questionIndex))

    4. 도착지 navigation resource로 가서 전달받고자 하는 Argments를 추가합니다.

    Arguments 추가

    5. Toast를 사용하여 GameWonFragment에 값이 잘 전달되었는지 확인합니다.

    class GameWonFragment : Fragment() {
        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
        	...
            var args = GameWonFragmentArgs.fromBundle(requireArguments())
            Toast.makeText(context,
                "NumCorrect : ${args.numCorrect}, NumQuestions : ${args.numQuestions}", Toast.LENGTH_LONG).show()
    
            return binding.root
        }
    }

    6. 값이 잘 전달되는 것을 확인하였으므로 GameFragment에서 Action ID로 이동하는 모든 부분을 NavDirections를 함께 이용하는 것으로 바꾸어 줍니다.

    // view.findNavController().navigate(R.id.action_gameFragment_to_gameOverFragment)
    
    // 위의 코드를 아래와 같이 바꿔줍니다.
    view.findNavController().navigate(GameFragmentDirections.actionGameFragmentToGameOverFragment())

     Navigation UI

    navigation controller가 제공하는 UI 라이브러리입니다.

    ▷ Action bar에 up button 자동 추가

    NavigationUI를 이용하여 편하게 ActinoBar에 back button을 추가할 수 있습니다.

    1. Activity에서 NavController를 찾아줍니다.
    2. NavController를 ActionBar에 연결합니다.
    3. ctrl + o를 사용하여 override 하고자 하는 함수 목록에서 onSupportNavigateUp 메서드를 찾아 오버라이드 해줍니다.
    class MainActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            @Suppress
            val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
            val navController = this.findNavController(R.id.navHostFragment)
            NavigationUI.setupActionBarWithNavController(this, navController)
        }
    
        override fun onSupportNavigateUp(): Boolean {
            val navController = this.findNavController(R.id.navHostFragment)
            return navController.navigateUp()
        }
    }

    ▷ Menu 추가하기

    1. menu에 추가하고자 하는 fragment를 navigation graph에 추가하기

    2. menu resource 생성하기

    3. menu.xml 에 item 추가하기 ( 이 때, item의 ID와 연결하려는 fragment의 ID가 동일해야 합니다. )

    출처 :&amp;amp;amp;amp;nbsp;https://developer.android.com/guide/navigation/navigation-ui#Tie-navdrawer

    4. 추가하려는 fragment의 onCreateView setHasOptionsMenu(true)를 선언하기

        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            ...
            // Android system에게 해당 fragment가 menu를 가지고 있다는 것을 알려줌
            setHasOptionsMenu(true)
            return binding.root
        }

    5. 추가하려는 fragment에 onCreateOptionsMenu를 override 하기

        // menu를 어디에 inflate 할 지 알려주는 코드
        override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
            super.onCreateOptionsMenu(menu, inflater)
            inflater?.inflate(R.menu.overflow_menu, menu)
        }

    6. 추가하려는 fragment에 onOptionsItemsSelected를 override 하고 NavigationUI.onNavDestinationSelected 호출하기

        override fun onOptionsItemSelected(item: MenuItem): Boolean {
            return NavigationUI.onNavDestinationSelected(item!!, requireView().findNavController())
                    || super.onOptionsItemSelected(item)
        }

    ▷ Navigation Drawer

    유저가 화면의 왼쪽(starting) edge에서 스와이프 하거나 action bar의 drawer 아이콘 (= 햄버거 아이콘)을 선택하면 나타나는 view입니다. 

    navigation drawer

    1. app Gradle file에 dependency 추가하기

        implementation 'com.google.android.material:material:1.3.0'

    2. drawer에서 사용할 menu 추가하기

    3. menu에 itme 추가하기 ( rulesFragment와 aboutFragment 추가하기 )

    4. Layout에 DrawerLayout과 NavigationView를 추가해줍니다. 

    • app:menu="@menu/navdrawer_menu"을 통해서 어떤 menu를 사용할지 결정
    • app:headerLayout="@layout/nav_header"을 통해서 어떤 headerLayout을 사용할지 결정
    <?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">
    
        <androidx.drawerlayout.widget.DrawerLayout
            android:id="@+id/drawerLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
    
            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="vertical">
    
                <fragment
                    android:id="@+id/navHostFragment"
                    android:name="androidx.navigation.fragment.NavHostFragment"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    app:defaultNavHost="true"
                    app:navGraph="@navigation/navigation" />
            </LinearLayout>
    
            <com.google.android.material.navigation.NavigationView
                android:id="@+id/navView"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:layout_gravity="start"
                app:menu="@menu/navdrawer_menu"
                app:headerLayout="@layout/nav_header"/>
        </androidx.drawerlayout.widget.DrawerLayout>
    </layout>

    5. MainAcivity에 drawerLayout을 선언, 초기화 한 다음 프로젝트를 rebuild 해줍니다.

    6. NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout)과 NavigationUI.setupWithNavController(binding.navView, navController)을 선언하여 navigation view를 navigation UI에 연결해줍니다. 

        private lateinit var drawerLayout: DrawerLayout
        //private lateinit var appBarConfiguration: AppBarConfiguration
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            @Suppress
            val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
            drawerLayout = binding.drawerLayout
    
            val navController = this.findNavController(R.id.navHostFragment)
            NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout)
            //appBarConfiguration = AppBarConfiguration(navController.graph, drawerLayout)
    
            NavigationUI.setupWithNavController(binding.navView, navController)
        }

    7. onSupportNavigateUp에 NavigationUI.navigateUp(navController, drawerLayout)을 선언합니다.

        override fun onSupportNavigateUp(): Boolean {
            val navController = this.findNavController(R.id.navHostFragment)
            return NavigationUI.navigateUp(navController, drawerLayout)
        }

     

    💡 addOnDestinationChangedListener을 이용하여 navigation이 시작하는 곳을 제외하고 다른 화면에서 drawer을 사용하지 못하게 할 수 있습니다.

            // 시작점이 아니면 drawer layout 보이지 않게 하기
            navController.addOnDestinationChangedListener {
                nc: NavController, nd: NavDestination, args: Bundle? ->
                if (nd.id == nc.graph.startDestination) {
                    drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
                }
                else {
                    drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
                }
            }

     

    'Android > Concept' 카테고리의 다른 글

    [ Android ] ADB 사용하기  (0) 2021.04.27
    [ Android ] Android Lifecycle  (0) 2021.04.15
    [ Android ] Constraint layout  (0) 2021.04.08
    [ Android ] Data binding  (0) 2021.04.06
    [ Android ] View Binding  (0) 2021.03.24
    Comments