경험의 기록

카카오톡을 보면

바텀네비게이션 메뉴를 클릭해 다른 화면으로 이동 후 다시 돌아왔을 때

기존의 화면이 유지되어 있는 것을 확인할 수 있다.

또한 그 화면에서 같은 메뉴를 다시 클릭하면 화면이 맨 위로 올라간다.

 

하지만 Navigation 라이브러리는 기본적으로 replace로 작동하기 때문에

화면 전환 후 다시 돌아오거나, 화면에서 같은 메뉴를 다시 클릭 시 둘 다 화면이 재생성된다.

 

사용자 입장에서 화면 전환시마다 상태가 초기화되면 좋지 못하고,

개발자 입장에서도 불필요한 데이터 로드가 반복되기 때문에 좋지 못하다.

 

이 부분을 카카오톡과 유사하게 동작하도록 변경해보려고 한다.

우선적으로, Jetpack Navigation을 커스텀하여 구현해보려고 했으나

 

https://hungseong.tistory.com/56

 

[Android, Kotlin] Bottom Navigation View + Jetpack Navigation 바텀 메뉴 클릭 시 프래그먼트 재생성 막기

문제 : 위 사진(카카오톡)과 같이 바텀 메뉴 클릭 시 최초 클릭 시에는 프래그먼트가 생성되어 화면이 로딩되고, 이후 클릭 시에는 기존 생성된 프래그먼트가 유지되었으면 한다. 그러나 기본적

hungseong.tistory.com

 

현재(2022.06.01) 기준 네비게이션 최신 버전은 2.4.2 인데,

훙성님 블로그에 따르면 2.3.5 버전 이후로는 커스텀이 제대로 되지 않는다고 한다.

물론 예전 버전을 사용하는 방법도 있으나, 다른 방법으로 해결할 수 없을까 생각하다가

FragmentManager를 사용한 방법으로 구현해보았다.

 

Navigation 커스텀하여 구현하는 방법은

https://github.com/HanYeop/Memory-of-Music-android

 

GitHub - HanYeop/Memory-of-Music-android: 음악 기록 앱

음악 기록 앱. Contribute to HanYeop/Memory-of-Music-android development by creating an account on GitHub.

github.com

위 프로젝트를 참고할 수 있다.


1️⃣ 화면 재생성 막기

레이아웃

<?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"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".view.MainActivity">

        <com.google.android.material.appbar.MaterialToolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/white"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <TextView
                android:id="@+id/text_title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="타이틀"
                android:layout_gravity="center"
                android:textSize="22sp"
                android:textColor="@color/black"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

        </com.google.android.material.appbar.MaterialToolbar>

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/fragmentContainerView"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toTopOf="@+id/bottomNavi"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/toolbar" />

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bottomNavi"
            android:layout_width="match_parent"
            android:layout_height="48dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:menu="@menu/menu_bottom" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

 

우선적으로, 위와 같은 메인 레이아웃을 구성하였고

1번, 2번 프래그먼트는 각각 임의의 데이터를 서버로부터 불러와 보여준다.

FragmentContainerView는 최초 생성 시 처음 화면을 추가해줄 것이므로 아무 프래그먼트도 넣지 않았다.

 

MainActivity

class MainActivity : AppCompatActivity() {

    private val fragmentManager = supportFragmentManager
    
    private lateinit var binding : ActivityMainBinding

    private var oneFragment: OneFragment? = null
    private var twoFragment: TwoFragment? = null
    private var threeFragment: ThreeFragment? = null

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

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.lifecycleOwner = this

