Tabs con TabRow HorizontalPager en Android Studio | Jetpack Compose

Video thumbnail

Vamos a aprender a crear un sistema de Tabs (pestañas), similar al que ves aquí. Si te preguntas por el nombre, hace años en el desarrollo tradicional de Android se utilizaba el TabLayout con ViewPager y XML; hoy en día, en Compose, la lógica es mucho más potente y sencilla.

Como puedes suponer, este es un componente que, al igual que los diálogos, Drawers o Bottom Sheets, incluye animaciones. Al haber transiciones de movimiento, es obligatorio el uso de Corrutinas para gestionar los cambios de estado de forma asíncrona.

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, aunque, al final del artículo te dejo la implementación Legacy con XML.

Anteriormente vimos como usar los menús lateral o Navigation Drawer con Android Studio.

Enfoque Moderno: HorizontalPager en Jetpack Compose

Para lograr el mismo efecto de swipe con pestañas en Compose, utilizamos HorizontalPager y un TabRow:

// Ejemplo de ViewPager 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 ->
            // Simplemente llamamos a la vista del fragment/pantalla
            PantallaContenido(tabs[page])
        }
    }
}

Estado y Reactividad

Recordemos una regla de oro en Compose: todas las variables reactivas (el estado) deben declararse dentro de un @Composable y no directamente en el método onCreate.

Para las pestañas, requerimos un estado especial: PagerState.

  • rememberPagerState: Lo inicializamos indicando el pageCount (la cantidad de pestañas que tendrá nuestro sistema). Esto permite que el componente sepa cuántos elementos debe renderizar.
  • Corrutinas: Como mencioné, al existir animaciones para pasar de una pestaña a otra, necesitamos un scope de corrutinas para accionar los cambios de página.
val tabs = listOf("Vinos", "Cervezas", "Gourmet")
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()

Estructura del TabRow (La barra de pestañas)

Utilizaremos una Column que contenga el equivalente al TabLayout tradicional. En las versiones más recientes de Material Design 3, se recomienda usar PrimaryTabRow o SecondaryTabRow.

Iteración de los Tabs

En lugar de escribir cada pestaña a mano, lo ideal es iterar un listado de categorías (Vino, Cerveza, Gourmet):

// 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 TabRow se encuentra deprecated así que, puedes usar en su lugar PrimaryScrollableTabRow o SecondaryScrollableTabRow:

