Tab Layout in Material 3 Jetpack Compose (with Examples)

Jetpack Compose Material 3 Tab Layout

In this article, we’ll learn how to implement the tab layout (tab row) in Material 3 Jetpack Compose.

Prerequisites:

What is a Tab Layout?

Tab Layout is used to organize and present content in a structured and user-friendly manner. It consists of a series of tabs. Each tab typically represents a distinct category or function of the app. Users can swipe left or right to switch between the tabs.

tab row with pager together

Let’s see how to implement it in the Android Studio.

First, create an empty Compose project and open MainActivity. Create a Composable called MyUI() and call it from the onCreate(). We’ll write our code in it.

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.outlined.Email
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.ShoppingCart
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            YourProjectNameTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Column(
                        modifier = Modifier.fillMaxSize(),
                        horizontalAlignment = Alignment.CenterHorizontally   
                    ) {
                        MyUI()
                    }
                }
            }
        }
    }
}

@Composable
fun MyUI() {

}

Jetpack Compose provides TabRow() method. We use it to create the tab layout.

@Composable
fun TabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    containerColor: Color = TabRowDefaults.containerColor,
    contentColor: Color = TabRowDefaults.contentColor,
    indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions ->   
        if (selectedTabIndex < tabPositions.size) {
            TabRowDefaults.SecondaryIndicator(
                Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
            )
        }
    },
    divider: @Composable () -> Unit = @Composable {
        HorizontalDivider()
    },
    tabs: @Composable () -> Unit
) 

selectedTabIndex – The index of the currently selected tab.

modifier – The Modifier to be applied to this tab row.

containerColor – The background color. Use Color.Transparent to have no color.

contentColor – The preferred color for content inside this tab row.

indicator – The indicator that represents which tab is currently selected.

divider – The divider displayed at the bottom of the tab row. This provides a layer of separation between the tab row and the content displayed underneath.

tabs – The tabs inside this tab row. We use the Tab() method to add them. Each tab inside this lambda will be measured and placed evenly across the row, each taking up equal space.

The Tab() method looks like this:

@Composable
fun Tab(
    selected: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    text: @Composable (() -> Unit)? = null,
    icon: @Composable (() -> Unit)? = null,
    selectedContentColor: Color = LocalContentColor.current,
    unselectedContentColor: Color = selectedContentColor,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }    
)

selected – Whether this tab is selected or not.

onClick – It is called when this tab is clicked.

modifier – The modifier to be applied to this tab.

enabled – If the tab is enabled or not. When false, this component will not respond to user input, and it will appear visually disabled and disabled to accessibility services.

text – Name of this tab. We add it using the Text() composable.

icon – The icon displayed in this tab.

selectedContentColor – The color for the content of this tab when selected, and the color of the ripple.

unselectedContentColor – The color for the content of this tab when not selected.

interactionSource – It is used to observe and customize interactions. For example, we can disable the ripple effect.

First, create a data class for storing the tab items:

data class TabItem(
    val title: String,
    val unselectedIcon: ImageVector,   
    val selectedIcon: ImageVector
)

Next, add the following code in the MyUI():

@Composable
fun MyUI() {
    val tabItems = listOf(
        TabItem(
            title = "Home",
            unselectedIcon = Icons.Outlined.Home,
            selectedIcon = Icons.Filled.Home
        ),
        TabItem(
            title = "Emails",
            unselectedIcon = Icons.Outlined.Email,
            selectedIcon = Icons.Filled.Email
        ),
        TabItem(
            title = "Favorites",
            unselectedIcon = Icons.Outlined.FavoriteBorder,
            selectedIcon = Icons.Filled.Favorite
        ),
    )

    var selectedTabIndex by remember {
        mutableIntStateOf(0) // or use mutableStateOf(0)
    }

    Column(modifier = Modifier.fillMaxSize()) {
        // tab row
        TabRow(selectedTabIndex = selectedTabIndex) {
            // tab items
            tabItems.forEachIndexed { index, item ->
                Tab(
                    selected = (index == selectedTabIndex),
                    onClick = {
                        selectedTabIndex = index
                    },
                    text = {
                        Text(text = item.title)
                    },
                    icon = {
                        Icon(
                            imageVector = if (index == selectedTabIndex) item.selectedIcon else item.unselectedIcon,   
                            contentDescription = null
                        )
                    }
                )
            }
        }
    }
}

data class TabItem(
    val title: String,
    val unselectedIcon: ImageVector,
    val selectedIcon: ImageVector
)

Output:

tab row layout jetpack compose

First, we created the list with the tab items. The selectedTabIndex remembers the currently selected tab.

Next, we added the TabRow to the Column layout and then added items to the TabRow using the Tab() method.

