Scaffold En Jetpack Compose

Este tutorial te mostrará qué es y cómo se utiliza el layout Scaffold En Jetpack Compose con el objetivo de crear interfaces con Material Design a partir de una plantilla que ubica componentes predefinidos sobre un contenedor.

Pantalla genérica con Scaffold
Figura 1. Pantalla genérica con Scaffold

Verás que existe la función componible Scaffold() para crear patrones de pantallas comunes que se constituyen de elementos como: Top App Bar, Bottom App Bar, Snackbar, Floating Action Button y Navigation Drawer.

Usa la siguiente tabla de contenidos como guía para encontrar el tema el ejemplo que necesites:

Ejemplo Scaffold En Jetpack Compose

Este tutorial usa el ejemplo situado en el paquete examples/Scaffold del módulo :p7_componentes que encuentras en mi repositorio de Jetpack Compose:

En él encontrarás al archivo ScaffoldScreent.kt, origen de todos los ejemplos presentados. No dudes en consultarlo en caso de que una explicación obvie algunas líneas de código.

Figura 2. Ejemplo de Scaffold en Jetpack Compose

1. ¿Cómo Usar El Scaffold?

Al principio mencionamos que la función componible Scaffold representa un layout que implementa la estructura básica del Material Design.

Es decir, internamente agrupa componentes predefinidos, con el fin de acomodarlos en la pantalla mediante los lineamientos del Material Design y asegurándose de que funcionen correctamente en conjunto.

Por esta razón, se le puede considerar como una plantilla que nos evita tener que definir ubicación, márgenes, tamaños y comportamientos de elementos de una pantalla genérica (ejemplo: Bottom App Bar + FAB).

Pantalla con Bottom App Bar + FAB
Figura 3. Pantalla con Bottom App Bar + FAB

En su forma más sencilla, el Scaffold se invoca como cualquier layout estándar, recibiendo su contenido en una lambda al final de la línea:

Scaffold {
    // Contenido
}

Obviamente nuestra intención es usarlo con más complejidad, debido a la cantidad de componentes que permite incluir su firma:

@Composable
fun Scaffold(
    modifier: Modifier! = Modifier,
    scaffoldState: ScaffoldState! = rememberScaffoldState(),
    topBar: (@Composable () -> Unit)? = {},
    bottomBar: (@Composable () -> Unit)? = {},
    snackbarHost: (@Composable (SnackbarHostState) -> Unit)? = { SnackbarHost(it) },
    floatingActionButton: (@Composable () -> Unit)? = {},
    floatingActionButtonPosition: FabPosition! = FabPosition.End,
    isFloatingActionButtonDocked: Boolean! = false,
    drawerContent: (@Composable @ExtensionFunctionType ColumnScope.() -> Unit)? = null,
    drawerGesturesEnabled: Boolean! = true,
    drawerShape: Shape! = MaterialTheme.shapes.large,
    drawerElevation: Dp! = DrawerDefaults.Elevation,
    drawerBackgroundColor: Color! = MaterialTheme.colors.surface,
    drawerContentColor: Color! = contentColorFor(drawerBackgroundColor),
    drawerScrimColor: Color! = DrawerDefaults.scrimColor,
    backgroundColor: Color! = MaterialTheme.colors.background,
    contentColor: Color! = contentColorFor(backgroundColor),
    content: (@Composable (PaddingValues) -> Unit)?
): Unit

Como ves, es posible añadir: App Bar superior e inferior, Snackbars, FAB y un Navigation Drawer.

Nota que el parámetro scaffoldState es un objeto con el estado del Scaffold (información sobre el estado de la pantalla, configuración del drawer y tamaños de los componentes). Para crear un valor inicial de este usa la función rememberScaffoldState():

@Composable
fun ScaffoldScreen() {
    val scaffoldState = rememberScaffoldState()

    Scaffold(
        scaffoldState = scaffoldState
    ) {

    }
}

Dicho esto, estudiemos la manera de usar los demás parámetros para incluir a todos los componentes.


2. Añadir Top App Bar

Scaffold con TopAppBar
Figura 4. TopAppBar en Scaffold

