본문 바로가기

개발일기

8월 한달 토이 프로젝트를 해보자! -02 : Navigation

1일차의 목표는 UI 구성이다. 이를 위하여 Fragment Navigation 설정을 우선 해줄 것이다. 디자인이 나와있지 않기 때문에(어차피 내 맘대로 해야 하지만) 화면 전환을 우선 구성하기로 한다.

Empty Activity 베이스로 프로젝트를 생성 후, 종속성을 추가해준다. 종속성은 한 번에 전부 추가하기보다, 매 섹션별로 필요한 종속성을 추구하고 관련된 태스크를 다루는 식으로 포스트를 구성하려 한다.


1. 종속성 추가

build.gradle(:app)

plugins {
    ...
    // safeargs 설정을 위해 id 추가. project level gradle에도 
    // 동일한 절차를 수행할 것이다.
    id 'androidx.navigation.safeargs.kotlin'
}

...

// 가장 최신의 version 코드를 부여하도록 하겠다. 
def nav_version = "2.5.1

dependencies {
    ...
    // fragment navigation
    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"

    // Testing Navigation
    androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
}

다음으로 project level gradle에 safeArgs 설정을 해준다. 유형 안정성을 통해, 데이터의 key-value의 타입 캐스팅 미스에 대하여 내가 신경 쓸 여지를 줄여준다.

build.gradle (Project Name)

plugins {
    ...
    id 'androidx.navigation.safeargs' version '2.5.1' apply false
}

sync 후 Build 하여 모든 게 정상적인지 확인해본다. 물론 아직 아무것도 새로 생성해준 것은 없으니 이상이 있을 수 없을 것이다.


2. Fragments 추가

현재의 예정된 화면은 1. 로그인 2. 회원가입 3. 리스트 4. 디테일 5. 설정 총 5개다. 각각의 xml을 우선 만들어두자. 물론 아직 내용은 없이 xml과 디폴트 Fragment 클래스만 생성할 것이다.

 

SettingsFragment 예로 들어, 이렇게 초기 세팅을 해주었다.

class SettingsFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {

        return inflater.inflate(R.layout.settings_fragment, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
    }

}

3. Navigation 만들기

res 디렉토리에 Android Resource File을 추가해준다. 나는 이름을 main_nav라 짓고, 우선 로그인 <-> 리스트 <-> 디테일 이 메인 내비게이션을 설정해줄 것이다. 큰 줄기를 만든 후 나머지를 추가할 계획. 이때, Resource type을 Navigation 이라고 설정해주면 알아서 디렉토리를 만들어준다.

아래와 같이 텅 빈 nav가 만들어졌다.

좌측 상단의 추가 버튼을 눌러, destination들을 추가해주도록 하자. 위에서 말하였듯 이 경우에는 LogInFragment, ListFragment, DetailFragment 세 가지를 추가해줄 것이다.

정렬하고 싶어... 아무튼 각각의 destination에서 선을 그어, 어디로 갈지를 표시해주도록 한다.

일단은 각각의 id만 설정해주도록 하자! 모델 클래스의 정의를 아직 해두지 않았으니, 전달할 Argument를 설정해줄 수 없다. (우측 창의 Argument Default Values) 사용자 계정을 고유값으로 설정해둔 후, 그걸 String으로 로그인 -> 리스트로 전달해줄 것 같긴 하다. 단순하게 가자구~

이제, activity_main.xmlFragmentContainerView를 추가하여 실제로 액티비티 위에 띄우도록 하자.

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/main_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:defaultNavHost="true"
        app:navGraph="@navigation/main_nav" />

Navigation이 정상적으로 동작할지 테스트를 위하여 LogInFragment, ListFragment에 각각 toList, toDetail 함수를 수행할 UI 컴포넌트를 만들어주고, 내비게이션바의 백버튼으로 돌아갈 수 있는지도 확인한다.

우선 로그인 페이지에 버튼을 만들어준 후, Fragment에 View.OnCLickListener를 상속시켜 onClick을 오버라이드 한다. 그리고 ListFragment로 탐색할 함수를 생성한다.

이때, Directions가 찾아지지 않는 경우가 발생할 수 있다. 침착하게 프로젝트 빌드를 한번 해주자. 정상적으로 조회될 것이다. Navigation은 새로 추가해줄 때마다 빌드를 해줌으로써 프로젝트에 싱크해줄 수 있다.

    private fun toList() {
        val action = LogInFragmentDirections.loginToList()
        Navigation.findNavController(requireActivity(), R.id.main_container)
            .navigate(action)
    }

    override fun onClick(v: View?) {
        when(v?.id) {
            R.id.btn_logIn -> toList()
        }
    }

