프로그래밍/Android

[안드로이드] 예제로 보는 NavigationComponent

Lou Park 2022. 9. 18. 23:45

Navigation Component란?

기존의 안드로이드에서는 여러 단계의 Fragment 진행을 추적하고, 다루기 어려웠다. Jetpack의 Naviagtion Component는 이러한 문제를 개선하기 위해서 등장했는데, iOS의 스토리보드처럼 여러 화면 이동을 그래프(Graph)로 시각화하여 보여주고 NavController를 이용해 한 곳에서 전환을 관리할 수 있도록 도와준다.

Android Navigation Component

“오~좋은건 알겠는데, 다음 프로젝트에 써야지^^”라는 생각이 스친다!

당장 레거시 코드에서 어떻게 활용하면 좋을지 막막할 것이다. (내가 그랬기때문) 그래서 회원가입을 예로 들어서, 현재 진행중인 프로젝트에 Navigation Component를 사용하는 방법을 포스팅해보겠다.

 

# 간단한 개념 3가지

  • NavGraph: XML 리소스로, res/navigation에 위치한다. NavHost에서 어느 NavGraph를 따를지 설정할 수 있다.
  • NavHost: 네비게이션을 표시하는 빈 컨테이너다. 이 안에 NavGraph의 화면들이 표시된다. 각 NavHost에는 각각의 자체 NavController가 존재한다. findNavController() 로 찾을 수 있다.
  • NavController: NavHost 내에서 네비게이션을 관리하는 객체다. 어딘가 다른 화면으로 이동하려고하면 현재 NavHost의 NavController를 이용하면 된다.

 

# 회원가입 프로세스

구현하려고하는 프로세스는 다음과 같다.

처음은 메인화면이고, 만약에 로그인되어있다면 닉네임을 보여준다. 여기서 회원가입 버튼을 통해 회원가입을하고 다시 돌아오면 가입한 닉네임이 보일 것이다. 아이디가 없는 유저는 그림의 빨간 화살표를 따라 회원가입을 진행하게된다.

 

이를 직접 구현할때 생기는 문제는 “뒤로가기”이다.

 

회원가입 가장 마지막단계인 “약관동의 및 가입” 단계에서 뒤로가기를 누르면 인증번호 입력으로 가면 안된다. 인증은 끝났으니 휴대폰 입력으로 가는 것이 맞다.

따라서 정리하자면 화면진행은 다음과 같이 바뀐다. 파란색 화살표는 뒤로가기를, 빨간색 화살표는 진행방향이다. 여기서 Activity는 2개인데, 다음과같이 구성된다.

  • MainActivity: 메인화면
  • SignUpActivity: 회원가입 화면
    • NicknameFragment: 닉네임 입력
    • PhoneFragment: 휴대폰 입력
    • VerifyCodeFragment: 인증번호 입력
    • AgreementFragment: 약관동의 및 가입

레거시 프로젝트에 적용시킨다는 가정이니, MainActivity는 Navigation Component를 사용하고 있지 않은 상태이다.

 

# 시연영상 & 소스코드

여간 귀찮은 일이 아니구만~?!

 

전체 코드는 여기에있다! 

https://github.com/lx5475/NavComponent-Tutorial

 

GitHub - lx5475/NavComponent-Tutorial: Apply android jetpack NavComponent on legacy project

Apply android jetpack NavComponent on legacy project - GitHub - lx5475/NavComponent-Tutorial: Apply android jetpack NavComponent on legacy project

github.com

 

# 준비하기

kotlin 프로젝트 기준으로 app/build.gradle에 다음과같이 Navigation Component 라이브러리를 선언해주자. 화면간 TypeSafe한 데이터 이동을 위해서 SafeArgs도 사용할 것이기 때문에 plugins에도 safeargs를 추가해준다.

plugins {
    id 'androidx.navigation.safeargs'
}

dependencies {
    def nav_version = "2.5.1"
    // Kotlin
    implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
    implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
    // Feature module Support
    implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
}

