[Android] BottomNavigationView에서 Fragment 전환
Android

[Android] BottomNavigationView에서 Fragment 전환

728x90

 

 

이번 포스팅에서는 BottomNavigationView를 통해 Fragment를 전환할 때 생길 수 있는 문제점과 해결 방안에 대해 알아보겠습니다.

 

 

 

menu_bottom_navigation.xml

우선 간단하게 menu.xml을 통해 BottomNavigationView의 item을 지정해줍니다.

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/item_page_1"
        android:enabled="true"
        android:icon="@drawable/ic_favorite"
        android:title="@string/main_tab_title_page_1"/>

    <item
        android:id="@+id/item_page_2"
        android:enabled="true"
        android:icon="@drawable/ic_home"
        android:title="@string/main_tab_title_page_2"/>

    <item
        android:id="@+id/item_page_3"
        android:enabled="true"
        android:icon="@drawable/ic_settings"
        android:title="@string/main_tab_title_page_3"/>
</menu>

 

 

 

activity_sample.xml

다음은 Fragment를 담아줄 FrameLayout과 BottomNavigationView를 배치해줍니다.

<?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.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <FrameLayout
            android:id="@+id/container"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toTopOf="@id/bnv_main"/>

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bnv_main"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:menu="@menu/menu_bottom_navigation"
            app:layout_constraintBottom_toBottomOf="parent"/>

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

 

 

 

SampleActivity.kt

class SampleOneActivity : AppCompatActivity() {

    private val binding by lazy { ActivitySampleOneBinding.inflate(layoutInflater) }

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

        binding.bnvMain.setOnItemSelectedListener {
            changeFragment(it.itemId)
            true
        }

        // init fragment
        changeFragment(R.id.item_page_1)
    }

    private fun changeFragment(menuItemId: Int) {
        val targetFragment = getFragment(menuItemId)

        supportFragmentManager.beginTransaction()
            .replace(R.id.container, targetFragment)
            .commitAllowingStateLoss()
    }

    private fun getFragment(menuItemId: Int): Fragment {
        val title = when (menuItemId) {
            R.id.item_page_1 -> "page1"
            R.id.item_page_2 -> "page2"
            R.id.item_page_3 -> "page3"
            else -> throw IllegalArgumentException("not found menu item id")
        }
        return SampleFragment.newInstance(title)
    }
}

 BottomNavigationView의 item을 눌렀을 때 발생하는 이벤트는 setOnItemSelectedListener를 통해 처리 가능합니다. itemId를 통해 탭을 구분하며, 전환할 프래그먼트를 만든 뒤 FragmentManager의 replace() 함수를 통해 전환해주면 됩니다.

 

 

문제점

위의 코드의 changeFragment() 함수에서는 프래그먼트를 replace 하기 때문에 프래그먼트를 전환할 때마다 새로운 프래그먼트를 생성하게 됩니다. 만약 이와 같은 동작을 의도한 게 아니라면 한번 생성된 프래그먼트는 재사용하는 것이 효율적입니다.

FragmentManager의 add() 함수를 사용하면 여러 개의 프래그먼트를 추가해서 관리할 수 있습니다.

 

 

PageType.kt

BottomNavigationView에서 사용할 프래그먼트를 보다 쉽게 관리해주기 위해서 enum class로 각 프래그먼트의 타입을 정의해줍니다.

enum class PageType(val title: String, val tag: String) {
    PAGE1("page1", "tag_page_1"),
    PAGE2("page2", "tag_page_2"),
    PAGE3("page3", "tag_page_3");
}

 

 

 

SampleActivity.kt

class SampleTwoActivity : AppCompatActivity() {

    private val binding by lazy { ActivitySampleTwoBinding.inflate(layoutInflater) }

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

        binding.bnvMain.setOnItemSelectedListener {
            val pageType = getPageType(it.itemId)
            changeFragment(pageType)
            true
        }

        changeFragment(PageType.PAGE1)
    }

    private fun changeFragment(pageType: PageType) {
        val transaction = supportFragmentManager.beginTransaction()

        var targetFragment = supportFragmentManager.findFragmentByTag(pageType.tag)

        if (targetFragment == null) {
            targetFragment = getFragment(pageType)
            transaction.add(R.id.container, targetFragment, pageType.tag)
        }

        transaction.show(targetFragment)

        PageType.values()
            .filterNot { it == pageType }
            .forEach { type ->
                supportFragmentManager.findFragmentByTag(type.tag)?.let {
                    transaction.hide(it)
                }
            }

        transaction.commitAllowingStateLoss()
    }

    private fun getPageType(menuItemId: Int): PageType {
        return when (menuItemId) {
            R.id.item_page_1 -> PageType.PAGE1
            R.id.item_page_2 -> PageType.PAGE2
            R.id.item_page_3 -> PageType.PAGE3
            else -> throw IllegalArgumentException("not found menu item id")
        }
    }

    private fun getFragment(pageType: PageType): Fragment {
        return SampleFragment.newInstance(pageType.title)
    }
}