2.1 Remover ActionBar De La Actividad

En las anteriores versiones de Android la ActionBar representaba la barra superior en nuestras actividades de forma fija.

Si ya tienes un proyecto existente al cual deseas aplicar Jetpack Compose, es importante remover la ActionBar usando los temas de Android que terminan en *.NoActionBar:

<style name="Theme.TuTema" parent="android:Theme.Material.Light.NoActionBar">
    <!-- .. -->
</style>

También puedes usar los atributos android:windowActionBar y android:windowNoTitle:

<style name="Theme.TuTema.NoActionBar" parent="android:Theme.Material.Light">
    <item name="android:windowActionBar">false</item>
    <item name="android:windowNoTitle">true</item>
</style>

O usar los nuevos estilos Theme.MaterialComponents.* o Theme.Material3.*. En este ejemplo usamos el siguiente tema generado automáticamente al crear un proyecto en Android Studio con la plantilla Empty Compose Activity:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.JetpackCompose" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- ... -->
    </style>
</resources>

Con esto en mente, pasemos a revisar el elemento de Jetpack Compose que representa la barra superior.

2.2 Componente TopAppBar

El parámetro topBar representa al slot donde se dibuja la Top App Bar en la pantalla. Aunque puedes pasar tu propia función componible, por lo general usamos el componente TopAppBar con el fin de crear una barra superior estándar de la librería.

Por ejemplo: Crear la barra que se muestra en la figura 4. Esta se compone de un icono en la parte izquierda para desplegar el drawer, el título y tres botones de acción para búsqueda, favorito y presentación de otras acciones.

La solución consiste en invocar la función TopAppBar() con los vectores de los iconos mencionados y el título:

@Composable
private fun ExampleTopAppBar() {
    TopAppBar(
        navigationIcon = {
            IconButton(onClick = { }) {
                Icon(imageVector = Icons.Filled.Menu, contentDescription = "Abrir menú desplegable")
            }
        },
        title = { Text(text = "Scaffold") },
        actions = {
            IconButton(onClick = { }) {
                Icon(imageVector = Icons.Filled.Favorite, contentDescription = "Favorito")
            }
            IconButton(onClick = { }) {
                Icon(imageVector = Icons.Filled.Search, contentDescription = "Buscar")
            }
            IconButton(onClick = { }) {
                Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "Más")
            }
        }
    )
}

Como ves, usamos los parámetros navigationIcon, title y actions para satisfacer cada slot propuesto.

Luego pasamos la función componible de la barra superior en topBar:

@Composable
fun ScaffoldScreen() {
    val scaffoldState = rememberScaffoldState()

    Scaffold(
        scaffoldState = scaffoldState,
        topBar = { ExampleTopAppBar() } // Top App Bar
    ) {

    }
}

Ve al tutorial TopAppBar en Jetpack Compose para profundizar sobre su funcionamiento


3. Añadir Bottom App Bar

Bottom App Bar con Scaffold
Figura 5. Bottom App Bar con Scaffold

De forma similar a la Top App Bar, usa el parámetro bottomBar para mostrar una barra inferior de la pantalla con el componente BottomAppBar(todo).

Con esto en mente, recreemos la bottom app bar de la figura previa con la siguiente función componible:

@Composable
private fun ExampleBottomAppBar() {
    BottomAppBar {
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
            IconButton(onClick = { /*TODO*/ }) { // (1)
                Icon(Icons.Filled.Menu, contentDescription = "Abri menú desplegable")
            }
        }

        Spacer(Modifier.weight(1f, true)) // (2)

        IconButton(onClick = { /*TODO*/ }) { // (3)
            Icon(imageVector = Icons.Filled.Search, contentDescription = "Buscar")
        }
        IconButton(onClick = { /*TODO*/ }) { // (4)
            Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "Más")
        }
    }
}

La función ExampleBottomAppBar() invoca a BottomAppBar para añadir a su lambda de contenido:

  1. El botón para abrir el drawer. Usamos el CompositionLocalProvider para establecer su alpha en ContentAlpha.high, ya que el contexto tiene por defecto ContentAlpha.medium
  2. Un espacio entre el botón de menú y los demás
  3. Un botón de búsqueda
  4. Un botón para revelar más opciones