PrimaryScrollableTabRow (selectedTabIndex = pagerState.currentPage) {

El componente TabRow tiene parámetros fijos como selected, onClick, text e icon. Si quieres algo más complejo, podrías pasar un Data Class con iconos personalizados para cada categoría.

Contenido Dinámico: HorizontalPager

Una vez definida la barra superior, necesitamos el componente que muestra el contenido: el HorizontalPager. Lo interesante es que tanto el TabRow como el HorizontalPager comparten la misma variable de estado (pagerState). Esto hace que, si deslizas el contenido con el dedo (efecto swipe), la pestaña de arriba se mueva automáticamente, y viceversa:

// El equivalente al ViewPager
HorizontalPager(
    state = pagerState,
    modifier = Modifier.fillMaxSize()
) { page ->
    // Simplemente llamamos a la vista del fragment/pantalla
    PantallaContenido(tabs[page])
}

Renderizado según categoría

Dentro del HorizontalPager, evaluamos la página actual para cargar el contenido correspondiente. Podemos usar un bloque when (el equivalente al switch) para decidir qué lista de datos mostrar:

  • Si es Vino, cargamos el listado de vinos.
  • Si es Cerveza, el de cervezas.
  • Por defecto, el de Gourmet.

Visualización con LazyColumn y ListItem

Para mostrar los ítems, utilizamos una LazyColumn (la evolución del RecyclerView). Dentro de cada celda, empleamos el componente ListItem (que en Flutter sería el ListTile). Este componente es muy versátil y nos permite definir:

  • HeadlineContent: El título principal.
  • SupportingContent: La descripción o subtítulo.
  • LeadingContent: El icono o imagen a la izquierda.
@Composable
fun PantallaContenido(categoria: String) {
    // Aquí podrías cargar datos reales según la categoría
    val items = remember(categoria) {
        when (categoria) {
            "Vinos" -> listOf("Cabernet", "Merlot", "Malbec")
            "Cervezas" -> listOf("Ipa", "Stout", "Lager")
            else -> listOf("Quesos", "Jamones", "Aceites")
        }
    }

    // Estructura de la vista
    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á amontonado. Para solucionar esto, Jetpack Compose nos ofrece el ScrollableTabRow. Esto permite que el usuario deslice la barra de pestañas horizontalmente, manteniendo una navegación limpia y funcional.

@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()) {
        // El equivalente al TabLayout
        PrimaryScrollableTabRow (selectedTabIndex = pagerState.currentPage) {
TabRow y HorizontalPager ejemplo

Remover el swipe

Si en el enfoque Legacy teníamos que crear un CustomViewPager para quitar el movimiento lateral, en Compose es tan sencillo como establecer un parámetro:

HorizontalPager(
    state = pagerState,
    userScrollEnabled = false // Esto deshabilita el "terrible" efecto swipe
) { page ->
    // ...
}

Enfoque Legacy: ViewPager en XML (AndroidX)

En esta sección veremos cómo emplear los ViewPager2 (la evolución de los antiguos ViewPager de soporte), los cuales permiten manejar convenientemente el característico movimiento lateral (derecha/izquierda) o swipe de forma nativa.

Los ViewPager son empleados frecuentemente en conjunto con los fragments. En versiones actuales de Android Studio, se recomienda el uso de las librerías de androidx en lugar de las antiguas android.support.

Los viewPager son unos tipos de vistas que cada vez son más comunes como en aplicaciones como la Google Play y permite desplazarnos entre distintas pantallas a través de un menú lateral:

Uso de viewPager en Legado Gourmet

Uso del ViewPager en Android.

Uso de viewPager en Google Play

Uso del ViewPager en Android.

Como puedes ver, puedes ubicarla donde quieras, arriba o abajo como cualquier otra vista en Android.

Un punto fuerte de los ViewPager es que permiten emplear de manera nativa el swipe que es el característico movimiento lateral para desplazarnos entre pantallas:

swipe  en Gmail

A la práctica con XML:

Desde nuestro Android Studio creamos un layout como el siguiente, utilizando el componente moderno ViewPager2:

"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">

    .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"/>

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

Ahora debemos definir un adapter que herede de FragmentStateAdapter para manejar nuestros fragments:

public class MiFragmentPagerAdapter extends FragmentStateAdapter {
    private final List tags;

    public MiFragmentPagerAdapter(@NonNull FragmentActivity fragmentActivity, List 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 se hace manualmente icono por icono de forma obligatoria, sino que usamos TabLayoutMediator:

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 estamos listos y tenemos una aplicación como la siguiente:

viewPager ejemplo

Remover el swipe

Si por alguna razón te molesta el swipe, puedes quitarselo de encima definiendo nuestro propio ViewPager que llamaremos como no CustomViewPager:

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 actividad no tenemos que hacer nada del otro mundo, solo colocar CustomViewPager en vez de ViewPager:

ViewPager viewPager = (CustomViewPager) findViewById(R.id.viewpager);

E invocar al método:

viewPager.setPagingEnabled(false);

Con esto podrás quitarte de encima el "terrible" efecto swipe.

El siguiente paso, aprende a usar los menus en Android Studio.

Acepto recibir anuncios de interes sobre este Blog.

Aprende a implementar Tabs en Jetpack Compose paso a paso. Domina el uso de TabRow, HorizontalPager y PagerState con corrutinas para crear apps Android modernas.

| 👤 Andrés Cruz

🇺🇸 In english