project/build.gradle에서도 safeargs를 추가한다.

buildscript {
    repositories {
        google()
    }

    dependencies {
        def nav_version = "2.5.1"
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
    }
}

 

*Activity와 Fragment 만들기

빨간줄이 뜨는게 싫다면 미리 내용없는 Fragment와 Activity를 준비해두자. 세부 내용은 같이 살펴볼 것인데, Git Repository를 참조하면서 빠르게 만들어두면된다!

Project Structure

 

# Navigation Graph 구성하기

res/navigation 아래에 signup_nav_graph.xml을 생성한다.

@navigation/signup_nav_graph.xml

<?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"
    android:id="@+id/signup_nav_graph"
    app:startDestination="@id/nicknameFragment">

    <fragment
        android:id="@+id/nicknameFragment"
        android:name="com.loki.navigationcomponenttutorial.signup.NicknameFragment"
        android:label="닉네임 입력" >
    </fragment>

    <fragment
        android:id="@+id/phoneFragment"
        android:name="com.loki.navigationcomponenttutorial.signup.PhoneFragment"
        android:label="휴대폰 입력" >
    </fragment>

    <fragment
        android:id="@+id/verifyCodeFragment"
        android:name="com.loki.navigationcomponenttutorial.signup.VerifyCodeFragment"
        android:label="인증번호 입력" >
    </fragment>

    <fragment
        android:id="@+id/agreementFragment"
        android:name="com.loki.navigationcomponenttutorial.signup.AgreementFragment"
        android:label="약관 동의" >
    </fragment>

</navigation>

navigation:id

내용을보면 <navigation> 태그의 id는 NavGraph의 ID를 뜻하며, NavHost에서 어떤 graph를 따라갈지 참조하는데 사용한다.

 

navigation:startDestination

이 NavGraph의 시작 지점을 어디로할지 설정할 수 있다. 회원가입 스텝은 닉네임을 정하는 것이 첫번째이므로 NicknameFragment의 ID로 설정해주었다.

 

fragment:name

실질적으로 어느 Fragment인지 패키지네임과 함께 적어주는 부분이다. 예시에는 Fragment 밖에없지만, Activity도 될 수 있고, DialogFragment의 경우 dialog로도 적는다.

 

# 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">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment_content_signup"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/signup_nav_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>

회원가입 Activity의 내용인데, Fragment가 들어갈 부분에 FragmentContainerView가 위치한다. 이는 NavHost인데, name에보면 NavHostFragment라고 설정되어있음을 볼 수 있다.

 

defaultNavHost가 참일경우, 시스템 입력 뒤로가기(BackPress)를 가로챈다. 뒤로가기를 눌렀을때 NavGraph 흐름에따라 가게되는 것이다. 이러한 특성때문에 한 화면에는 하나의 NavHost만 기본값으로 설정할 수 있다.

 

navGraph부분을 보면 방금전에 만든 @navigation/signup_nav_graph를 참조하도록 한 것이 보일 것이다.

이로인해 SignUpActivity는 별다른 설정을 해주지 않아도 NicknameFragment가 제일 처음보이게 된다.

 

Fragment 사용 설정을 끝낸 Activity의 전체 코드다. 

믿기지 않을 정도로 깔끔하구나&hellip;!

 

# 화면 간 이동 추가하기

<?xml version="1.0" encoding="utf-8"?>
<navigation>
        ...
    <fragment
        android:id="@+id/nicknameFragment"
                android:name="com.loki.navigationcomponenttutorial.signup.NicknameFragment"
        android:label="닉네임 입력">
        <action
            android:id="@+id/action_nicknameFragment_to_phoneFragment"
            app:destination="@id/phoneFragment" />
    </fragment>

    <fragment
        android:id="@+id/phoneFragment"
                android:name="com.loki.navigationcomponenttutorial.signup.PhoneFragment"
        android:label="휴대폰 입력">
        <action
            android:id="@+id/action_phoneFragment_to_verifyCodeFragment"
            app:destination="@id/verifyCodeFragment" />
        <action
            android:id="@+id/action_phoneFragment_to_agreementFragment"
            app:destination="@id/agreementFragment" />
    </fragment>
    ...
