Kotlin per Jetpack Compose

Jetpack Compose è basato su Kotlin. In alcuni casi Kotlin fornisce espressioni speciali che semplificano la scrittura di un buon codice in Compose. Se pensi in un altro linguaggio di programmazione e traduci mentalmente quella lingua in Kotlin, molto probabilmente perderai alcuni dei suoi punti di forza di Compose e potresti trovare difficile comprendere il codice Kotlin scritto in modo idiomatico. Acquisire maggiore familiarità con lo stile di Kotlin può aiutarti a evitare queste insidie.

Argomenti predefiniti

Quando scrivi una funzione Kotlin, puoi specificare i valori predefiniti per gli argomenti della funzione, utilizzati se il chiamante non trasmette questi valori in modo esplicito. Questa funzionalità riduce la necessità di funzioni sovraccaricate.

Ad esempio, supponi di voler scrivere una funzione che disegna un quadrato. Questa funzione potrebbe avere un singolo parametro obbligatorio, sideLength, che specifica la lunghezza di ogni lato. Potrebbe avere diversi parametri facoltativi, come thickness, edgeColor e così via. Se il chiamante non li specifica, la funzione utilizza i valori predefiniti. In altri linguaggi, è possibile scrivere diverse funzioni:

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

In Kotlin, puoi scrivere una singola funzione e specificare i valori predefiniti per gli argomenti:

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

Oltre a evitare di dover scrivere più funzioni ridondanti, questa funzionalità rende il codice molto più chiaro da leggere. Se il chiamante non specifica un valore per un argomento, significa che vuole utilizzare il valore predefinito. Inoltre, i parametri denominati rendono molto più facile capire cosa sta succedendo. Se esamini il codice e noti una chiamata di funzione come questa, potresti non sapere il significato dei parametri senza controllare il codice drawSquare():

drawSquare(30, 5, Color.Red);

Al contrario, questo codice è autodocumento:

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

La maggior parte delle librerie Compose utilizza argomenti predefiniti ed è buona norma fare lo stesso per le funzioni componibili che scrivi. Questa prassi rende personalizzabili i componibili, ma semplifica comunque il richiamo del comportamento predefinito. Quindi, ad esempio, potresti creare un semplice elemento di testo come questo:

Text(text = "Hello, Android!")

Questo codice ha lo stesso effetto del seguente codice molto più dettagliato, in cui più parametri Text sono impostati esplicitamente:

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

Non solo il primo snippet di codice è molto più semplice e facile da leggere, ma è anche autodocumento. Se specifichi solo il parametro text, documenti che per tutti gli altri parametri vuoi utilizzare i valori predefiniti. Al contrario, il secondo snippet implica che vuoi impostare esplicitamente i valori per gli altri parametri, anche se i valori impostati sono quelli predefiniti per la funzione.

Funzioni di ordine superiore ed espressioni lambda

Kotlin supporta funzioni di ordine superiore, che ricevono altre funzioni come parametri. Compose si basa su questo approccio. Ad esempio, la funzione componibile Button fornisce un parametro lambda onClick. Il valore di questo parametro è una funzione che il pulsante chiama quando l'utente fa clic:

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

Le funzioni di ordine superiore si accoppiano naturalmente alle espressioni lambda, espressioni che restituiscono una funzione. Se hai bisogno della funzione una sola volta, non è necessario definirla altrove per passarla alla funzione di ordine superiore. Puoi invece definire la funzione direttamente con un'espressione lambda. Nell'esempio precedente si presuppone che myClickFunction() sia definito altrove. Tuttavia, se utilizzi solo questa funzione, è più semplice definire la funzione in linea con un'espressione lambda:

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

Lambda finali

Kotlin offre una sintassi speciale per chiamare funzioni di ordine superiore il cui ultimo parametro è lambda. Se vuoi passare un'espressione lambda come parametro, puoi utilizzare la sintassi lambda trailing. Anziché inserire l'espressione lambda tra parentesi, poi la inserisci in un secondo momento. Si tratta di una situazione comune in Compose, quindi è necessario conoscere l'aspetto del codice.

Ad esempio, l'ultimo parametro di tutti i layout, come la funzione componibile Column(), è content, una funzione che emette gli elementi UI secondari. Supponiamo che tu voglia creare una colonna contenente tre elementi di testo e di dover applicare una certa formattazione. Questo codice funziona, ma è molto ingombrante:

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

Poiché il parametro content è l'ultimo nella firma della funzione e ne stiamo passando il valore come espressione lambda, possiamo estrarlo dalle parentesi:

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

I due esempi hanno esattamente lo stesso significato. Le parentesi graffe definiscono l'espressione lambda passata al parametro content.

Infatti, se l'unico parametro che passi è il valore lambda finale, ovvero se il parametro finale è lambda e non passi altri parametri, puoi omettere tutte le parentesi. Ad esempio, supponi di non dover passare un modificatore a Column. Potresti scrivere il codice come questo:

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

Questa sintassi è abbastanza comune in Compose, in particolare per gli elementi del layout come Column. L'ultimo parametro è un'espressione lambda che definisce gli elementi secondari dell'elemento. Questi parametri sono specificati tra parentesi graffe dopo la chiamata della funzione.

