📋 Screens, Lists & Navigation · Lesson 28
TabLayout & ViewPager2
Tabs that swipe
TabLayout draws the tab titles. ViewPager2 holds the pages and lets the user swipe. TabLayoutMediator keeps both in sync.
Step 1: Layout
XML – res/layout/fragment_notes.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>Step 2: FragmentStateAdapter
Kotlin
class CoursePagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount() = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> SyllabusFragment()
1 -> ReviewsFragment()
else -> FaqFragment()
}
}Step 3: TabLayoutMediator
Kotlin
val titles = listOf("Syllabus", "Reviews", "FAQ")
binding.viewPager.adapter = CoursePagerAdapter(this)
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
tab.text = titles[position]
}.attach()
// listen for page changes
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
Log.d("Tabs", "Now on ${titles[position]}")
}
})Real code: Student Diary tabs
One NoteListFragment class is reused for all three tabs with a different filter argument:
Kotlin – ui/notes/NotesFragment.kt
package com.codingeasily.studentdiary.ui.notes
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.codingeasily.studentdiary.R
import com.codingeasily.studentdiary.databinding.FragmentNotesBinding
import com.codingeasily.studentdiary.ui.NoteFilter
import com.codingeasily.studentdiary.ui.NoteViewModel
import com.google.android.material.tabs.TabLayoutMediator
/** Home screen: 3 tabs (All / Pending / Done) in a ViewPager2 + a search box in the toolbar. */
class NotesFragment : Fragment() {
private var _binding: FragmentNotesBinding? = null
private val binding get() = _binding!!
private val viewModel: NoteViewModel by activityViewModels()
private val tabs = listOf(
NoteFilter.ALL to R.string.tab_all,
NoteFilter.PENDING to R.string.tab_pending,
NoteFilter.DONE to R.string.tab_done
)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentNotesBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewPager.adapter = object : FragmentStateAdapter(this) {
override fun getItemCount() = tabs.size
override fun createFragment(position: Int) = NoteListFragment.newInstance(tabs[position].first)
}
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
tab.setText(tabs[position].second)
}.attach()
// Toolbar menu that belongs to this screen only
requireActivity().addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.menu_notes, menu)
val searchView = menu.findItem(R.id.action_search).actionView as SearchView
searchView.queryHint = getString(R.string.search_hint)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?) = true
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.setSearchQuery(newText.orEmpty())
return true
}
})
}
override fun onMenuItemSelected(menuItem: MenuItem) = false
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
override fun onDestroyView() {
super.onDestroyView()
viewModel.setSearchQuery("")
_binding = null
}
}Icons, badges and modes
Kotlin
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
tab.text = titles[position]
tab.setIcon(icons[position]) // R.drawable.ic_…
}.attach()
binding.tabLayout.getTabAt(1)?.orCreateBadge?.number = 12 // "Reviews 12"XML – many tabs that scroll
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
app:tabGravity="start" />Onboarding slider with views
ViewPager2 also accepts a normal RecyclerView adapter — perfect for welcome slides.
Kotlin
data class Slide(val emoji: String, val title: String, val text: String)
class SlideAdapter(private val slides: List<Slide>) : RecyclerView.Adapter<SlideAdapter.VH>() {
class VH(val b: ItemSlideBinding) : RecyclerView.ViewHolder(b.root)
override fun onCreateViewHolder(p: ViewGroup, t: Int) =
VH(ItemSlideBinding.inflate(LayoutInflater.from(p.context), p, false)) // item root: match_parent
override fun onBindViewHolder(h: VH, i: Int) {
h.b.tvEmoji.text = slides[i].emoji
h.b.tvTitle.text = slides[i].title
h.b.tvText.text = slides[i].text
}
override fun getItemCount() = slides.size
}
// Activity
val slides = listOf(
Slide("📝", "Write notes", "Homework, revision and exam notes in one place"),
Slide("⏰", "Never forget", "Get a reminder before every deadline"),
Slide("📊", "Track progress", "See how much you have completed")
)
binding.pager.adapter = SlideAdapter(slides)
TabLayoutMediator(binding.dots, binding.pager) { _, _ -> }.attach() // TabLayout as dots
binding.btnNext.setOnClickListener {
if (binding.pager.currentItem < slides.lastIndex) binding.pager.currentItem++ else finishOnboarding()
}Common mistakes
- Forgetting
.attach()on the mediator → tabs show no titles. - ViewPager2 page layouts with
wrap_content→ crash "Pages must fill the whole ViewPager2"; usematch_parent. - Keeping references to page fragments in a list → they are recreated; communicate through a shared ViewModel.
Summary
- TabLayout + ViewPager2 + FragmentStateAdapter + TabLayoutMediator.
- Reuse one fragment class with arguments for similar tabs.
- ViewPager2 with a RecyclerView adapter for sliders and onboarding.
✅ Practice
Build a Course screen with three tabs (Syllabus, Reviews, FAQ) and a badge showing the review count. Add a 3-slide onboarding screen that opens only on first launch (save a flag in SharedPreferences).
Frequently Asked Questions
ViewPager or ViewPager2?
Use ViewPager2. It is built on RecyclerView, supports right-to-left, vertical paging and DiffUtil, and is the maintained version.
Inside a Fragment, should I pass this or requireActivity() to FragmentStateAdapter?
Pass this (the parent fragment). The pages then become child fragments and are cleaned up with the parent.
How do I open a specific tab from code?
Call viewPager.setCurrentItem(index, true). The TabLayout follows automatically because of the mediator.