        initBottomNavigation()
    }

    private fun initBottomNavigation(){
        // 최초로 보이는 프래그먼트
        oneFragment = OneFragment()
        fragmentManager.beginTransaction().replace(R.id.fragmentContainerView,oneFragment!!).commit()

        binding.bottomNavi.setOnItemSelectedListener {

            // 최초 선택 시 fragment add, 선택된 프래그먼트 show, 나머지 프래그먼트 hide
            when(it.itemId){
                R.id.oneFragment ->{
                    if(oneFragment == null){
                        oneFragment = OneFragment()
                        fragmentManager.beginTransaction().add(R.id.fragmentContainerView,oneFragment!!).commit()
                    }
                    if(oneFragment != null) fragmentManager.beginTransaction().show(oneFragment!!).commit()
                    if(twoFragment != null) fragmentManager.beginTransaction().hide(twoFragment!!).commit()
                    if(threeFragment != null) fragmentManager.beginTransaction().hide(threeFragment!!).commit()

                    return@setOnItemSelectedListener true
                }
                R.id.twoFragment ->{
                    if(twoFragment == null){
                        twoFragment = TwoFragment()
                        fragmentManager.beginTransaction().add(R.id.fragmentContainerView,twoFragment!!).commit()
                    }
                    if(oneFragment != null) fragmentManager.beginTransaction().hide(oneFragment!!).commit()
                    if(twoFragment != null) fragmentManager.beginTransaction().show(twoFragment!!).commit()
                    if(threeFragment != null) fragmentManager.beginTransaction().hide(threeFragment!!).commit()

                    return@setOnItemSelectedListener true
                }
                R.id.threeFragment ->{
                    if(threeFragment == null){
                        threeFragment = ThreeFragment()
                        fragmentManager.beginTransaction().add(R.id.fragmentContainerView,threeFragment!!).commit()
                    }
                    if(oneFragment != null) fragmentManager.beginTransaction().hide(oneFragment!!).commit()
                    if(twoFragment != null) fragmentManager.beginTransaction().hide(twoFragment!!).commit()
                    if(threeFragment != null) fragmentManager.beginTransaction().show(threeFragment!!).commit()

                    return@setOnItemSelectedListener true
                }
                else ->{
                    return@setOnItemSelectedListener true
                }
            }
        }
    }
}

코드를 하나씩 살펴보면

 

아이템 선택 부분에서 null 인지 체크하여 최초 생성 시에만 초기화해주기 위해서 

각각의 프래그먼트를 null로 선언해준다.

 

처음 화면은

미리 초기화해주고 replace를 통해 화면에 추가해준다.

 

이제 선택된 메뉴에 따른 동작을 처리해주는데,

만약 첫 번째 메뉴가 클릭되었다면 우선적으로 한 번도 생성되지 않았는지 null 체크하여 판별 후

최초 생성이라면 add를 통해 추가해준다.

그 후 본인 화면을 제외한 나머지 화면을 전부 hide 처리한다.

나머지 부분도 이와 같은 동작으로 처리한다.

 

즉, 선택된 화면을 show 처리하고, 나머지 화면을 hide 처리함으로써 최초 생성 시에만 생성되도록 한다.

 

현재 상태가 잘 유지되는 것을 확인할 수 있다.

 

2️⃣ 세부 화면 이동 시

 

또 한 번 카카오톡을 살펴보면

바텀 메뉴가 아닌 다른 메뉴를 클릭했을 때

새로운 화면이 뜨고 그 화면 종료 시 기존 화면이 유지되어 있는 것을 확인할 수 있다.

 

이 화면은 프래그먼트, 액티비티 두 가지 방법으로 구현할 수 있다.

 

2 - 1. 프래그먼트로 구현하는 방법

OneFragment

override fun onCreate(savedInstanceState: Bundle?) {
        Log.d(TAG, "onCreate: One")
        super.onCreate(savedInstanceState)

        // 처음 생성시에만 실행할 메소드
        mainViewModel.getPost(Random().nextInt(10) + 1)
    }

우선적으로 onCreate에서 데이터를 불러오는 메서드를 호출해준다.

 

// 아이템 클릭 시 DetailFragment 로 전환
    override fun onItemClicked() {
        requireActivity().supportFragmentManager.beginTransaction()
            .setCustomAnimations(R.anim.vertical_enter,
                R.anim.none,
                R.anim.none,
                R.anim.vertical_exit)
            .replace(R.id.fragmentContainerView,DetailFragment())
            .addToBackStack(null)
            .commit()
    }

아이템 클릭 시 새로운 화면(DetailFragment)으로 전환하는 코드를 작성한다.

전체 코드는 글의 맨 밑 깃허브 링크에서 확인할 수 있다.

 

여기서 replace와 addToBackstack을 사용하면 Jetpack Navigation의 action과 유사한 동작을 수행한다.

addToBackstack는 뒤로 가기 버튼을 클릭했을 때 이전 화면이 뜨도록 스택을 쌓기 위해 사용한다.

 

또한 세부화면에서는 바텀네비게이션이 보이지 않아야 한다.

 

MainActivity

메인 액티비티에서 바텀 내비게이션을 숨기고 보여주는 메서드를 하나 작성해주고

 

DetailFragment

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    MainActivity.hideNavi(true)
}

override fun onDestroyView() {
    super.onDestroyView()
    MainActivity.hideNavi(false)
}

디테일 화면에서는 바텀 네비를 숨기고, 보여주는 코드를 작성해준다.

 

화면이 잘 출력되고, 아까 onCreate에서 데이터를 불러왔기 때문에

기존의 상태도 유지되는 것을 확인할 수 있으나

 

로그에서 생명주기를 잘 보면 DetailFragment로 이동했을 때 onDestroyView 가 호출되고,