Lo siguiente es invocarla en el bottomBar:

Scaffold(
    //...,
    bottomBar = { ExampleBottomAppBar() }
) {

}

Ve al tutorial BottomAppBar En Jetpack Compose (todo) para estudiar su uso en profundidad


4. Mostrar Una Snackbar

Snackbar en Scaffold
Figura 6. Snackbar en Scaffold

El parámetro snackbarHost recibe un objeto del tipo SnackbarHost, el cual se encarga de mostrar, ocultar y descartar las Snackbars en pantalla.

La firma de Scaffold toma como argumento un valor por defecto que nos evita su definición. Por lo que puedes mostrar una Snackbar solo ejecutando la función SnackbarHostState.showSnackbar().

El SnackbarHostState controla el orden de aparición en pantalla de la cola de Snackbars. Puedes acceder a este estado con la propiedad snackbarHostState de tu ScaffoldState.

Por ejemplo

Mostrar una Snackbar cada vez que el usuario hace clic en los botones de acción de la Top App Bar.

Solución

Cuando un action button de la barra recibe un evento de clic, lo procesamos desde el parámetro onClick. Para ello modificamos la función ExampleTopAppBar() para que reciba la función a ejecutar al momento del evento (onActionButtonClick).

@Composable
private fun ExampleTopAppBar(onActionButtonClick: (String) -> Unit) {
    TopAppBar(
        navigationIcon = {
            IconButton(onClick = { /*TODO*/ }) {
                Icon(imageVector = Icons.Filled.Menu, contentDescription = "Abrir menú desplegable")
            }
        },
        title = { Text(text = "Scaffold") },
        actions = {
            IconButton(onClick = { onActionButtonClick("Favorito") }) {
                Icon(imageVector = Icons.Filled.Favorite, contentDescription = "Favorito")
            }
            IconButton(onClick = { onActionButtonClick("Buscar") }) {
                Icon(imageVector = Icons.Filled.Search, contentDescription = "Buscar")
            }
            IconButton(onClick = { onActionButtonClick("Más") }) {
                Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "Más")
            }
        }
    )
}

En seguida, invocamos a showSnackbar() desde la lambda que se debe pasar a ExampleTopAppBar. Como esta función es suspendible, definimos un alcance con rememberCoroutineScope():

@Composable
fun ScaffoldScreen() {
    val scaffoldState = rememberScaffoldState()
    val scope = rememberCoroutineScope()

    Scaffold(
        scaffoldState = scaffoldState,
        topBar = {
            ExampleTopAppBar(
                onActionButtonClick = {
                    scope.launch {
                        scaffoldState.snackbarHostState.showSnackbar("Clic en '$it'")
                    }
                }
            )
        },
    ) {
        
    }
}

Si corres la vista preliminar para ScaffoldScreen y haces clic en un botón de la barra superior, se mostrará la Snackbar con el nombre de la acción:

Mostrar Snackbar en Jetpack Compose

Revisa el tutorial Snackbars en Jetpack Compose (todo) para aprender más sobre el componente y personalizar su apariencia desde el parámetro snackbarHost.


5. Añadir Floating Action Button

Floating Action Button con Scaffold
Figura 7. Floating Action Button con Scaffold

Ahora, si deseas añadir un FAB, entonces pasa como argumento una lambda componible con un componente FloatingActionButton al parámetro floatingActionButton del Scaffold.

Adicionalmente, cuentas con los parámetros floatinActionButtonPosition y isFloatingActionButtonDocked. El primero cambia la posición el FAB y el segundo solapa el FAB sobre la Bottom App Bar si es que existe.

Ejemplo

Añadir un Floating Action Button como acción principal en la parte inferior central y superponerlo sobre la barra inferior (ver la figura 7).

Solución

La solución consiste en pasar un componente FloatingActionButton junto a la posición FabPosition.Center y el valor de true para isFloatingActionButtonDocked.

Scaffold(    
    floatingActionButton = {
        FloatingActionButton(onClick = { /*TODO*/ }) {
            Icon(imageVector = Icons.Filled.Add, contentDescription = "Crear")
        }
    },
    floatingActionButtonPosition = FabPosition.Center,
    isFloatingActionButtonDocked = true
) { 
    
}

