Índice de contenido
- Enfoque Moderno: TabRow y HorizontalPager en Jetpack Compose
- Estado y Reactividad: PagerState y Corrutinas
- Estructura del TabRow: la barra de pestañas
- Iteración de los Tabs
- Contenido Dinámico: HorizontalPager
- Renderizado según categoría
- Visualización con LazyColumn y ListItem
- Tabs con Scroll: PrimaryScrollableTabRow y SecondaryScrollableTabRow
- Cómo deshabilitar el Swipe en HorizontalPager
- Enfoque Legacy: TabLayout y ViewPager2 en XML (AndroidX)
- Cómo deshabilitar el Swipe en ViewPager (Legacy)
Vamos a aprender a crear un sistema de Tabs (pestañas) en Android con Jetpack Compose, similar al que puedes ver en el vídeo. Si te preguntas por el nombre, hace años en el desarrollo tradicional de Android se utilizaba el TabLayout junto con ViewPager y XML; hoy en día, en Compose, la lógica es mucho más potente y, a la vez, más sencilla de implementar.
Como puedes suponer, este es un componente que, al igual que los diálogos, Drawers o Bottom Sheets, incluye animaciones. Al existir transiciones de movimiento entre pestañas, es obligatorio el uso de Corrutinas para gestionar los cambios de estado de forma asíncrona sin bloquear el hilo principal.
Hoy en día, el desarrollo en Android ha evolucionado hacia Jetpack Compose. Aquí ya no necesitamos XMLs complejos ni Adapters pesados; todo se define mediante funciones @Composable, lo que resulta en código más limpio, reactivo y fácil de mantener. Al final del artículo te dejo también la implementación Legacy con XML por si la necesitas como referencia.
Anteriormente vimos cómo usar los menús laterales o Navigation Drawer en Android Studio. Si aún no lo has visto, te recomiendo echarle un vistazo antes de continuar.
Enfoque Moderno: TabRow y HorizontalPager en Jetpack Compose
Para lograr el efecto de swipe con pestañas en Compose, la combinación perfecta es HorizontalPager junto con TabRow. A diferencia del enfoque Legacy, aquí no necesitamos un Adapter ni un Mediator; ambos componentes comparten el mismo objeto de estado y se sincronizan automáticamente.
A continuación, el esquema completo del componente:
// Ejemplo de Tabs con HorizontalPager en Compose
@Composable
fun MiPantallaPager() {
val tabs = listOf("Vinos", "Cervezas", "Gourmet")
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
// El equivalente al TabLayout
TabRow(selectedTabIndex = pagerState.currentPage) {
tabs.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
},
text = { Text(title) },
icon = { Icon(Icons.Default.Star, contentDescription = null) }
)
}
}
// El equivalente al ViewPager
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
PantallaContenido(tabs[page])
}
}
}Estado y Reactividad: PagerState y Corrutinas
Recordemos una regla de oro en Compose: todas las variables reactivas (el estado) deben declararse dentro de una función @Composable, nunca directamente en el método onCreate de la Activity. De lo contrario, perderás la reactividad de la UI.
Para el sistema de pestañas necesitamos dos elementos clave de estado:
rememberPagerState: Se inicializa indicando elpageCount(la cantidad de pestañas del sistema). Este objeto es el corazón de la implementación: sabe en qué página está el usuario en todo momento y expone propiedades comocurrentPageycurrentPageOffsetFractionpara animaciones más avanzadas.rememberCoroutineScope: Al existir animaciones para pasar de una pestaña a otra, necesitamos un scope de corrutinas para lanzar la animación deanimateScrollToPagede forma segura desde un callback deonClick, que no es un contexto suspendible por sí solo.
val tabs = listOf("Vinos", "Cervezas", "Gourmet")
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()Estructura del TabRow: la barra de pestañas
Usamos una Column como contenedor principal, que albergará tanto la barra de pestañas como el paginador. El TabRow es el equivalente al TabLayout tradicional de XML; recibe el índice de la pestaña activa a través del parámetro selectedTabIndex y se encarga de dibujar el indicador animado debajo de la pestaña seleccionada.
En las versiones más recientes de Material Design 3, se recomienda migrar de TabRow a PrimaryTabRow o SecondaryTabRow, que siguen las nuevas especificaciones de diseño de Google.
Iteración de los Tabs
En lugar de escribir cada pestaña a mano, lo ideal es iterar una lista de categorías. Con forEachIndexed obtenemos tanto el índice como el título de cada pestaña, lo que nos permite gestionar la selección con precisión:
// El equivalente al TabLayout
TabRow(selectedTabIndex = pagerState.currentPage) {
tabs.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
},
text = { Text(title) },
icon = { Icon(Icons.Default.Star, contentDescription = null) }
)
}
}Importante: el componente TabRow se encuentra marcado como deprecated en las versiones más recientes de Material3. En su lugar, puedes usar PrimaryScrollableTabRow o SecondaryScrollableTabRow:
PrimaryScrollableTabRow(selectedTabIndex = pagerState.currentPage) { ... }El componente Tab acepta parámetros como selected, onClick, text e icon. Si necesitas algo más elaborado —por ejemplo, iconos personalizados por categoría— puedes definir un data class que encapsule el icono y el título de cada pestaña y pasarlo como elemento de tu lista.
Contenido Dinámico: HorizontalPager
Una vez definida la barra superior, necesitamos el componente que muestra el contenido de cada pantalla: el HorizontalPager. Lo más elegante de esta arquitectura es que tanto el TabRow como el HorizontalPager comparten el mismo objeto pagerState. Esto crea una sincronización bidireccional automática: si el usuario desliza el contenido con el dedo (swipe), la pestaña activa se actualiza sola, y si pulsa una pestaña, el paginador navega animadamente a la página correspondiente:
// El equivalente al ViewPager
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
PantallaContenido(tabs[page])
}Renderizado según categoría
Dentro del HorizontalPager, evaluamos la categoría de la página actual para cargar el contenido correspondiente. La forma más idiomática en Kotlin es usar un bloque when (el equivalente al switch de Java) para decidir qué conjunto de datos mostrar según la pestaña activa:
- Si la categoría es
"Vinos", cargamos el listado de vinos. - Si la categoría es
"Cervezas", el de cervezas. - En el bloque
else, el catálogo de Gourmet por defecto.
Visualización con LazyColumn y ListItem
Para mostrar los ítems de cada pestaña, utilizamos una LazyColumn (la evolución del RecyclerView en Compose). Dentro de cada celda, empleamos el componente ListItem —que en Flutter sería el ListTile—. Es muy versátil y nos permite definir las tres zonas clásicas de un ítem de lista:
headlineContent: El título principal del ítem.supportingContent: La descripción o subtítulo.leadingContent: El icono o imagen a la izquierda del ítem.
@Composable
fun PantallaContenido(categoria: String) {
val items = remember(categoria) {
when (categoria) {
"Vinos" -> listOf("Cabernet", "Merlot", "Malbec")
"Cervezas" -> listOf("Ipa", "Stout", "Lager")
else -> listOf("Quesos", "Jamones", "Aceites")
}
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.TopStart
) {
Column {
Text(
text = "Catálogo de $categoria",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items) { producto ->
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
ListItem(
headlineContent = { Text(producto) },
supportingContent = { Text("Descripción breve del producto...") },
leadingContent = {
Icon(Icons.Default.Info, contentDescription = null)
}
)
}
}
}
}
}
}Tabs con Scroll: PrimaryScrollableTabRow y SecondaryScrollableTabRow
Si tu aplicación tiene muchas pestañas —por ejemplo, 10 o más— y no caben en el ancho de la pantalla, el diseño se verá apretado y poco usable. Para solucionar esto, Jetpack Compose nos ofrece PrimaryScrollableTabRow y SecondaryScrollableTabRow. Estos componentes permiten que el usuario deslice la barra de pestañas horizontalmente, manteniendo una navegación limpia y funcional sin importar cuántas categorías tengas.
La diferencia entre ambos es meramente visual y semántica, siguiendo la especificación de Material Design 3: PrimaryScrollableTabRow es para navegación de nivel superior, mientras que SecondaryScrollableTabRow se usa para sub-categorías dentro de una pantalla.
@Composable
fun MiPantallaPager() {
val tabs = listOf("Vinos", "Cervezas", "Gourmet","Vinos", "Cervezas", "Gourmet","Vinos", "Cervezas", "Gourmet")
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
// Usamos PrimaryScrollableTabRow para soportar muchas pestañas
PrimaryScrollableTabRow(selectedTabIndex = pagerState.currentPage) {
Cómo deshabilitar el Swipe en HorizontalPager
En algunos casos, puede que el swipe no sea el comportamiento deseado —por ejemplo, si quieres que el usuario solo navegue pulsando las pestañas—. Si en el enfoque Legacy teníamos que crear un CustomViewPager completo para quitar el movimiento lateral, en Compose es tan sencillo como añadir un único parámetro al HorizontalPager:
HorizontalPager(
state = pagerState,
userScrollEnabled = false // Deshabilita el efecto swipe por completo
) { page ->
// ...
}Enfoque Legacy: TabLayout y ViewPager2 en XML (AndroidX)
En esta sección veremos cómo implementar el mismo sistema de pestañas usando el enfoque tradicional con ViewPager2 —la evolución de los antiguos ViewPager de soporte— junto con el TabLayout de Material Components. Aunque ya no es el enfoque recomendado para proyectos nuevos, es muy útil si mantienes un proyecto existente o trabajas en una base de código mixta.
Los ViewPager2 se emplean frecuentemente junto con Fragments. En versiones actuales de Android Studio, se recomienda usar las librerías de androidx en lugar de las antiguas android.support, que quedaron obsoletas hace varios años.
Los ViewPager2 son componentes que permiten desplazarse entre distintas pantallas a través de un gesto deslizante (swipe), de forma muy similar a como funciona la app de Google Play o Gmail:

Ejemplo del ViewPager en una app Android.

El mismo patrón en la app de Google Play.
Como puedes ver, puedes ubicar el TabLayout tanto en la parte superior como en la inferior de la pantalla, como cualquier otra vista de Android.
Un punto fuerte del ViewPager2 es que soporta el swipe de forma nativa, permitiendo al usuario navegar entre pantallas con un gesto lateral sin configuración adicional:

A la práctica con XML:
En Android Studio creamos un layout como el siguiente, usando el componente ViewPager2 junto con el TabLayout de Material Components:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/appbartabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="fixed"
app:tabGravity="fill"/>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewpager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>A continuación, definimos un adapter que herede de FragmentStateAdapter para gestionar los Fragments de cada pestaña. El método createFragment actúa como una fábrica que devuelve el Fragment correcto según la posición:
public class MiFragmentPagerAdapter extends FragmentStateAdapter {
private final List<String> tags;
public MiFragmentPagerAdapter(@NonNull FragmentActivity fragmentActivity, List<String> tags) {
super(fragmentActivity);
this.tags = tags;
}
@NonNull
@Override
public Fragment createFragment(int position) {
switch(position) {
case 0: return new Fragment1();
case 1: return new Fragment2();
default: return new Fragment1();
}
}
@Override
public int getItemCount() {
return tags.size();
}
}Para vincular el TabLayout con el ViewPager2, ya no es necesario configurar cada pestaña manualmente. La clase TabLayoutMediator se encarga de esa conexión de forma automática y declarativa:
ViewPager2 viewPager = findViewById(R.id.viewpager);
TabLayout tabLayout = findViewById(R.id.appbartabs);
viewPager.setAdapter(new MiFragmentPagerAdapter(this, tags));
new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> {
tab.setText(tags.get(position));
tab.setIcon(ICONS[position]);
}).attach();Con esto obtenemos una aplicación completamente funcional con pestañas y swipe nativo:

Cómo deshabilitar el Swipe en ViewPager (Legacy)
Si necesitas quitar el efecto de swipe en el enfoque Legacy, la solución tradicional es extender ViewPager con una clase propia a la que llamaremos CustomViewPager. Sobreescribimos los métodos onTouchEvent e onInterceptTouchEvent para interceptar y cancelar el gesto:
public class CustomViewPager extends ViewPager {
private boolean enabled;
public CustomViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
this.enabled = true;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (this.enabled) {
return super.onTouchEvent(event);
}
return false;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
if (this.enabled) {
return super.onInterceptTouchEvent(event);
}
return false;
}
public void setPagingEnabled(boolean enabled) {
this.enabled = enabled;
}
}
Desde nuestra Activity, simplemente usamos CustomViewPager en lugar de ViewPager al hacer el findViewById:
ViewPager viewPager = (CustomViewPager) findViewById(R.id.viewpager);Y lo desactivamos llamando a su método:
viewPager.setPagingEnabled(false);Con esto, el swipe quedará completamente deshabilitado. Como puedes ver, la diferencia de código entre el enfoque Legacy y Compose es notable: en Compose, un solo parámetro (userScrollEnabled = false) equivale a toda una clase personalizada en el mundo XML.
El siguiente paso, aprende a usar los menús de opciones en Android Studio.