뒤로 가기 버튼을 클릭하여 돌아왔을 때 onCreateView, onViewCreated 가 생성되어

뷰가 다시 그려지는 것을 확인할 수 있다.

 

왜냐하면 addToBackstack 은 view의 스택을 저장하여 관리한다.

그래서 destroy는 호출되지 않고, destroy 되지 않았으므로 create 또한 다시 호출되지 않는다.

백스택이 호출되었을 때 저장되어 있던 view가 다시 그려지게 된다.

 

❗ 따라서 위 방법은 결국 뷰를 다시 그리게 되므로 좋지 않다.

 

// 아이템 클릭 시 DetailFragment 로 전환
    override fun onItemClicked() {
        requireActivity().supportFragmentManager.beginTransaction()
            .setCustomAnimations(R.anim.vertical_enter,
                R.anim.none,
                R.anim.none,
                R.anim.vertical_exit)
            .add(R.id.fragmentContainerView,DetailFragment())
            .addToBackStack(null)
            .commit()
    }

이를 해결하기 위해서는

기존에 작성되어 있던 replace가 아닌 add를 사용한다.

add는 기존의 프래그먼트 화면 자체에서 변경되는 것이 아닌, 위에 새로운 화면을 쌓는다.

 

여기서 주의해야 할 점이 2가지 있는데,

Fragment는 기본적으로 background 컬러가 투명색이다.

그리고 add를 사용하면 기존의 프래그먼트와 겹쳐지므로 이전 프래그먼트의 아이템이 클릭되는 일이 발생한다.

 

이를 방지하기 위해서

<?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"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".view.DetailFragment"
        android:clickable="true"
        android:background="@color/white">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="DetailFragment 입니다."
            android:textSize="28sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

배경색을 임의로 지정해주고 (white)

전체 레이아웃의 clickable 속성을 true로 바꿔주어 뒤 화면이 클릭되지 않도록 한다.

 

 

로그를 보면 이제 view 또한 최초로 1번만 그리게 되는 것을 확인할 수 있다.

 

2 - 2. 액티비티로 구현하는 방법

액티비티로 구현하면 매우 쉽게 구현할 수 있다.

// 아이템 클릭 시 DetailActivity 로 전환
    override fun onItemClicked() {
        startActivity(Intent(requireContext(),DetailActivity::class.java))
    }

단지 아이템 클릭 시 startActivity를 호출해주기만 하면 된다.

 

다른 액티비티 이므로, 생명주기 자체에 영향을 주지 않고

바텀네비게이션 또한 숨길 필요 없다. 그래서 더 자연스러운 동작을 보여준다.

 

이 방법은 간단하고 직관적이나 성능면에서 프래그먼트 보다 떨어진다.

하지만 현재 안드로이드 폰의 전체적인 성능이 상향평준화되어 큰 차이가 벌어지진 않으므로

UX를 고려하여 더 효율적인 방법이라고 판단될 경우 위 방법을 사용할 수 있다.

 

 

 

3️⃣ 메뉴 두 번 클릭 시 화면 스크롤

뷰모델을 활용하여 액티비티와 프래그먼트 간 통신하여 구현하였다.

전체 코드는 맨 밑의 깃허브 링크에서 확인할 수 있다.

 

OneFragment

mainViewModel.oneState.observe(viewLifecycleOwner){
            binding.recyclerView.smoothScrollToPosition(0)
        }

두 번 클릭됐는지 관찰하여

리사이클러뷰를 smoothScrollToPosition 를 통해 맨 위로 스크롤해준다.

 

 

MainActivity

각각의 화면이 현재 선택되어 있는지 확인하기 위한 boolean 변수를 추가해주고

 

메뉴를 클릭할 때마다 선택된 메뉴 이외의 상태를 false로 변경한다.

그 후 연속으로 클릭되었다면 아까 OneFragment에서 관찰하고 있던 상태를 바꿔주는 메서드를 호출한다.

 

같은 화면에서 다시 클릭 시 맨 위로 스크롤되는 것을 확인할 수 있다.


 

공부하며 작성한 내용이라 틀린 부분이 있을 수 있습니다.

틀린 부분이 있다면 말씀해주세요!

 

 

전체 코드

https://github.com/HanYeop/AndroidStudio-Practice2/tree/master/NavigationEx2

 

GitHub - HanYeop/AndroidStudio-Practice2: (2021.05.20~) 안드로이드 학습 내용 저장소

(2021.05.20~) 안드로이드 학습 내용 저장소. Contribute to HanYeop/AndroidStudio-Practice2 development by creating an account on GitHub.

github.com

 

 

 

 

반응형

공유하기

facebook twitter kakaoTalk kakaostory naver band
loading