[안드로이드] BottomNavigatioView와 Fragment

hee09·2022년 3월 4일
0
post-thumbnail

개요

Fragment, FragmentManager, FragmetTransaction에 대한 기본적인 내용은 Fragment와 FragmentManager, FragmentTransaction 글에 작성하였습니다. 이번 글에서는 BottomNavigationView을 사용하여 Fragment를 전환하는 방법에 대해 알아보겠습니다.


replace 사용하여 프래그먼트 생성

bottom_navigation_menu.xml

BottomNavigaionView에서 사용할 menu는 아래와 같이 생성합니다.

<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menu_one"
        android:enabled="true"
        android:icon="@android:drawable/ic_menu_add"
        android:title="Fragment1" />

    <item
        android:id="@+id/menu_two"
        android:enabled="true"
        android:icon="@android:drawable/ic_menu_call"
        android:title="Fragment2" />

    <item
        android:id="@+id/menu_three"
        android:enabled="true"
        android:icon="@android:drawable/ic_menu_camera"
        android:title="Fragment3" />
</menu>

activity_main.xml

activity_main xml 파일은 아래와 같이 생성합니다. Fragment의 container는 FragmentContainerView로 지정하고, BottomNavigaionView의 menu 속성으로 위에서 생성한 xml 파일을 지정합니다.

<androidx.constraintlayout.widget.ConstraintLayout
    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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/container_main"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/bnv_main"
        app:layout_constraintTop_toTopOf="parent" />

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

</androidx.constraintlayout.widget.ConstraintLayout>

SampleFragment

class SampleFragment: Fragment() {
    private var _binding: FragmentSampleBinding? = null
    private val binding: FragmentSampleBinding
        get() = _binding!!
    private var count = 0

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = DataBindingUtil.inflate(inflater, R.layout.fragment_sample, container, false)

        binding.apply {
            tvName.text = requireArguments().getString("fragment_title")
            tvCount.text = count.toString()
            btnAddCount.setOnClickListener {
                count++
                tvCount.text = count.toString()
            }
        }

        return binding.root
    }

    companion object {
        fun newInstance(title: String) = SampleFragment().apply {
            arguments = Bundle().apply {
                putString("fragment_title", title)
            }
        }
    }
}

Fragment의 코드입니다. DataBinding을 통해 레이아웃을 초기화하고 newInstance 메소드를 통해 Fragment 객체를 생성하도록 하였습니다. 그리고 버튼과 텍스트뷰를 배치하여 버튼을 누르면 count가 증가하고, 이 값이 텍스트뷰에 나타나도록 설정하였습니다.


MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

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

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

        binding.apply {
            bnvMain.setOnItemSelectedListener { menuItem ->
                changeFragment(menuItem.itemId)
                true
            }
        }

        // init
        changeFragment(R.id.menu_one)

        setContentView(binding.root)
    }

    // MenuItemid에 따른 Fragment 변경
    private fun changeFragment(menuItemId: Int) {
        val targetFragment = getFragment(menuItemId)

        // FragmentKTX의 commit 메소드 사용
        supportFragmentManager.commit {
            // targetFragment로 변경
            replace(R.id.container_main, targetFragment)
        }
    }

    // MenuItemId에 따른 Fragment 생성
    private fun getFragment(menuItemId: Int): Fragment {
        val title = when(menuItemId) {
            R.id.menu_one -> "Fragment1"
            R.id.menu_two -> "Fragment2"
            R.id.menu_three -> "Fragment3"
            else -> throw IllegalArgumentException("Not found menu item")
        }

        return SampleFragment.newInstance(title)
    }
}

MainActivity의 코드입니다. BottomNavigationView의 메뉴를 클릭하면 menuItem의 itemId를 changeFragment 함수에 인자로 넘겨줍니다. changeFragment의 함수는 getFragment 함수를 통해 프래그먼트 객체를 생성하고, replace를 통해서 Container에 설정된 프래그먼트를 getFragment를 통해 생성된 프래그먼트로 변경합니다.


문제점

화면을 보면 Fragment1에서 Count를 증가시키고 Fragment2로 갔다가 다시 돌아오면 Count가 초기화되는 것을 볼 수 있습니다. 그 이유는 위에 코드에서 프래그먼트를 replace 하기 때문에 프래그먼트를 전환할 때마다 새로운 프래그먼트를 생성하게 됩니다. 만약 이와 같은 동작을 의도해서 만든게 아니라면 한번 생성된 프래그먼트는 재사용하는 것이 효율적입니다.


add, show, hide 이용하여 프래그먼트 재사용

우선 프래그먼트를 쉽게 관리하기 위해 enum class로 각 프래그먼트의 타입을 정의합니다.

FragmentType.kt

enum class FragmentType(val fragmentTitle: String, val fragmentTag: String) {
    FRAGMENT1("fragment1", "fragment1_tag"),
    FRAGMENT2("fragment2", "fragment2_tag"),
    FRAGMENT3("fragment3", "fragment3_tag");
}

MainViewModel.kt

class MainViewModel: ViewModel() {
    private val _currentFragmentType = MutableLiveData(FragmentType.FRAGMENT1)

    val currentFragmentType: LiveData<FragmentType>
        get() = _currentFragmentType
    
