본문 바로가기

Android

kotlin 이슈 8 BottomNavigationView + ViewPager2 이용하여 슬라이드 메뉴 만들기

반응형

로또위시에서 위시리스트와 게임리스트 메뉴 사이를 이동할 때, 하단 메뉴나 좌우 슬라이드를 이용하고 싶어 잠깐 찾아 봤습니다. 안드로이드 에는 하단 메뉴를 지원하는 Bottom Navigation View가 있습니다. New Project에서 Bottom Navigation View를 선택한 후, 변형하여 사용해볼까요?

처음 프로젝트를 생성하면 아래와 같은 구조로 이루어져 있습니다. empty project와 비교해서 ui, menu, navigation 폴더가 추가되었고, ui에 해당하는 layout이 추가되어 있습니다. ui 폴더 아래 각각의 메뉴 폴더 안에는 Fragment와 ViewModel 파일이 있습니다.

// HomeFragment.kt
// ...
class HomeFragment : Fragment() {

    private lateinit var homeViewModel: HomeViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        homeViewModel =
            ViewModelProviders.of(this).get(HomeViewModel::class.java)
        val root = inflater.inflate(R.layout.fragment_home, container, false)
        val textView: TextView = root.findViewById(R.id.text_home)
        homeViewModel.text.observe(this, Observer {
            textView.text = it
        })
        return root
    }
}

// HomeViewModel.kt
// ...
class HomeViewModel : ViewModel() {

    private val _text = MutableLiveData<String>().apply {
        value = "This is home Fragment"
    }
    val text: LiveData<String> = _text
}

// MainActivity.kt
// ...
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val navController = findNavController(R.id.nav_host_fragment)
        val appBarConfiguration = AppBarConfiguration(
            setOf(
                R.id.navigation_home, R.id.navigation_dashboard, R.id.navigation_notifications
            )
        )
        setupActionBarWithNavController(navController, appBarConfiguration)
        nav_view.setupWithNavController(navController)
    }
}

menu 폴더에는 아이콘과 텍스트를 설정하는 xml이, navigation 폴더에도 fragment 정보에 대한 xml이 있으므로, 메뉴를 수정하고 싶다면 각각의 xml을 수정해줘야 합니다.

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

    <item
        android:id="@+id/navigation_home"
        android:icon="@drawable/ic_home_black_24dp"
        android:title="@string/title_home" />

    <item
        android:id="@+id/navigation_dashboard"
        android:icon="@drawable/ic_dashboard_black_24dp"
        android:title="@string/title_dashboard" />

    <item
        android:id="@+id/navigation_notifications"
        android:icon="@drawable/ic_notifications_black_24dp"
        android:title="@string/title_notifications" />

</menu>

<!-- res/navigation/mobile_navigation.xml -->
<?xml version="1.0" encoding="utf-8"?>
<navigation 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/mobile_navigation"
    app:startDestination="@+id/navigation_home">

    <fragment
        android:id="@+id/navigation_home"
        android:name="com.rhsslug.bottomnav.ui.home.HomeFragment"
        android:label="@string/title_home"
        tools:layout="@layout/fragment_home" />

    <fragment
        android:id="@+id/navigation_dashboard"
        android:name="com.rhsslug.bottomnav.ui.dashboard.DashboardFragment"
        android:label="@string/title_dashboard"
        tools:layout="@layout/fragment_dashboard" />

    <fragment
        android:id="@+id/navigation_notifications"
        android:name="com.rhsslug.bottomnav.ui.notifications.NotificationsFragment"
        android:label="@string/title_notifications"
        tools:layout="@layout/fragment_notifications" />
</navigation>

activity_main.xml을 살펴보면, Bottom Navigation View에서 Nav Host Fragment를 메뉴로 사용하는 것을 볼 수 있습니다.

<!-- activity_main.xml -->
<?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"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="?attr/actionBarSize">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="0dp"
        android:layout_marginEnd="0dp"
        android:background="?android:attr/windowBackground"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:menu="@menu/bottom_nav_menu" />

    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/nav_view"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/mobile_navigation" />

</androidx.constraintlayout.widget.ConstraintLayout>

Bottom Navigation View는 하단 메뉴의 아이콘을 눌러 메뉴를 이동할 수 있지만, 아쉽게도 슬라이드를 지원하지 않습니다.

화면을 좌우로 슬라이드하여 이동하고 싶다면 ViewPager2를 이용하여야 합니다. activity_main.xml에서 fragment를 삭제한 후 ViewPager2를 추가합니다. ViewPager2를 사용하기 위해서는 module의 build.gradle에 ViewPager2를 추가하셔야 합니다.

