Kotlin para Jetpack Compose

O Jetpack Compose foi criado com base em Kotlin. Em alguns casos, o Kotlin oferece expressões idiomáticas especiais que facilitam a criação de um bom código do Compose. Se você imaginar seu código em outra linguagem de programação e traduzir mentalmente essa linguagem para o Kotlin, provavelmente perderá algumas das vantagens do Compose e poderá achar difícil entender código Kotlin escrito idiomaticamente. Compreender melhor o estilo do Kotlin pode ajudar você a evitar esses problemas.

Argumentos padrão

Ao escrever uma função do Kotlin, você pode especificar valores padrão para argumentos de função, usados se o autor da chamada não transmitir esses valores explicitamente. Esse recurso reduz a necessidade de funções sobrecarregadas.

Por exemplo, suponha que você queira criar uma função que desenhe um quadrado. Essa função pode ter um único parâmetro obrigatório, sideLength, especificando o comprimento de cada lado. Ela pode ter vários parâmetros opcionais, como thickness, edgeColor e assim por diante. Se eles não forem especificados pelo autor da chamada, a função usará valores padrão. Em outras linguagens, seria esperado ter que criar várias funções:

// 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) { }

Com o Kotlin, é possível escrever uma única função e especificar os valores padrão dos argumentos:

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

Além de evitar que você precise escrever várias funções redundantes, esse recurso deixa a leitura do código muito mais clara. Caso o autor da chamada não especifique um valor para um argumento, isso indica que ele está disposto a usar o valor padrão. Além disso, os parâmetros nomeados facilitam muito a visualização do que está acontecendo. Se você observar o código e vir uma chamada de função como esta, talvez não saiba o que os parâmetros significam sem analisar o código drawSquare():

drawSquare(30, 5, Color.Red);

Por outro lado, este código é autodocumentado:

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

A maioria das bibliotecas do Compose usa argumentos padrão, e é recomendável fazer o mesmo para as funções que podem ser compostas que você criar. Essa prática torna os elementos que podem ser compostos personalizáveis, mas ainda será simples invocar o comportamento padrão. Por exemplo, você pode criar um elemento de texto simples como este:

Text(text = "Hello, Android!")

Esse código tem o mesmo efeito que o seguinte código muito mais detalhado, em que mais parâmetros Text são definidos explicitamente:

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

Além do primeiro snippet de código ser muito mais simples e fácil de ler, ele também é autodocumentado. Ao especificar apenas o parâmetro text, você documenta que, para todos os outros parâmetros, você quer usar os valores padrão. Já o segundo snippet implica que você quer definir explicitamente os valores desses outros parâmetros, ainda que os valores definidos sejam os valores padrão da função.

Funções de ordem superior e expressões lambda

O Kotlin é compatível com funções de ordem superior, que recebem outras funções como parâmetros. O Compose amplia essa abordagem. Por exemplo, a função que pode ser composta Button fornece um parâmetro lambda onClick. O valor desse parâmetro é uma função, que o botão chama quando o usuário clica nele:

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

As funções de ordem superior são pareadas naturalmente com expressões lambda, que são expressões que avaliam uma função. Se você precisar da função apenas uma vez, não precisará defini-la em outro lugar para transmiti-la à função de ordem superior. Em vez disso, você pode definir a função diretamente com uma expressão lambda. O exemplo anterior supõe que myClickFunction() foi definida em outro lugar. Mas, se você usar essa função apenas aqui, será mais simples definir a função in-line com uma expressão lambda:

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

Lambdas finais

O Kotlin oferece uma sintaxe especial para chamar funções de ordem superior cujo parâmetro last é uma lambda. Se você quiser transmitir uma expressão lambda como esse parâmetro, poderá usar a sintaxe de lambdas finais. Em vez de colocar a expressão lambda entre parênteses, você a coloca em seguida. Essa é uma situação comum no Compose, então você precisa estar familiarizado com a aparência do código.

Por exemplo, o último parâmetro para todos os layouts, como a função que pode ser composta Column(), é content, uma função que emite os elementos de IU filhos. Suponha que você queira criar uma coluna com três elementos de texto e aplicar uma formatação. Esse código funcionaria, mas seria muito complicado:

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

Como o parâmetro content é o último na assinatura da função e estamos transmitindo o valor dele como uma expressão lambda, podemos retirá-lo dos parênteses:

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

Os dois exemplos têm o mesmo significado. As chaves definem a expressão lambda transmitida para o parâmetro content.

Na verdade, se o único parâmetro que você está transmitindo for a lambda final, ou seja, se o parâmetro final for uma lambda e você não estiver transmitindo outros parâmetros, você poderá omitir os parênteses. Então, por exemplo, suponha que você não tenha transmitido um modificador para Column. Você pode escrever o código assim:

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

Essa sintaxe é muito comum no Compose, especialmente para elementos de layout, como Column. O último parâmetro é uma expressão lambda que define os filhos do elemento, e eles são especificados em chaves após a chamada de função.