버튼 변수를 만들어주고, 리스너를 달기/해제 해준다. 과거에는 해제해주지 않으면 메모리 누수가 발생할 수 있었다 하는데, 안드로이드 프레임워크가 이걸 알아서 컨트롤 해주게 바뀌었다는 글도 본 적이 있는 것 같다. 확실하게 아신다면 알려주십쇼,, 굽신

    private val btnLogIn: MaterialButton by lazy {
        view?.findViewById(R.id.btn_logIn) as MaterialButton
    }

    ...

        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        btnLogIn.setOnClickListener(this)
    }

    override fun onStop() {
        super.onStop()

        btnLogIn.setOnClickListener(null)
    }

ListFragment에도 동일한 작업을 해준 후, 일단은 별도의 처리 없이 OnBackPressed를 활용하자.

좋아.. 실행을 해보자.
앗!

"""
android.view.InflateException: Binary XML file line #2 in com.movingroot.toyproject:layout/login_fragment: Binary XML file line #2 in com.movingroot.toyproject:layout/login_fragment: Error inflating class layout
Caused by: android.view.InflateException: Binary XML file line #2 in com.movingroot.toyproject:layout/login_fragment: Error inflating class layout
Caused by: java.lang.ClassNotFoundException: android.view.layout
"""

에러가 발생했다! 로그를 보니 layout 클래스를 찾지 못했다고. 이전에 하던대로 가장 베이스를 layout 태그로 달아주었지만 dataBinding을 enabled 해주지 않아서 발생한 이슈다.
gradle에서 해당 처리를 해주고 나니 정상적으로 런칭이 됨을 확인할 수 있다. 또한, 의도한 대로 앱이 동작하고 있음도 확인할 수 있다... 하지만 정말 그럴까? 되돌아온 후 다시 다음 화면으로 돌아가보자.

버튼이 동작을 안한다! 눌리긴 눌리는데 안 넘어간다! 리스너가 안 먹힌다! 왜일까!

분위기 고조시킬려고 이렇게 강조하는 거 아닙니다 진짜로 모르겠습니다... 좀 알려주세요 굽신굽신

사실 이전에 공부할 때에도 동일한 현상을 겪은 적이 있다. 그때도 원인을 결국 파악하지 못하고 자동으로 호출되는 popBackStack() 대신 action을 사용하여 해결하였다. 이번에도 일단 진행은 그렇게 할 것이다. (저에겐 시간이 없습니다... 일단 완성시키고 이후에 문제로 돌아올 것입니다) 이를 위하여 처음에 양갈래 액션을 설정해두었던 것. + 단순히 이전 Fragment로 돌아가는 것 외에도 복잡한 프로세스, 예를 들어 로그아웃 프로세스라든지 작성 중인 사항 저장 안 하고 돌아갈 건지 묻는 다이얼로그 생성 등을 추가할 수 있기 때문에 액션을 따로 걸어두기로 하였다.

OnBackPressedCallback을 생성, Fragment의 onAttach, OnDetach에 열심히 설정해주자. 아래는 예시다.

DetailFragment

    private lateinit var backCallback: OnBackPressedCallback

    ...

        override fun onAttach(context: Context) {
        super.onAttach(context)
        backPress()
    }

    override fun onDetach() {
        super.onDetach()
        backCallback.remove()
    }

    private fun toList() {
        val action = DetailFragmentDirections.detailToList()
        Navigation.findNavController(requireActivity(), R.id.main_container)
            .navigate(action)
    }

    private fun backPress() {
        backCallback = object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                toList()
            }
        }
        requireActivity().onBackPressedDispatcher.addCallback(this, backCallback)
    }

각 Fragment에 달아준 후, 자 이제 다시 해보자.
동작한다~!

블로그 닉값을 하도록 하자. 돌아가는 코드는 훌륭한 코드다. 일단 이것을 성공으로 치겠다! 껄껄. 다음 단계는, 본격적인 디자인 작업에 들어갈 것이다. 우선 로그인 레이아웃을 만들어준 후, 위에서 dataBinding enable 해준 김에 데이터 바인딩 시켜줄 것이다.

다만, ViewModel 적용은 이후에 Clean Architecture 적용을 고민하는 단계에 이르렀을 때 추가해주도록 할 것이다. Step by step이 좋지 않겠습니까 하하.

그럼 20,000. 내래 곧 다시 돌아오갓서