    // FragmentType에 따라 currentFragmentType 변경
    fun setCurrentFragment(item: MenuItem): Boolean {
        val menuItemId = item.itemId
        val pageType = getPageType(menuItemId)
        changeCurrentFragmentType(pageType)

        return true
    }

    // menuItemId에 따른 FragmentType 반환
    private fun getPageType(menuItemId: Int): FragmentType {
        return when(menuItemId) {
            R.id.menu_one -> FragmentType.FRAGMENT1
            R.id.menu_two -> FragmentType.FRAGMENT2
            R.id.menu_three -> FragmentType.FRAGMENT3
            else -> throw IllegalArgumentException("Not found menu item")
        }
    }

    // 현재 FragmentType과 비교하여 같으면 return, 다르면 변경
    private fun changeCurrentFragmentType(fragmentType: FragmentType) {
        if(currentFragmentType.value == fragmentType) return

        _currentFragmentType.value = fragmentType
    }
}

ViewModel과 LiveData를 사용해서 FragmentType(enum class)을 관리합니다. setCurrentFragment는 아래에서 살펴보겠지만 XML에서 BottomNavigationView에 연결된 BindingAdapter에 의해 호출되는 메소드입니다. BindingAdapter에 대해서 궁금하시다면 DataBinding - 2를 클릭해주세요.

setCurrentFragment를 통해 menuItem를 획득하면, getPageType은 menuItem의 ItemId에 따라서 FragmentType을 분기하고, changeCurrentFragmentType은 이 FragmentType과 LiveData에 저장된 FragmentType을 비교하여 LiveData의 값을 변경하는 함수입니다.


BindingAdapter

object ViewBinding {
    @JvmStatic
    @BindingAdapter("onNavigationItemSelected")
    fun bindOnNavigationItemSelectedListener(
        view: BottomNavigationView, listener: NavigationBarView.OnItemSelectedListener
    ) {
        view.setOnItemSelectedListener(listener)
    }
}

XML에 연결되는 BindingAdapter 함수입니다. 인자로 BindingAdapter가 연결되는 BottomNavigationView와 NavigationBarView.OnItemSelectedListener를 전달받습니다. 그리고 이 전달받은 BottomNavigationView에 setOnItemSelectedListener 메소드를 통해서 리스너를 등록하는 코드입니다.


MainActivity.xml

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

        <variable
            name="viewModel"
            type="kr.co.lee.bottomnavigationviewexample.MainViewModel" />
    </data>

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

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/container_main"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toTopOf="@id/bnv_main"
            app:layout_constraintTop_toTopOf="parent" />

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bnv_main"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:onNavigationItemSelected="@{viewModel::setCurrentFragment}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:menu="@menu/bottom_navigation_menu" />

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

BottomNavigationView에 onNavigationItemSelected 라는 속성으로 BindingAdapter를 사용하였고, 인자로는 viewModel에 선언되었던 setCurrentFragment 함수를 주었습니다. 위에서 보았지만 setCurrentFragment는 NavigationBarView.OnItemSelectedListener에 선언된 함수인 onNavigationItemSelected와 시그니처가 똑같습니다. 따라서 해당 함수가 BottomNavigationView의 리스너로 등록되고, BottomNavigationView의 menu가 선택되면 ViewModel의 함수를 호출하는 것입니다.


MainActivity

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private val mainViewModel by viewModels<MainViewModel>()

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

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

        binding.apply {
            viewModel = mainViewModel
            lifecycleOwner = this@MainActivity
        }

        // Observer 등록
        mainViewModel.currentFragmentType.observe(this) {
            // FragmentType이 변경되면 changeFragment에 FragmentType 인자로 넘김
            changeFragment(it)
        }

        setContentView(binding.root)
    }

    // 현재 Fragment는 show, 나머지 Fragment는 hide
    private fun changeFragment(fragmentType: FragmentType) {
        // 현재 Fragemnt
        var targetFragment = supportFragmentManager.findFragmentByTag(fragmentType.fragmentTag)

        // Fragment Ktx의 commit 함수
        supportFragmentManager.commit {
            // 현재 Fragment가 null이라면
            if (targetFragment == null) {
                // getFragment를 호출하여 Fragment 획득
                targetFragment = getFragment(fragmentType)
                // 현재 Fragment 추가
                add(R.id.container_main, targetFragment!!, fragmentType.fragmentTag)
            }

            // 현재 Fragment show
            show(targetFragment!!)

            // 나머지 Fragment hide
            FragmentType.values()
                .filterNot { it == fragmentType }
                .forEach { type ->
                    supportFragmentManager.findFragmentByTag(type.fragmentTag)?.let {
                        hide(it)
                    }
                }
        }
    }

    // FragmentType을 받아서 Fragment 생성하는 함수
    private fun getFragment(fragmentType: FragmentType): Fragment {
        return SampleFragment.newInstance(fragmentType.fragmentTitle)
    }
}

ViewModel에 저장된 FragmentType이 변경되면 changeFragment를 호출하여 현재의 FragmentType에 해당하는 Fragment는 show, 나머지 Fragment들은 hide 하게 됩니다.


본문의 예제 소스는 깃허브 링크에 있습니다.

참조
BottomNavigationView에서 Fragment 전환

틀린 부분은 댓글로 남겨주시면 수정하겠습니다..!!

profile
되새기기 위해 기록

0개의 댓글