</navigation>

다시 navigation/signup_nav_graph로 돌아와서 action을 추가해보자. NavigationComponent에서 이동은 Action으로 정의된다.

signup_nav_graph

닉네임 입력 후에 휴대폰 입력으로 이동하는 것, 휴대폰 입력 후 인증화면, 인증된 휴대폰 화면에서 동의화면으로 가는 것들을 정의해두었다. “처음보는 태그들에 어떻게 입력해야하나…”싶지만 Android Studio GUI가 잘 되어있어서 마우스로 화면 끝과 끝을 잇는 것으로 간단하게 설정할 수 있다. 이제, 화면 흐름이 한눈에 보이기 시작한다.

그럼 예시로, NicknameFragment에서 “다음” 버튼을 눌러 PhoneFragment로 이동하는 부분을 살펴보자.

class NicknameFragment : Fragment() {
    // ...
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.btnNext.setOnClickListener {
            findNavController().navigate(R.id.action_nicknameFragment_to_phoneFragment)
        }
    }
}

findNavController()로 NavController를 찾고, navigate(Action)으로 앞서 정의한 Action을 이용하여 이동하는 코드다. 이대로 실행시키면 “다음”버튼을 누르면 PhoneFragment가 보일것이고, 뒤로가기를 하면 NicknameFragment로 돌아오게된다.

 

# 데이터 전달하기

이동은 OK! 하지만 입력한 닉네임과 휴대폰 번호를 다음 화면에 전달해줘야하는 문제가 있다.

SafeArgs를 사용하지 않는다면 다음과 같은 방법으로 주고 받을 수 있다.

// Sending part
val bundle = Bundle()
bundle.putString("nickname", nickname)
findNavController().navigate(R.id.action_nicknameFragment_to_phoneFragment, bundle)

// Receiving part
val nickname = arguments?.getString("nickname") ?: ""

하지만 타입Safe하지 않기때문에 SafeArgs를 이용하여 데이터 전달을 하게되면 이러한 문제를 피할 수 있다.

 

다시, signup_nav_graph로 돌아가야한다.

<fragment
    android:id="@+id/phoneFragment"
    android:name="com.loki.navigationcomponenttutorial.signup.PhoneFragment"
    android:label="휴대폰 입력" >
    //...
    <argument
        android:name="nickname"
        app:argType="string"/>
</fragment>

<argument>에 전송할 Key값과 Argument의 타입을 명시해주면 된다.

 

일반적으로 인텐트에 싣는 argument와 동일한 타입만 전송할 수 있고, 기본값이 Null임을 지원하려면 android:defaultValue="@null"로 설정해주면된다. 지원하는 모든 타입은 문서를 참조하자.

 

argument를 추가하면 [Class명 + Args] 형태의 NavArgs 클래스가 자동생성되는데, 여기서 꺼내어서 쓸 수 있다.

class PhoneFragment : Fragment() {
    // ...
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val nickname = PhoneFragmentArgs.fromBundle(requireArguments()).nickname
    }
}

 

# 인증이 되었을때만 동의 화면가기

PhoneFragment에서 휴대폰 번호 입력이 끝나면, VerifyCodeFragment에서 사용자가 인증번호를 입력하길 기다려야한다. 아래는 PhoneFragment.kt의 버튼 클릭 코드다.

binding.btnNext.setOnClickListener {
    phone = binding.etPhone.text.toString()

    if (phone.isEmpty()) {
        Snackbar.make(binding.btnNext, "PhoneNumber must not be empty.", Snackbar.LENGTH_SHORT).show()
        return@setOnClickListener
    }

    val bundle = Bundle()
    bundle.putString("phone", binding.etPhone.text.toString())

    findNavController().navigate(
        R.id.action_phoneFragment_to_verifyCodeFragment,
        bundle
    )
}