Escopos e receptores

Alguns métodos e propriedades só estão disponíveis em escopos específicos. O escopo limitado permite oferecer funcionalidades quando necessário e evitar a utilização acidental delas quando não for apropriado.

Considere um exemplo usado no Compose. Quando você chama o layout Row que pode ser composto, o lambda de conteúdo é automaticamente invocado em RowScope. Isso permite que Row exponha funcionalidades válidas somente dentro de um Row. O exemplo abaixo demonstra como Row expôs um valor específico de linha para o 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)
    )
}

Algumas APIs aceitam lambdas chamadas no escopo do receptor. Essas lambdas têm acesso a propriedades e funções que são definidas em outro lugar, com base na declaração 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 ver mais informações, consulte literais de função com o receptor na documentação do Kotlin (link em inglês).

Propriedades delegadas

O Kotlin é compatível com propriedades delegadas. Essas propriedades são chamadas como se fossem campos, mas o valor delas é determinado dinamicamente pela avaliação de uma expressão. É possível reconhecer essas propriedades pelo uso da sintaxe by:

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

Outros códigos podem acessar a propriedade com um código como este:

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

Quando println() é executado, nameGetterFunction() é chamada para retornar o valor da string.

Essas propriedades delegadas são úteis principalmente quando você trabalha com propriedades com estado armazenado:

var showDialog by remember { mutableStateOf(false) }

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

Como desestruturar classes de dados

Se você definir uma classe de dados, será possível acessar os dados facilmente com uma declaração de desestruturação. Por exemplo, suponha que você defina uma classe Person:

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

Se você tiver um objeto desse tipo, poderá acessar os valores dele com um código como este:

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

// ...

val (name, age) = mary

Você geralmente verá esse tipo de código em funções do 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.

    // ...
}

As classes de dados oferecem muitas outras funcionalidades úteis. Por exemplo, quando você define uma classe de dados, o compilador define automaticamente funções úteis, como equals() e copy(). Veja mais informações na documentação das classes de dados.

Objetos singleton

Com o Kotlin, é fácil declarar singletons, que sempre têm uma e apenas uma instância. Estes singletons são declarados com a palavra-chave object. O Compose usa esses objetos com frequência. Por exemplo, o MaterialTheme é definido como um objeto singleton; as propriedades MaterialTheme.colors, shapes e typography contêm os valores do tema atual.

Builders com segurança de tipos e DSLs

O Kotlin permite criar linguagens específicas de domínio (DSLs) com builders com segurança de tipos. As DSLs permitem criar estruturas hierárquicas de dados complexas de maneira mais sustentável e legível.

O Jetpack Compose usa DSLs para algumas APIs, como LazyRow e 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)
        }
    }
}

O Kotlin garante a segurança de tipo dos builders usando literais de função com receptor (link em inglês). Se usarmos a função que pode ser composta Canvas como exemplo, ela terá como parâmetro uma função com DrawScope como receptor, onDraw: DrawScope.() -> Unit, permitindo que o bloco de código chame funções de membro definidas em 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)
        }
    }
}

Saiba mais sobre builders com segurança de tipo e DSLs na documentação do Kotlin (link em inglês).

Corrotinas do Kotlin

As corrotinas oferecem compatibilidade com programação assíncrona no nível da linguagem em Kotlin. As corrotinas podem suspender a execução sem bloquear linhas de execução. Uma IU responsiva é inerentemente assíncrona, e o Jetpack Compose resolve isso adotando corrotinas no nível da API em vez de usar callbacks.

O Jetpack Compose oferece APIs que tornam o uso de corrotinas seguro na camada da IU. A função rememberCoroutineScope retorna um CoroutineScope que você pode usar para criar corrotinas em manipuladores de eventos e chamar APIs de suspensão do Compose. Veja o exemplo abaixo usando a API animateScrollTo do 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()
        }
    }
) { /* ... */ }

As corrotinas executam o bloco de código de forma sequencial por padrão. Uma corrotina em execução que chama uma função de suspensão suspenderá a execução até que a função de suspensão seja retornada. Isso acontecerá mesmo se a função de suspensão mover a execução para um CoroutineDispatcher diferente. No exemplo anterior, o loadData não será executado até que a função de suspensão animateScrollTo retorne.

Para executar o código simultaneamente, é necessário criar novas corrotinas. No exemplo acima, para carregar a rolagem na parte superior da tela e carregar dados do viewModel, duas corrotinas são necessárias.

// 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()
        }
    }
) { /* ... */ }

As corrotinas facilitam a combinação de APIs assíncronas. No exemplo a seguir, combinamos o modificador pointerInput com as APIs de animação para animar a posição de um elemento quando o usuário toca na tela.

@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)
        )
    }

Para saber mais sobre corrotinas, consulte o guia Corrotinas do Kotlin no Android.