If you try to swipe left or right, nothing happens. This is because the functionality requires HorizontalPager. Let’s implement that.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MyUI() {
    val tabItems = listOf(
        TabItem(
            title = "Home",
            unselectedIcon = Icons.Outlined.Home,
            selectedIcon = Icons.Filled.Home
        ),
        TabItem(
            title = "Emails",
            unselectedIcon = Icons.Outlined.Email,
            selectedIcon = Icons.Filled.Email
        ),
        TabItem(
            title = "Favorites",
            unselectedIcon = Icons.Outlined.FavoriteBorder,
            selectedIcon = Icons.Filled.Favorite
        ),
    )

    var selectedTabIndex by remember {
        mutableIntStateOf(0) // or use mutableStateOf(0)
    }

    // remember the pager state
    var pagerState = rememberPagerState {
        tabItems.size
    }

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedTabIndex) {
            tabItems.forEachIndexed { index, item ->
                Tab(
                    selected = (index == selectedTabIndex),
                    onClick = {
                        selectedTabIndex = index
                    },
                    text = {
                        Text(text = item.title)
                    },
                    icon = {
                        Icon(
                            imageVector = if (index == selectedTabIndex) item.selectedIcon else item.unselectedIcon,   
                            contentDescription = null
                        )
                    }
                )
            }
        }

        // pager
        HorizontalPager(
            state = pagerState,
            modifier = Modifier.fillMaxWidth()
        ) { index ->
            // app content
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = tabItems[index].title,
                    fontSize = 18.sp
                )
            }
        }
    }
}

data class TabItem(
    val title: String,
    val unselectedIcon: ImageVector,
    val selectedIcon: ImageVector
)

Output:

tab row with pager

We created the pagerState object to track the pager status. The HorizontalPager() is added below the TabRow(). Gestures are working, but the text is not changing. This is because there is no connection between the tab row and the pager. Let’s connect them.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MyUI() {
    val tabItems = listOf(
        TabItem(
            title = "Home",
            unselectedIcon = Icons.Outlined.Home,
            selectedIcon = Icons.Filled.Home
        ),
        TabItem(
            title = "Emails",
            unselectedIcon = Icons.Outlined.Email,
            selectedIcon = Icons.Filled.Email
        ),
        TabItem(
            title = "Favorites",
            unselectedIcon = Icons.Outlined.FavoriteBorder,
            selectedIcon = Icons.Filled.Favorite
        ),
    )

    var selectedTabIndex by remember {
        mutableIntStateOf(0) // or use mutableStateOf(0)
    }

    var pagerState = rememberPagerState {
        tabItems.size
    }

    // coroutine scope
    val coroutineScope = rememberCoroutineScope()

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedTabIndex) {
            tabItems.forEachIndexed { index, item ->
                Tab(
                    selected = (index == selectedTabIndex),
                    onClick = {
                        selectedTabIndex = index
                        
                        // change the page when the tab is changed
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(selectedTabIndex)
                        }
                    },
                    text = {
                        Text(text = item.title)
                    },
                    icon = {
                        Icon(
                            imageVector = if (index == selectedTabIndex) item.selectedIcon else item.unselectedIcon,    
                            contentDescription = null
                        )
                    }
                )
            }
        }

        HorizontalPager(
            state = pagerState,
            modifier = Modifier.fillMaxWidth()
        ) { index ->
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = tabItems[index].title,
                    fontSize = 18.sp
                )
            }
        }
    }

    // change the tab item when current page is changed
    LaunchedEffect(pagerState.currentPage, pagerState.isScrollInProgress) {
        if (!pagerState.isScrollInProgress) {
            selectedTabIndex = pagerState.currentPage
        }
    }
}

data class TabItem(
    val title: String,
    val unselectedIcon: ImageVector,
    val selectedIcon: ImageVector
)

Output:

tab row with pager together

In the onClick block of Tab(), we are changing the page.

coroutineScope.launch {
    pagerState.animateScrollToPage(selectedTabIndex)   
}

As a result, whenever we click on the tabs, the corresponding page will be shown. The animateScrollToPage() is a suspend function. So, we should call it from the coroutine scope.

In the LaunchedEffect block, we set the tab item selected when its corresponding page is visible.

LaunchedEffect(pagerState.currentPage, pagerState.isScrollInProgress) {   
    if (!pagerState.isScrollInProgress) {
        selectedTabIndex = pagerState.currentPage
    }
}