휴대폰 번호가 비어있지않다면 VerifyFragment로 휴대폰 번호와 같이 이동을 한다.

 

다음은 VerifyFragment.kt의 코드다.

/**
 * 인증번호 입력화면
 */
class VerifyCodeFragment : Fragment() {

    private lateinit var savedStateHandle: SavedStateHandle
    private var _binding: FragmentVerifyCodeBinding? = null
    private val binding get() = _binding!!

    private val viewModel: SignUpViewModel by activityViewModels()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentVerifyCodeBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 이전 화면(PhoneFragment)의 savedStateHandle
        savedStateHandle = findNavController().previousBackStackEntry!!.savedStateHandle
        savedStateHandle[PHONE_VERIFIED] = false

        // 넘어온 phone이 이미 확인된 거라면 PASS
        val phone = VerifyCodeFragmentArgs.fromBundle(requireArguments()).phone
        if (viewModel.isVerifiedPhoneNumber(phone)) {
            onVerificationSucceed(phone)
        }

        binding.btnNext.setOnClickListener {
            val code = binding.etCode.text.toString().trim()
            if (code == TEST_CODE) {
                onVerificationSucceed(phone)
            } else {
                Snackbar.make(binding.btnNext, "Incorrect verification code.", Snackbar.LENGTH_SHORT).show()
            }
        }
    }

    // 인증성공, PhoneFragment로 돌아간다.
    private fun onVerificationSucceed(phone: String) {
        savedStateHandle[PHONE_VERIFIED] = true
        viewModel.addVerifiedPhoneNumber(phone)
        findNavController().popBackStack()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    companion object {
        const val TEST_CODE = "1234" // 테스트용 인증번호
        const val PHONE_VERIFIED = "PHONE_VERIFIED"
    }
}

주목할 부분은 SavedStateHandle 부분이다. SavedStateHandle은 Activity나 Fragment에 값을 저장하고 복원하기 위해 사용되는 것인데, 이전 화면인 PhoneFragment의 SavedStateHandle 값을 처음에 false로 지정한다. 이는 인증이 되지않았다고 초기화 해주는 부분이다.

 

인증이 완료되었을때 호출되는 onVerificationSucceed() 부분을보면, SavedStateHandle 값을 true로 전환하고 popBackStack()을 통해 뒤로가서 PhoneFragment로 복귀를 한다.

 

PhoneFragment에서는 현재 백스택 엔트리의 SavedStateHandle의 값을 가져온다. LiveData형태로 구독하고 있다가, 만약에 true가되면 동의화면으로 넘어가게 된다.

class PhoneFragment : Fragment() {

    private var nickname: String = ""
    private var phone: String = ""

    // ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val navController = findNavController()
        val currentBackStackEntry = navController.currentBackStackEntry!!

        currentBackStackEntry.savedStateHandle
            .getLiveData<Boolean>(VerifyCodeFragment.PHONE_VERIFIED)
            .observe(currentBackStackEntry) { verified ->
                if (verified) {
                    val bundle = Bundle()
                    bundle.putString("nickname", nickname)
                    bundle.putString("phone", phone)
                    navController.navigate(
                        R.id.action_phoneFragment_to_agreementFragment,
                        bundle
                    )
                }
            }
    }
}

이렇게 하면 인증이 되었을때만 동의화면으로 갈 수 있으며, 인증번호 화면은 이미 백스택에 존재하지 않기 때문에 동의 화면에서 뒤로가기를 눌러도 휴대폰 화면으로 갈 뿐이다.

 

# 다시, 기존의 화면으로

동의화면에서, 회원가입 플로우가 끝나고 메인화면(MainActivity)로 이동할 것인데, MainActivity는 기존의 화면이동 시스템을 따르고있기때문에 따로 액션이 없다. 원래 쓰던대로 Intent를 사용해 이동시킬 수 있다.

class AgreementFragment : Fragment() {
    // ...
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val args = AgreementFragmentArgs.fromBundle(requireArguments())
        val nickname = args.nickname
        val phone = args.phone