Ambiti e ricevitori

Alcuni metodi e proprietà sono disponibili solo in un determinato ambito. L'ambito limitato ti consente di offrire la funzionalità dove è necessaria e di evitare di utilizzarla accidentalmente dove non è appropriata.

Considera un esempio utilizzato in Compose. Quando chiami il layout componibile Row, la lambda dei contenuti viene richiamata automaticamente all'interno di un RowScope. Ciò consente a Row di esporre funzionalità che è valida solo all'interno di un elemento Row. L'esempio seguente mostra in che modo Row ha esposto un valore specifico per la riga per il modificatore 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)
    )
}

Alcune API accettano i lambda, chiamati nell'ambito del ricevitore. Queste lambda hanno accesso a proprietà e funzioni definite altrove, in base alla dichiarazione dei parametri:

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

Per ulteriori informazioni, consulta i valori letterali di funzione con ricevitore nella documentazione di Kotlin.

Proprietà delegate

Kotlin supporta le proprietà delegate. Queste proprietà vengono chiamate come se fossero campi, ma il loro valore viene determinato dinamicamente valutando un'espressione. Puoi riconoscere queste proprietà dall'utilizzo della sintassi by:

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

Un altro codice può accedere alla proprietà con un codice simile a questo:

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

Quando println() viene eseguito, nameGetterFunction() viene chiamato per restituire il valore della stringa.

Queste proprietà delegate sono particolarmente utili quando lavori con proprietà supportate da uno stato:

var showDialog by remember { mutableStateOf(false) }

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

Distruzione delle classi di dati

Se definisci una classe di dati, puoi accedere facilmente ai dati con una dichiarazione destrutturante. Ad esempio, supponi di definire una classe Person:

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

Se hai un oggetto di quel tipo, puoi accedere ai suoi valori con codice come questo:

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

// ...

val (name, age) = mary

Spesso vedrai questo tipo di codice nelle funzioni di Scrivi:

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.

    // ...
}

Le classi di dati offrono molte altre funzionalità utili. Ad esempio, quando definisci una classe di dati, il compilatore definisce automaticamente funzioni utili come equals() e copy(). Puoi trovare ulteriori informazioni nella documentazione relativa alle classi di dati.

Oggetti singleton

Kotlin semplifica la dichiarazione dei singolitoni, ovvero le classi che hanno sempre una sola istanza. Questi singleton vengono dichiarati con la parola chiave object. Compose spesso utilizza questi oggetti. Ad esempio, MaterialTheme viene definito come un oggetto singleton; le proprietà MaterialTheme.colors, shapes e typography contengono tutte i valori per il tema corrente.

Builder e DSL sicuri per tipo

Kotlin consente di creare lingue specifici del dominio (DSL) con i builder di tipo sicuro per il tipo. Le DSL consentono di creare strutture di dati gerarchici e complesse in modo più gestibile e leggibile.

Jetpack Compose utilizza le DSL per alcune API come 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)
        }
    }
}

Kotlin garantisce i costruttori sicuri per i tipi utilizzando valori letterali di funzione con ricevitore. Se prendiamo come esempio il componibile Canvas, prendiamo come parametro una funzione con DrawScope come destinatario, onDraw: DrawScope.() -> Unit, che consente al blocco di codice di chiamare le funzioni dei membri definite in 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)
        }
    }
}

Scopri di più sugli strumenti per la creazione di tipi sicuri e DSL nella documentazione di Kotlin.

Coroutine Kotlin

Le coroutines offrono supporto per la programmazione asincrona a livello di lingua in Kotlin. Le coroutine possono sospendere l'esecuzione senza bloccare i thread. Un'interfaccia utente reattiva è intrinsecamente asincrona e Jetpack Compose risolve il problema incorporando le coroutine a livello di API anziché utilizzare i callback.

Jetpack Compose offre API che rendono sicuro l'uso delle coroutine all'interno del livello UI. La funzione rememberCoroutineScope restituisce un CoroutineScope con cui puoi creare coroutine nei gestori di eventi e chiamare le API di sospensione di Compose. Guarda l'esempio di seguito utilizzando l'API animateScrollTo di 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()
        }
    }
) { /* ... */ }

Per impostazione predefinita, le coroutine eseguono il blocco di codice in sequenza. Una coroutine in esecuzione che chiama una funzione di sospensione sospende la sua esecuzione fino a quando non viene restituita la funzione di sospensione. Questo vale anche se la funzione di sospensione sposta l'esecuzione in un altro CoroutineDispatcher. Nell'esempio precedente, loadData non verrà eseguito finché non verrà restituita la funzione di sospensione animateScrollTo.

Per eseguire contemporaneamente il codice, è necessario creare nuove coroutine. Nell'esempio riportato sopra, per caricare in contemporanea lo scorrimento fino alla parte superiore dello schermo e il caricamento dei dati da viewModel, sono necessarie due coroutine.

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

Le coroutine semplificano la combinazione delle API asincrone. Nel seguente esempio, combiniamo il modificatore pointerInput con le API di animazione per animare la posizione di un elemento quando l'utente tocca lo schermo.

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

Per scoprire di più sulle coroutine, consulta la guida relativa alle coroutine di Kotlin su Android.