Here is the complete code:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MyUI() {
    // tab items
    val tabItems = listOf(
        TabItem(
            title = "Home",
            unselectedIcon = Icons.Outlined.Home,
            selectedIcon = Icons.Filled.Home
        ),
        TabItem(
            title = "Emails",
            unselectedIcon = Icons.Outlined.Email,
            selectedIcon = Icons.Filled.Email
        ),
        TabItem(
            title = "Favorites",
            unselectedIcon = Icons.Outlined.FavoriteBorder,
            selectedIcon = Icons.Filled.Favorite
        ),
    )

    // remember the selected tab
    var selectedTabIndex by remember {
        mutableIntStateOf(0) // or use mutableStateOf(0)
    }

    // pager state
    var pagerState = rememberPagerState {
        tabItems.size
    }

    // coroutine scope
    val coroutineScope = rememberCoroutineScope()

    Column(modifier = Modifier.fillMaxSize()) {
        // tab row
        TabRow(selectedTabIndex = selectedTabIndex) {
            // tab items
            tabItems.forEachIndexed { index, item ->
                Tab(
                    selected = (index == selectedTabIndex),
                    onClick = {
                        selectedTabIndex = index

                        // change the page when the tab is changed
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(selectedTabIndex)
                        }
                    },
                    text = {
                        Text(text = item.title)
                    },
                    icon = {
                        Icon(
                            imageVector = if (index == selectedTabIndex) item.selectedIcon else item.unselectedIcon,   
                            contentDescription = null
                        )
                    }
                )
            }
        }

        // pager
        HorizontalPager(
            state = pagerState,
            modifier = Modifier.fillMaxWidth()
        ) { index ->
            // app content
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = tabItems[index].title,
                    fontSize = 18.sp
                )
            }
        }
    }

    // change the tab item when current page is changed
    LaunchedEffect(pagerState.currentPage, pagerState.isScrollInProgress) {
        if (!pagerState.isScrollInProgress) {
            selectedTabIndex = pagerState.currentPage
        }
    }
}

data class TabItem(
    val title: String,
    val unselectedIcon: ImageVector,
    val selectedIcon: ImageVector
)

Output:

tab row with pager together

Scrollable Tab Row:

Jetpack Compose provides ScrollableTabRow(). It looks similar to TabRow().

@Composable
fun ScrollableTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    containerColor: Color = TabRowDefaults.containerColor,
    contentColor: Color = TabRowDefaults.contentColor,
    edgePadding: Dp = ScrollableTabRowPadding,
    indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions ->    
        TabRowDefaults.SecondaryIndicator(
            Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
        )
    },
    divider: @Composable () -> Unit = @Composable {
        HorizontalDivider()
    },
    tabs: @Composable () -> Unit
)

It takes an extra parameter called edgePadding, which represents the padding applied to the beginning and end of the tab row. This is useful for informing the user that the tab row can be scrolled.

In the above code, add some tab items to the list and replace the TabRow() with the ScrollableTabRow().

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MyUI() {
    // tab items
    val tabItems = listOf(
        TabItem(
            title = "Home",
            unselectedIcon = Icons.Outlined.Home,
            selectedIcon = Icons.Filled.Home
        ),
        TabItem(
            title = "Emails",
            unselectedIcon = Icons.Outlined.Email,
            selectedIcon = Icons.Filled.Email
        ),
        TabItem(
            title = "Favorites",
            unselectedIcon = Icons.Outlined.FavoriteBorder,
            selectedIcon = Icons.Filled.Favorite
        ),
        TabItem(
            title = "Settings",
            unselectedIcon = Icons.Outlined.Settings,
            selectedIcon = Icons.Filled.Settings
        ),
        TabItem(
            title = "Cart",
            unselectedIcon = Icons.Outlined.ShoppingCart,
            selectedIcon = Icons.Filled.ShoppingCart
        ),
        TabItem(
            title = "Addresses",
            unselectedIcon = Icons.Outlined.LocationOn,
            selectedIcon = Icons.Filled.LocationOn
        ),
    )

    // remember the selected tab
    var selectedTabIndex by remember {
        mutableIntStateOf(0) // or use mutableStateOf(0)
    }

    // pager state
    var pagerState = rememberPagerState {
        tabItems.size
    }

    // coroutine scope
    val coroutineScope = rememberCoroutineScope()

    Column(modifier = Modifier.fillMaxSize()) {
        // tab row
        ScrollableTabRow(selectedTabIndex = selectedTabIndex) {
            // tab items
            tabItems.forEachIndexed { index, item ->
                Tab(
                    selected = (index == selectedTabIndex),
                    onClick = {
                        selectedTabIndex = index

                        // change the page when the tab is changed
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(selectedTabIndex)
                        }
                    },
                    text = {
                        Text(text = item.title)
                    },
                    icon = {
                        Icon(
                            imageVector = if (index == selectedTabIndex) item.selectedIcon else item.unselectedIcon,    
                            contentDescription = null
                        )
                    }
                )
            }
        }

        // pager
        HorizontalPager(
            state = pagerState,
            modifier = Modifier.fillMaxWidth()
        ) { index ->
            // app content
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = tabItems[index].title,
                    fontSize = 18.sp
                )
            }
        }
    }

    // change the tab item when current page is changed
    LaunchedEffect(pagerState.currentPage, pagerState.isScrollInProgress) {
        if (!pagerState.isScrollInProgress) {
            selectedTabIndex = pagerState.currentPage
        }
    }
}

data class TabItem(
    val title: String,
    val unselectedIcon: ImageVector,
    val selectedIcon: ImageVector
)

Output:

scrollable tab row jetpack compose

This is all about the tab layout in Material 3 Jetpack Compose. I hope you have learned something new. If you have any doubts, leave a comment below.

Related Articles:


References:

Leave a Comment