        // 가입내용을 보여준다.
        binding.tvData.text = "Nickname: $nickname\nPhone: $phone"

        // 메인 화면으로 보낸다.
        binding.btnSignup.setOnClickListener {
            startActivity(Intent(requireContext(), MainActivity::class.java).apply {
                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
                putExtra("nickname", nickname)
            })
        }
    }
}

여기까지가 NavigationComponent를 레거시 프로젝트에서 도입하는 방법의 끝이다. 중간 중간에 빠진 코드 분량이 많을텐데, Git에 업로드 해두었으니 구석구석 살펴보시면 될 것이다.

https://github.com/lx5475/NavComponent-Tutorial

 

# 뒤로가기하면 휴대폰말고, 닉네임 화면으로 가게해달래요! 어떡하죠?

마지막 동의화면에서 뒤로가기를 누를때 닉네임으로 가게해달라는 요구가 있을 수 있다. (정말..현실적으로 있다! ㅋㅋㅋㅋ) 전통적인 방식으로 개발했다면 머리가 아파올 것이다.

하지만 이제는 NavigationComponent를 도입했다구! NavGraph쪽만 수정하면된다.

<fragment
    android:id="@+id/phoneFragment"
    android:name="com.loki.navigationcomponenttutorial.signup.PhoneFragment"
    android:label="휴대폰 입력" >
    <action
        android:id="@+id/action_phoneFragment_to_verifyCodeFragment"
        app:destination="@id/verifyCodeFragment" />
    <action
        android:id="@+id/action_phoneFragment_to_agreementFragment"
        app:destination="@id/agreementFragment"
        app:popUpTo="@id/nicknameFragment"
        app:popUpToInclusive="false"/>
    <argument
        android:name="nickname"
        app:argType="string"/>
</fragment>

동의화면(AgreementFragment)으로 이동하는 부분에 popUpTo, popUpToInclusive라는 옵션 두개가 추가된 것이 보인다. 화면을 이동할때 뒤에 쌓인것을 우리는 백스택(BackStack)이라고 부르고 있다. 스택에서 아이템을 뺄때는 pop!이라고 하므로, popUp은 팝업창이아니라, 뒤로갔을때 얘기다.

 

PhoneFragment > AgreementFragment로 가는 Action인 action_phoneFragment_to_agreementFragment를 타고가면, 뒤로가기를 눌렀을때 NicknameFragment가 나올때까지 곧장 뒤로 쭉—쭉 후진하라는 명령이다.

 

popUpToInclusive는 popUpTo에 지정된 대상을 포함하냐 마냐인데, 이것을 true로하면 지정된 대상인 NicknameFragment까지 pop이된다. 기획자가 만약에 “동의화면에서 뒤로가면, 그냥 메인화면으로 돌아가게 해주세요~”라고 요구할경우에 popUpToInclusive=true로 설정해주면된다.

 

 

# 애니메이션 챙겨

기존 코드에는 아마도 애니메이션이있었을지모르지만, 우리가 방금까지 구현한 화면이동에는 애니메이션이 빠져있었다. 이것도, NavGraph만 손보면 해결이다! 기존에 쓰던 애니메이션 xml 파일을 그대로 사용하여 원하는 곳에 연결하면된다.

<fragment
    android:id="@+id/phoneFragment"
    android:name="com.loki.navigationcomponenttutorial.signup.PhoneFragment"
    android:label="휴대폰 입력" >
        <action
        android:id="@+id/action_phoneFragment_to_agreementFragment"
        app:destination="@id/agreementFragment"
        app:popUpTo="@id/nicknameFragment"
        app:popUpToInclusive="false"
                app:enterAnim="@anim/slide_in_right"
        app:exitAnim="@anim/slide_out_left"
        app:popEnterAnim="@anim/slide_in_left"
        app:popExitAnim="@anim/slide_out_right"/>
</fragment>

 

 

참고자료

https://developer.android.com/guide/navigation?hl=ko