Kotlin para Jetpack Compose

Jetpack Compose se basa en Kotlin. En algunos casos, Kotlin proporciona expresiones idiomáticas especiales que facilitan la escritura de código útil de Compose. Si piensas en otro lenguaje de programación y lo traduces mentalmente a Kotlin, es probable que pierdas parte de la solidez de Compose y que te resulte difícil comprender el código Kotlin escrito con expresiones idiomáticas. Familiarizarte con el estilo de Kotlin te puede ayudar a evitar esos problemas.

Argumentos predeterminados

Cuando escribes una función de Kotlin, puedes especificar valores predeterminados para argumentos de funciones, que se van a usar si el llamador no pasa valores explícitos. Eso reduce la necesidad de que se sobrecarguen las funciones.

Por ejemplo, supongamos que deseas escribir una función que dibuje un cuadrado. Esa función podría tener un solo parámetro obligatorio, sideLength, que especifique la longitud de cada lado. Podría tener varios parámetros opcionales, como thickness, edgeColor, etcétera. Si el llamador no los especifica, la función usa valores predeterminados. En otros lenguajes, es posible que debas escribir varias funciones:

// We don't need to do this in Kotlin!
void drawSquare(int sideLength) { }

void drawSquare(int sideLength, int thickness) { }

void drawSquare(int sideLength, int thickness, Color edgeColor) { }

En Kotlin, puedes escribir una sola función y especificar los valores predeterminados para los argumentos:

fun drawSquare(
    sideLength: Int,
    thickness: Int = 2,
    edgeColor: Color = Color.Black
) {
}

Además de evitar tener que escribir varias funciones redundantes, esta característica facilita mucho la lectura del código. Si el llamador no especifica un valor para un argumento, significa que está dispuesto a usar el valor predeterminado. Además, los parámetros con nombre permiten ver mucho mejor lo que sucede. Si observas el código y ves una llamada a una función como esta, es posible que no sepas qué significan los parámetros sin verificar el código drawSquare():

drawSquare(30, 5, Color.Red);

Por el contrario, este código se autodocumenta:

drawSquare(sideLength = 30, thickness = 5, edgeColor = Color.Red)

La mayoría de las bibliotecas de Compose usan argumentos predeterminados, y es recomendable que hagas lo mismo para las funciones que escribas y que admiten composición. Eso te permite personalizar las funciones que admiten composición, pero que siga siendo sencillo invocar el comportamiento predeterminado. Así, por ejemplo, puedes crear un elemento de texto simple como este:

Text(text = "Hello, Android!")

El código tiene el mismo efecto que el código mucho más detallado que se incluye a continuación, en el que gran parte de los parámetros Text se establecen de manera explícita:

Text(
    text = "Hello, Android!",
    color = Color.Unspecified,
    fontSize = TextUnit.Unspecified,
    letterSpacing = TextUnit.Unspecified,
    overflow = TextOverflow.Clip
)

El primer fragmento de código no solo es más simple y fácil de leer, sino que también se autodocumenta. Si especificas solo el parámetro text, documentas que para todos los demás parámetros deseas usar los valores predeterminados. Por el contrario, el segundo fragmento implica que quieres establecer valores explícitos para esos otros parámetros, aunque los valores que establezcas resulten ser los predeterminados para la función.

Funciones de orden superior y expresiones lambda

Kotlin admite funciones de orden superior, que reciben otras funciones como parámetros. Compose se basa en este enfoque. Por ejemplo, la función de componibilidad Button proporciona un parámetro lambda onClick. El valor de ese parámetro es una función, a la que llama el botón cuando el usuario hace clic en él:

Button(
    // ...
    onClick = myClickFunction
)
// ...

Las funciones de orden superior se vinculan naturalmente con expresiones lambda, que evalúan a una función. Si solo necesitas usar la función una vez, no tienes que definirla en otra parte para pasarla a la función de orden superior. En su lugar, puedes definirla directamente con una expresión lambda. En el ejemplo anterior, se supone que el objeto myClickFunction() está definido en otro lugar. Sin embargo, si solo usas esa función aquí, es más fácil definirla intercalada con una expresión lambda:

Button(
    // ...
    onClick = {
        // do something
        // do something else
    }
) { /* ... */ }

Expresiones lambda finales

Kotlin ofrece una sintaxis especial para llamar a funciones de orden superior cuyo último parámetro es un valor lambda. Si deseas pasar una expresión lambda como ese parámetro, puedes usar la sintaxis de expresión lambda final. En lugar de colocar la expresión lambda dentro de los paréntesis, la colocas después. Esta situación es común en Compose, por lo que debes familiarizarte con la apariencia del código.