Aprende más con el tutorial de Floating Action Button en Jetpack Compose


6. Añadir Navigation Drawer

Navigation Drawer en Scaffold
Figura 8. Navigation Drawer en Scaffold

El Scaffold también te permite incluir el Navigation Drawer (todo) en tu diseño a partir de siete parámetros:

  • drawerContent: Define el contenido a dibujar en la hoja lateral que actúa como contenedor del drawer
  • drawerGesturesEnabled: Habilita/deshabilita la capacidad del drawer de recibir gestos
  • drawerShape: Forma aplicada al contenedor del drawer
  • drawerElevation: Elevación del contenedor
  • drawerBackgroundColor: Color del fondo del contenedor
  • drawerContentColor: Color aplicado al contenido de la hoja (texto e iconos)
  • drawerScrimColor: Color de la sección oscurecida cuando el drawer está abierto

Ejemplo

Crear un Navigation Drawer con estos cinco ítems de navegación: Bandeja de entrada, Enviados, Archivados, Favoritos y Papelera.

Solución

Con el fin de llevar a cabo el diseño propuesto que se ubicará en la hoja lateral, creamos una función componible que genere una lista de cinco elementos con el componente Column.

@Composable
private fun DrawerContent() {
    val sections = listOf(
        "Bandeja de entrada",
        "Enviados", "Archivados",
        "Favoritos",
        "Papelera"
    )
    Column(Modifier.padding(vertical = 8.dp)) {
        sections.forEach { section ->
            TextButton(
                onClick = { /*TODO*/ },
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 8.dp)
            ) {
                Box(
                    modifier = Modifier.fillMaxWidth(),
                    contentAlignment = Alignment.CenterStart
                ) {

                    val textColor = MaterialTheme.colors.onSurface
                    Text(
                        text = section,
                        style = MaterialTheme.typography.body2.copy(color = textColor)
                    )
                }
            }
        }
    }
}

Luego procesamos el evento de clic en el botón de menú de la app bar con el objetivo de abrir el drawer (la apertura con un swipe a la derecha está disponible por defecto):

@Composable
private fun ExampleTopAppBar(
    onMenuButtonClick: () -> Unit, // Acciones a ejecutar
    onActionButtonClick: (String) -> Unit
) {
    TopAppBar(
        navigationIcon = {
            IconButton(onClick = onMenuButtonClick) { // Ejecución
                Icon(imageVector = Icons.Filled.Menu, contentDescription = "Abrir menú desplegable")
            }
        },
        title = { /*...*/ },
        actions = {/*...*/ }
    )
}

El siguiente paso es pasar un nuevo parámetro a DrawerContent() para cerrar el drawer cuando se haga clic en un ítem:

@Composable
private fun DrawerContent(closeDrawer: () -> Unit) { // Cerrar drawer
    //...
    Column(Modifier.padding(vertical = 8.dp)) {
        sections.forEach { section ->
            TextButton(
                onClick = closeDrawer, // Ejecución
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 8.dp)
            ) {
                /*..*/
            }
        }
    }
}

Y por último invocamos a DrawerContent() en el parámetro drawerContent del Scaffold y añadimos las lambdas para la apertura y cierre:

@Composable
fun ScaffoldScreen() {
    val scaffoldState = rememberScaffoldState()
    val scope = rememberCoroutineScope()

    Scaffold(
        scaffoldState = scaffoldState,
        topBar = {
            ExampleTopAppBar(
                onMenuButtonClick = {
                    scope.launch { // Abrir drawer
                        scaffoldState.drawerState.open()
                    }
                },
                onActionButtonClick = {/*...*/ }
            )
        },
        drawerContent = { // Contenido del drawer
            DrawerContent { // Cerrar drawer
                scope.launch { scaffoldState.drawerState.close() }
            }
        }
    ) {

    }
}

Ya que necesitamos abrir el drawer al ejecutar onMenuButtonClick, invocamos la función open() de la propiedad drawerState de ScaffoldState. Y para el cierre tenemos a close().

