Fragment에서 ViewPager2 + TabLayout 사용하기

DoDoBest

·

2024. 4. 24. 13:12

Fragment에서 ViewPager2를 사용하게 된 이유

공식 문서 가이드(https://developer.android.com/develop/ui/views/animations/screen-slide-2)에 따라 Activity에서 ViewPager2를 사용했습니다. ViewPager에 있는 Fragment에서 새로운 Fragment 화면을 보여줘야 했습니다.  FragmentContainerView가 없다보니, 아래와 같이 Fragment의 root layout의 Fragment를 바꿔주도록 구현했습니다.

 

// Fragment 최상위 Layout에 id를 설정
<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:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.contact.ContactListFragment">
val bundle = Bundle().apply {
    putParcelable(BUNDLE_KEY_FOR_CONTACT_INFO, contactInfo)
}

val fragmentDetail = ContactDetailFragment()
fragmentDetail.arguments = bundle

parentFragmentManager.beginTransaction()
    .replace(R.id.container, fragmentDetail)
    .setReorderingAllowed(true)
    .addToBackStack(null)
    .commit()

 

 

이 코드는 화면을 회전하는 경우에 문제가 발생합니다. ContactDetail 화면이 보이는 상태에서 화면을 회전하면, ContactDetail Fragment가 생성은 되지만 화면에 보이지 않는 문제가 발생합니다.

 

 

ViewPager2를 Fragment에서 사용하는 방법

Activity에 Fragment를 표시하기 위한 FragmentContainerView를 생성합니다. name attribute에 ViewPager2를 보여줄 Fragment를 지정합니다.

 

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="?colorPrimary"
    tools:context=".presentation.MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/fragment_container_view"
        android:name="com.nbc.two_of_us.presentation.viewpager.ViewPagerFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

ViewPager2를 보여줄 Fragment xml에 TabLayout와 ViewPager2를 설정합니다.

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context=".presentation.viewpager.ViewPagerFragment">


    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/vp"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/tab_layout"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.tabs.TabLayout
        android:id="@+id/tab_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/vp"
        app:tabIndicatorFullWidth="true" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

ViewPager2 Fragment 코드에 아래와 같이 코드를 작성합니다. 주요 내용은 다음과 같습니다.

  1. TabLayout와 ViewPager2 연결하기
  2. 뒤로가기를 눌렀을 때, ViewPager2가 시작지점이 아니면 ViewPager2에 보여주고 있는 페이지 이동시키기
enum class TabType(val position: Int, @StringRes val tabName: Int){
    CONTACT(0, R.string.contact), MY_PAGE(1, R.string.my_page);

    companion object {
        fun from(position: Int) = entries.first { it.position == position }
    }
}

 

class ViewPagerFragment : Fragment() {

    private var _binding: FragmentViewPagerBinding? = null
    private val binding: FragmentViewPagerBinding
        get() = _binding!!

    private lateinit var adapter: ContactViewPagerAdapter

//    override fun onCreate(savedInstanceState: Bundle?) {
//        super.onCreate(savedInstanceState)
//
//        adapter = ContactViewPagerAdapter(this@ViewPagerFragment)
//    }
    
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentViewPagerBinding.inflate(inflater, container, false)
        return binding.root
    }

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

        setBackPressed()
        setViewPager()
        setTabLayout()
    }

    private fun setBackPressed() = with(binding) {
        requireActivity().onBackPressedDispatcher.addCallback(this@ViewPagerFragment) {
            if (isEnabled) {
                if (vp.currentItem == 0) {
                    isEnabled = false
                    requireActivity().onBackPressedDispatcher.onBackPressed()
                } else {
                    vp.currentItem = vp.currentItem - 1
                }
            }
        }
    }

    private fun setViewPager() = with(binding) {
    	adapter = ContactViewPagerAdapter(this@ViewPagerFragment)
        vp.adapter = adapter
    }

    private fun setTabLayout() = with(binding) {
        TabLayoutMediator(tabLayout, vp) { tab, position ->
            val tabType = TabType.from(position)
            tab.text = getString(tabType.tabName)
        }.attach()
    }

    override fun onDestroyView() {
        _binding = null

        super.onDestroyView()
    }
}

 

class ContactViewPagerAdapter(fragment: Fragment): FragmentStateAdapter(fragment) {
    override fun getItemCount(): Int = TabType.entries.size

    override fun createFragment(position: Int): Fragment {
        return when (position) {
            TabType.CONTACT.position -> ContactListFragment()
            TabType.MY_PAGE.position -> ContactDetailFragment()
            else -> throw IllegalArgumentException("Not implemented yet")
        }
    }
}

 

 

작성한 코드에 대한 설명을 드리겠습니다.

 

1. TabLayout에 추가할 아이템을 xml 상에서 TabItem으로 추가할 수 있으나, ViewPager2를 TabLayout과 연결하기 위해서 TabLayoutMediator를 반드시 사용해야 하고, 여기서 설정할 수도 있기 때문에 xml을 통해 설정하지 않았습니다.

 

2. ViewPager2를 위한 FrgamentStateAdapter를onCreate 시점에 생성하여, Fragment는 파괴되지 않고 Fragment View만 파괴되는 경우(Fragment가 BackStack으로 이동하는 경우 등)에 불필요한 중복 생성을 막습니다.
Fragment View만 파괴되더라도 Adapter를 매번 재생성 해줘야 합니다. 관련 버그가 있는데, 아직 해결되지 않았습니다. 이 내용은 별도로 정리해서 작성하겠습니다.

 

3. adapter property 변수를 생성하는 시점에 초기화를 같이 하지 않은 이유는 Fragment가 Activity에 attach 되지 않았기 때문입니다. FragmentStateAdapter의 내부 코드를 따라가보면 Fragment의 childFragmentManager에 접근하고 있는데, Fragment가 Activity에 attach 되지 않은 경우에 FragmentManager가 없어 runtime Exception이 발생합니다.

 

ViewPager2에 있는 Fragment에서 다른 Fragment 호출하는 방법

이 코드를 작성하게 된 이유인 ViewPager에 있는 Fragment에서 새로운 Fragment 화면을 보여줘야 하는 코드를 작성할 준비는 끝났습니다. ContactDetailFragment를 생성하기 위해 다음과 같이 코드를 작성합니다.

 

주의할 점은 parentFragmentManager가 아닌 Activity의 FragmentManager를 이용해야 한다는 점입니다. ViewPagerFragment 내부에 있는 Fragment의 parentFragmentManager는 ViewPagerFragment가 됩니다. ViewPagerFragment에는 FragmentContainerView가 없기 때문에 런타임 exception이 발생합니다.

 

따라서 FragmentContainerView가 있는 Activity의 FragmentManager를 이용할 수 있도록 requireActivity().supportFragmentManager를 호출합니다.

 

val bundle = Bundle().apply {
    putParcelable(BUNDLE_KEY_FOR_CONTACT_INFO, contactInfo)
}

val fragmentDetail = ContactDetailFragment()
fragmentDetail.arguments = bundle

requireActivity().supportFragmentManager.beginTransaction()
    .replace(R.id.fragment_container_view, fragmentDetail)
    .setReorderingAllowed(true)
    .addToBackStack(null)
    .commit()

 

이제 앱을 실행해보면 Fragment 복원 처리가 정상적으로 되는 것을 볼 수 있습니다.

 

 

 

 

참고 자료

https://github.com/android/sunflower/blob/views/app/src/main/java/com/google/samples/apps/sunflower/HomeViewPagerFragment.kt