Por ejemplo, el último parámetro de todos los diseños, como la función de componibilidad Column(), es content, una función que emite los elementos secundarios de la IU. Supongamos que deseas crear una columna que contenga tres elementos de texto y necesitas aplicar formato. Este código funciona, pero es muy engorroso:

Column(
    modifier = Modifier.padding(16.dp),
    content = {
        Text("Some text")
        Text("Some more text")
        Text("Last text")
    }
)

Como el parámetro content es el último de la firma de la función y pasamos su valor como expresión lambda, podemos extraerlo de los paréntesis:

Column(modifier = Modifier.padding(16.dp)) {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

Los dos ejemplos tienen exactamente el mismo significado. Las llaves definen la expresión lambda que se pasa al parámetro content.

De hecho, si el único parámetro que pasas es esa expresión lambda final (es decir, si el parámetro final es un valor lambda y no pasas ningún otro parámetro), puedes omitir los paréntesis por completo. Por ejemplo, supongamos que no necesitaste pasar ningún modificador a Column, el código podrías escribirlo así:

Column {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

Esta sintaxis es bastante común en Compose, en especial para elementos de diseño como Column. El último parámetro es una expresión lambda que define los elementos secundarios, y esos elementos secundarios se especifican en llaves después de la llamada a función.

Alcances y receptores

Algunos métodos y propiedades solo están disponibles dentro de determinado alcance. El alcance limitado permite ofrecer la funcionalidad cuando sea necesario y evitar que se use por error cuando no corresponda.

Aquí tienes un ejemplo que se usa en Compose. Cuando llamas al elemento que admite composición del diseño Row, tu lambda de contenido se invoca automáticamente dentro de una RowScope. Eso permite que el objeto Row exponga funcionalidades que solo son válidas dentro de un objeto Row. En el siguiente ejemplo, se muestra cómo el objeto Row expuso un valor específico de fila para el modificador align:

Row {
    Text(
        text = "Hello world",
        // This Text is inside a RowScope so it has access to
        // Alignment.CenterVertically but not to
        // Alignment.CenterHorizontally, which would be available
        // in a ColumnScope.
        modifier = Modifier.align(Alignment.CenterVertically)
    )
}

Algunas API aceptan lambdas que se llaman dentro del alcance del receptor. Esas lambdas tienen acceso a propiedades y funciones que se definen en otro lugar, según la declaración de parámetro:

Box(
    modifier = Modifier.drawBehind {
        // This method accepts a lambda of type DrawScope.() -> Unit
        // therefore in this lambda we can access properties and functions
        // available from DrawScope, such as the `drawRectangle` function.
        drawRect(
            /*...*/
            /* ...
        )
    }
)

Para obtener más información, consulta los literales de función con receptores en la documentación de Kotlin.

Propiedades delegadas

Kotlin admite propiedades delegadas, que se llaman como si fueran campos, pero su valor se determina de forma dinámica mediante la evaluación de una expresión. Puedes reconocer esas propiedades por el uso de la sintaxis by:

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

Otro código puede acceder a la propiedad con un código como este:

val myDC = DelegatingClass()
println("The name property is: " + myDC.name)

Cuando se ejecuta println(), se llama a nameGetterFunction() para mostrar el valor de la string.

Esas propiedades delegadas son particularmente útiles cuando trabajas con propiedades respaldadas por estados:

var showDialog by remember { mutableStateOf(false) }

// Updating the var automatically triggers a state change
showDialog = true

Desestructuración de clases de datos

Si defines una clase de datos, puedes acceder con facilidad a ellos mediante una declaración de desestructuración. Por ejemplo, supongamos que defines una clase Person:

data class Person(val name: String, val age: Int)

Si tienes un objeto de ese tipo, puedes acceder a sus valores con un código como este:

val mary = Person(name = "Mary", age = 35)

// ...

val (name, age) = mary

A menudo, verás ese tipo de código en las funciones de Compose:

Row {

    val (image, title, subtitle) = createRefs()

    // The `createRefs` function returns a data object;
    // the first three components are extracted into the
    // image, title, and subtitle variables.

    // ...
}

Las clases de datos proporcionan muchas otras funcionalidades útiles. Por ejemplo, cuando defines una clase de datos, el compilador define automáticamente funciones útiles como equals() y copy(). Puedes obtener más información en la documentación sobre clases de datos.

Objetos singleton

Kotlin facilita la declaración de singleton, clases que siempre tienen una sola instancia. Esos singleton se declaran con la palabra clave object. Compose a menudo los usa. Por ejemplo, el objeto MaterialTheme se define como un objeto singleton. Las propiedades MaterialTheme.colors, shapes y typography contienen los valores para el tema actual.

DSL y compiladores de acceso seguro a tipos

Kotlin permite crear lenguajes específicos para cada dominio (DSL) con compiladores de tipo seguro. Los DSL permiten compilar estructuras de datos jerárquicos complejas de una manera más fácil de mantener y leer.

Jetpack Compose usa DSL para algunas API como LazyRow y LazyColumn.

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        // Add a single item as a header
        item {
            Text("Message List")
        }

        // Add list of messages
        items(messages) { message ->
            Message(message)
        }
    }
}

Kotlin garantiza que los compiladores de acceso seguro a tipos usen literales de función con receptores. Por ejemplo, el elemento que admite composición Canvas toma como parámetro una función con DrawScope como receptor, onDraw: DrawScope.() -> Unit, lo que permite que el bloque de código llame a las funciones de miembros definidas en DrawScope.

Canvas(Modifier.size(120.dp)) {
    // Draw grey background, drawRect function is provided by the receiver
    drawRect(color = Color.Gray)

    // Inset content by 10 pixels on the left/right sides
    // and 12 by the top/bottom
    inset(10.0f, 12.0f) {
        val quadrantSize = size / 2.0f

        // Draw a rectangle within the inset bounds
        drawRect(
            size = quadrantSize,
            color = Color.Red
        )

        rotate(45.0f) {
            drawRect(size = quadrantSize, color = Color.Blue)
        }
    }
}

Obtén más información sobre los compiladores de acceso seguro a tipos y DSL en la documentación de Kotlin.

Corrutinas de Kotlin

Las corrutinas ofrecen compatibilidad de programación asíncrona a nivel de lenguaje en Kotlin y pueden suspender la ejecución sin bloquear subprocesos. Una IU receptiva es inherentemente asíncrona, y Jetpack Compose resuelve eso admitiendo corrutinas a nivel de API, en lugar de usar devoluciones de llamada.

Jetpack Compose ofrece las API que hacen que el uso de corrutinas sea seguro dentro de la capa de IU. La función rememberCoroutineScope muestra un CoroutineScope con el que puedes crear corrutinas en controladores de eventos y llamar a las API suspendidas "Compose". Consulta el siguiente ejemplo con la API animateScrollTo de ScrollState.

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button(
    // ...
    onClick = {
        // Create a new coroutine that scrolls to the top of the list
        // and call the ViewModel to load data
        composableScope.launch {
            scrollState.animateScrollTo(0) // This is a suspend function
            viewModel.loadData()
        }
    }
) { /* ... */ }

De forma predeterminada, las corrutinas ejecutan el bloque de código de modo secuencial. Cuando una corrutina en ejecución llama a una función de suspensión, se suspende su ejecución hasta que se muestra la función de suspensión. Esto es así incluso si la función de suspensión traslada la ejecución a un CoroutineDispatcher diferente. En el ejemplo anterior, no se ejecutará loadData hasta que se muestre la función de suspensión animateScrollTo.

Para ejecutar código de manera simultánea, es necesario crear nuevas corrutinas. En el ejemplo anterior, se necesitan dos corrutinas para paralelizar el deslizamiento hacia la parte superior de la pantalla y la carga de datos de viewModel.

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button( // ...
    onClick = {
        // Scroll to the top and load data in parallel by creating a new
        // coroutine per independent work to do
        composableScope.launch {
            scrollState.animateScrollTo(0)
        }
        composableScope.launch {
            viewModel.loadData()
        }
    }
) { /* ... */ }

Las corrutinas facilitan la combinación de API asíncronas. En el siguiente ejemplo, combinamos el modificador pointerInput con las API de animación a fin de animar la posición de un elemento cuando el usuario toca la pantalla.

@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // Create a new CoroutineScope to be able to create new
                // coroutines inside a suspend function
                coroutineScope {
                    while (true) {
                        // Wait for the user to tap on the screen
                        val offset = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        // Launch a new coroutine to asynchronously animate to
                        // where the user tapped on the screen
                        launch {
                            // Animate to the pressed position
                            animatedOffset.animateTo(offset)
                        }
                    }
                }
            }
    ) {
        Text("Tap anywhere", Modifier.align(Alignment.Center))
        Box(
            Modifier
                .offset {
                    // Use the animated offset as the offset of this Box
                    IntOffset(
                        animatedOffset.value.x.roundToInt(),
                        animatedOffset.value.y.roundToInt()
                    )
                }
                .size(40.dp)
                .background(Color(0xff3c1361), CircleShape)
        )
    }

Si quieres obtener más información sobre las corrutinas, consulta la guía sobre corrutinas de Kotlin en Android.