// build.gradle
// Module: app
// ...
dependencies {
    // ...
    implementation 'androidx.viewpager2:viewpager2:1.0.0'
    // ...
}

<!-- activity_main.xml -->
<?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"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="?attr/actionBarSize">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="0dp"
        android:layout_marginEnd="0dp"
        android:background="?android:attr/windowBackground"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:menu="@menu/bottom_nav_menu" />

<!--    <fragment-->
<!--        android:id="@+id/nav_host_fragment"-->
<!--        android:name="androidx.navigation.fragment.NavHostFragment"-->
<!--        android:layout_width="match_parent"-->
<!--        android:layout_height="match_parent"-->
<!--        app:defaultNavHost="true"-->
<!--        app:layout_constraintBottom_toTopOf="@id/nav_view"-->
<!--        app:layout_constraintLeft_toLeftOf="parent"-->
<!--        app:layout_constraintRight_toRightOf="parent"-->
<!--        app:layout_constraintTop_toTopOf="parent"-->
<!--        app:navGraph="@navigation/mobile_navigation" />-->

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/view_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

// MainActivity.kt
// ...
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        view_pager.adapter = PagerAdapter(supportFragmentManager, lifecycle)
        view_pager.registerOnPageChangeCallback( PageChangeCallback() )
        nav_view.setOnNavigationItemSelectedListener { navigationSelected(it) }
    }

    private inner class PagerAdapter(fm: FragmentManager, lc: Lifecycle): FragmentStateAdapter(fm, lc) {
        override fun getItemCount() = 3
        override fun createFragment(position: Int): Fragment {
            return when (position) {
                0 -> HomeFragment()
                1 -> DashboardFragment()
                2 -> NotificationsFragment()
                else -> error("no such position: $position")
            }
        }
    }

    private fun navigationSelected(item: MenuItem): Boolean {
        val checked = item.setChecked(true)
        when (checked.itemId) {
            R.id.navigation_home -> {
                view_pager.currentItem = 0
                return true
            }
            R.id.navigation_dashboard -> {
                view_pager.currentItem = 1
                return true
            }
            R.id.navigation_notifications -> {
                view_pager.currentItem = 2
                return true
            }
        }
        return false
    }

    private inner class PageChangeCallback: ViewPager2.OnPageChangeCallback() {
        override fun onPageSelected(position: Int) {
            super.onPageSelected(position)
            nav_view.selectedItemId = when (position) {
                0 -> R.id.navigation_home
                1 -> R.id.navigation_dashboard
                2 -> R.id.navigation_notifications
                else -> error("no such position: $position")
            }
        }
    }
}

navController 역할을 하는 nav_host_fragment를 지웠기에 Action Bar에서 타이틀이 변경되지 않습니다. 대신 어플리케이션 이름이 표시됩니다.

슬라이드 하는 중

이제 슬라이드와 터치 모두 인식하는 메뉴가 완성되었습니다. ViewPager2는 추상 클래스인 FragmentStateAdapter를 상속한 adapter를 필요로 합니다. PageAdapter 클래스가 이 역할을 하며, 필수로 구현해야 하는 getItemCountcreateFragment만을 구현했습니다.

getItemCount은 fragment의 수를 명시적으로 지정해주는 함수이며, 여기서 지정한 값 이상으로는 슬라이드가 되지 않습니다. 만일 값을 2로 놔둔다면, Dashboard에서 Notifications로 슬라이드가 불가능합니다.

Notifications로 넘어가지 않는 모습

createFragment는 when 구문을 사용하여, 각각의 position에 해당하는 fragment를 반환합니다. 값이 이상하면 에러를 발생시킵니다.

navigationSelected 함수는 선택된 BottomNavigationView의 MenuItem을 따라 ViewPager2가 표시할 fragment를 지정합니다. setOnNavigationItemSelectedListener의 반환형은 Boolean이어야 해서 정상 실행일 때 true, 오류일 때 false를 반환합니다.

ViewPager2를 통해 메뉴가 변경된 경우, BottomNavigationView의 아이콘 상태가 변경 되지 않습니다. PageChangeCallback은 이를 해결하기 위한 클래스입니다. 선택된 페이지의 position에 따라 아이콘 상태를 변경합니다.

마지막으로, 위와 같이 코드를 변경하면 더이상 res/navigation/mobile_navigation.xml 파일을 사용하지 않습니다.

반응형