Al correr la previsualización, verás el despliegue del navigation drawer como resultado:

Abrir y cerrar navigation drawer en Scaffold

Puedes ver más de este componente en mi tutorial Navigation Drawer en Jetpack Compose (todo).


7. Contenido Del Scaffold

Contenido en un Scaffold
Figura 9. Contenido en un Scaffold

Por último tenemos el contenido que se dibujará en el área principal del Scaffold con el parámetro content. Este representa una lambda con un parámetro PaddingValues, que contiene la cantidad de desplazamiento vertical necesario, a fin de evitar la intersección entre el contenido y las app bars.

Asimismo, si deseas cambiar el color del fondo o el contenido en el Scaffold, usa los parámetros backgroundColor y contentColor.

Ejemplo

Añadamos como ejemplo un contenido una opción que permita seleccionar entre Top o Bottom Bar con RadioButtons y el cambio de color del fondo del Scaffold con el clic en un botón.

Solución

La jerarquía del diseño la resolvemos al:

  1. Recibir como parámetro el padding adicional del Scaffold, el nombre de la app bar seleccionada, la función para seleccionar la app bar y la función a ejecutar cuando se hace clic en el botón
  2. Crear un layout Column para presentar verticalmente ambas opciones
  3. Añadir una Row para dos RadioButtons
  4. Añadir una elemento Button

El código equivalente a las tareas anteriores es:

@Composable
fun ScaffoldContent( // (1)
    padding: PaddingValues,
    appBarSelected: String,
    selectAppBar: (String) -> Unit,
    onButtonClick: () -> Unit
) {
    Column( // (2)
        modifier = Modifier.padding(top = 16.dp, bottom = padding.calculateBottomPadding()),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Row { // (3)
            LabelledRadioButton(
                label = appBarOptions[0],
                selected = appBarSelected == appBarOptions[0],
                onClick = { selectAppBar(appBarOptions[0]) })
            LabelledRadioButton(
                label = appBarOptions[1],
                selected = appBarSelected == appBarOptions[1],
                onClick = { selectAppBar(appBarOptions[1]) }
            )
        }

        Spacer(modifier = Modifier.height(16.dp))
        
        Button(onClick = onButtonClick) { // (4)
            Text(text = "Cambiar backgroundColor")
        }
    }
}

A continuación definimos los estados para la selección de la app bar y el color del fondo del Scaffold:

// Opciones
private val appBarOptions = listOf("TopAppBar", "BottomAppBar")

@Composable
fun ScaffoldScreen() {
    //...

    // Estados
    val (appBarSelected, selectAppBar) = remember { mutableStateOf(appBarOptions.first()) }
    var scaffoldBackgroundColor by remember { mutableStateOf(Color.White) }

    Scaffold(
        scaffoldState = scaffoldState,
        topBar = {
            if (appBarSelected == appBarOptions[0]) // Condicionar top bar
                ExampleTopAppBar(/*...*/)
        },
        bottomBar = {
            if (appBarSelected == appBarOptions[1]) // Condicionar bottom bar
                ExampleBottomAppBar()
        },
        backgroundColor = scaffoldBackgroundColor // Color de fondo
    ) { padding ->
        ScaffoldContent(
            padding = padding,
            appBarSelected = appBarSelected,
            selectAppBar = selectAppBar,
            onButtonClick = { // Generar color aleatorio
                scaffoldBackgroundColor = Color(Random.nextLong(0xFFFFFFFF))
            }
        )
    }
}

De esta forma, al ejecutar verás la modificación del fondo y el intercambio entre la Top App Bar y la Bottom App Bar.

Cambiar backgroundColor de Scaffold

¿Qué Sigue?

Este tutorial sobre el Scaffold en Jetpack Compose te permitió explorar el uso del componente y sus parámetros asociados. Viste como facilita la creación de pantallas comunes al momento de usar Material Design.

Se crearon varios ejemplos con el Scaffold para evidenciar su funcionamiento, pero por razones de simplicidad, se limitó la explicación de los componentes que hacen parte del layout. Para expandir el conocimiento sobre ellos ve a los siguientes tutoriales:

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!