첫 번째 방식의 코드와 달라진 부분이 있다면, replace() 함수 대신 add()와 show(), hide()를 사용했다는 것입니다.

전환할 프래그먼트가 FragmentManager에서 관리하고 있지 않다면 add() 함수를 통해 새로 추가해준 뒤, 전환할 프래그먼트만 show() 해주고, 나머지 프래그먼트들은 hide() 시켜줍니다.

이런 방식이면 프래그먼트를 여러 번 전환하더라도 최초에 만들어진 프래그먼트가 보이게 됩니다.

하지만 이 방식도 문제점이 있습니다. 앱이 회전하거나, 다크 모드로 변경하는 등의 액티비티가 새로 그려지는 이벤트가 발생하면 아래와 같은 화면처럼 BottomNavigation과 프래그먼트가 맞지 않는 이슈가 발생할 수 있습니다.

 

 

이는 Activity의 onCreate에서 changeFragment() 함수를 호출해 1번 페이지로 변경되게끔 고정해놨기 때문입니다. 따라서 기존에 보고 있던 프래그먼트를 저장한 뒤, 해당 프래그먼트로 변경해줘야 합니다. 이는 ViewModel을 통해 해결할 수 있습니다.

 

 

SampleViewModel.kt

class SampleThreeViewModel : ViewModel() {

    private val _currentPageType = MutableLiveData(PageType.PAGE1)
    val currentPageType: LiveData<PageType> = _currentPageType

    fun setCurrentPage(menuItemId: Int): Boolean {
        val pageType = getPageType(menuItemId)
        changeCurrentPage(pageType)

        return true
    }

    private fun getPageType(menuItemId: Int): PageType {
        return when (menuItemId) {
            R.id.item_page_1 -> PageType.PAGE1
            R.id.item_page_2 -> PageType.PAGE2
            R.id.item_page_3 -> PageType.PAGE3
            else -> throw IllegalArgumentException("not found menu item id")
        }
    }

    private fun changeCurrentPage(pageType: PageType) {
        if (currentPageType.value == pageType) return

        _currentPageType.value = pageType
    }
}
<?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">

    <data>

        <variable
            name="vm"
            type="com.leveloper.sample.sample3.SampleThreeViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <FrameLayout
            android:id="@+id/container"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toTopOf="@id/bnv_main"/>

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bnv_main"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:menu="@menu/menu_bottom_navigation"
            app:onItemSelectedListener="@{(menu) -> vm.setCurrentPage(menu.itemId)}"
            app:layout_constraintBottom_toBottomOf="parent"/>

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

ViewModel에서 현재 보고 있는 type을 LiveData로 저장해줍니다. 프래그먼트 전환 이벤트는 데이터 바인딩으로 BottomNavigationView에 직접 연결해줍니다.

이벤트가 발생하면 currentPageType의 값을 변경준 뒤, Activity에서 해당 값을 옵저빙 하여 프래그먼트를 전환시켜줍니다.

 

 

 

SampleActivity.kt

class SampleThreeActivity : AppCompatActivity() {

    private val viewModel: SampleThreeViewModel by viewModels()

    private val binding by lazy { ActivitySampleThreeBinding.inflate(layoutInflater) }

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

        binding.vm = viewModel

        viewModel.currentPageType.observe(this) {
            changeFragment(it)
        }
    }

    private fun changeFragment(pageType: PageType) {
        val transaction = supportFragmentManager.beginTransaction()

        var targetFragment = supportFragmentManager.findFragmentByTag(pageType.tag)

        if (targetFragment == null) {
            targetFragment = getFragment(pageType)
            transaction.add(R.id.container, targetFragment, pageType.tag)
        }

        transaction.show(targetFragment)

        PageType.values()
            .filterNot { it == pageType }
            .forEach { type ->
                supportFragmentManager.findFragmentByTag(type.tag)?.let {
                    transaction.hide(it)
                }
            }

        transaction.commitAllowingStateLoss()
    }

    private fun getFragment(pageType: PageType): Fragment {
        return SampleFragment.newInstance(pageType.title)
    }
}

 Activity에서 ViewModel의 LiveData를 옵저빙 하여 프래그먼트를 전환해줍니다. 이와 같이 ViewModel의 LiveData를 사용하면 액티비티가 새로 그려지는 이벤트가 발생해도 정상적으로 동작하게 됩니다.

 

 

 

예제 소스

https://github.com/tkdgusl94/blog-source/tree/master/Change-fragment-in-BottomNavigation

 

GitHub - tkdgusl94/blog-source: https://leveloper.tistory.com/ 에서 제공하는 예제 source

https://leveloper.tistory.com/ 에서 제공하는 예제 source. Contribute to tkdgusl94/blog-source development by creating an account on GitHub.

github.com

 

728x90