Kotlin pour Jetpack Compose

Jetpack Compose est conçu sur la base du langage Kotlin. Dans certains cas, Kotlin fournit des expressions spéciales qui facilitent l'écriture d'un code Compose de qualité. Si vous pensez dans un autre langage de programmation et que vous le traduisez mentalement en langage Kotlin, vous risquez de ne pas bénéficier de certains avantages de Compose, et il vous sera difficile de comprendre l'écriture idiomatique du code Kotlin. Familiarisez-vous avec le style de Kotlin pour éviter ces pièges.

Arguments par défaut

Lorsque vous écrivez une fonction en Kotlin, vous pouvez spécifier des valeurs par défaut pour les arguments de fonction, qui sont utiles si l'appelant ne transmet pas ces valeurs explicitement. Cette fonctionnalité réduit le besoin de fonctions surchargées.

Par exemple, supposons que vous souhaitiez écrire une fonction qui trace un carré. Cette fonction peut avoir un seul paramètre obligatoire, sideLength, indiquant la longueur de chaque côté. Elle peut avoir plusieurs paramètres facultatifs, tels que thickness, edgeColor, etc. Si l'appelant ne les spécifie pas, la fonction utilise les valeurs par défaut. Dans d'autres langages, vous pouvez avoir à écrire plusieurs fonctions :

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

Avec le langage Kotlin, vous pouvez écrire une seule fonction et spécifier les valeurs par défaut des arguments :

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

Kotlin vous évite non seulement d'écrire plusieurs fonctions redondantes, mais il rend également votre code beaucoup plus lisible. Si l'appelant ne spécifie pas de valeur pour un argument, cela signifie qu'il est prêt à utiliser la valeur par défaut. De plus, les paramètres nommés facilitent la visualisation de ce qui se passe. Si vous examinez le code et voyez un appel de fonction comme celui-ci, vous ne connaissez peut-être pas la signification des paramètres sans vérifier le code drawSquare() :

drawSquare(30, 5, Color.Red);

En revanche, ce code est auto-documenté :

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

La plupart des bibliothèques Compose utilisent des arguments par défaut. Il est également recommandé de faire de même pour les fonctions modulables que vous écrivez. Cette pratique rend vos composables personnalisables, mais l'appel du comportement par défaut reste simple. Par exemple, vous pouvez créer un élément de texte simple comme celui-ci :

Text(text = "Hello, Android!")

Ce code a le même effet que le code suivant, qui est beaucoup plus complexe. Il définit davantage les paramètres Text explicitement :

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

Le premier extrait de code est non seulement bien plus simple et facile à lire, mais il s'auto-documente également. En spécifiant uniquement le paramètre text, vous documentez que vous souhaitez utiliser les valeurs par défaut pour tous les autres paramètres. En revanche, le second extrait implique que vous souhaitiez définir explicitement les valeurs de ces autres paramètres, bien que celles-ci soient définies comme valeurs par défaut pour la fonction.

Fonctions d'ordre supérieur et expressions lambda

Kotlin accepte les fonctions de niveau supérieur, c'est-à-dire des fonctions qui reçoivent d'autres fonctions en tant que paramètres. Compose s'appuie sur cette approche. Par exemple, la fonction modulable Button fournit un paramètre lambda onClick. La valeur de ce paramètre est une fonction, que le bouton appelle lorsque l'utilisateur clique dessus :

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

Les fonctions de niveau supérieur s'associent naturellement aux expressions lambda, c'est-à-dire aux expressions qui renvoient une fonction. Si vous n'avez besoin de la fonction qu'une seule fois, vous n'avez pas besoin de la définir ailleurs pour la transmettre à la fonction d'ordre supérieur. À la place, vous pouvez simplement définir la fonction directement avec une expression lambda. L'exemple précédent suppose que myClickFunction() est définie ailleurs. Toutefois, si vous n'utilisez cette fonction qu'ici, il est plus simple de la définir de manière intégrée avec une expression lambda :

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

Lambdas de fin

Kotlin propose une syntaxe spéciale pour appeler des fonctions d'ordre supérieur dont le paramètre last est un lambda. Si vous souhaitez transmettre une expression lambda en tant que paramètre, vous pouvez utiliser la syntaxe de lambda de fin. Au lieu de placer l'expression lambda entre parenthèses, vous la placez après. Il s'agit d'une situation courante dans Compose. Vous devez donc vous familiariser avec cette structure de code.

Par exemple, le paramètre "last" de toutes les mises en page, comme la fonction modulable Column(), est content, une fonction qui émet les éléments de l'interface utilisateur enfant. Supposons que vous vouliez créer une colonne contenant trois éléments de texte et que vous deviez appliquer une mise en forme. Ce code fonctionne, mais est très fastidieux :

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

Étant donné que le paramètre content est le dernier de la signature de la fonction et que nous transmettons sa valeur en tant qu'expression lambda, nous pouvons l'extraire des parenthèses :

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

Ces deux exemples ont la même signification. Les accolades définissent l'expression lambda transmise au paramètre content.

En fait, si le seul paramètre que vous transmettez est le lambda de fin, c'est-à-dire, si le paramètre final est un lambda, et que vous ne transmettez aucun autre paramètre, vous pouvez omettre les parenthèses. Par exemple, supposons que vous n'ayez pas besoin de transmettre un modificateur à Column. Vous pouvez écrire le code comme suit :

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

Cette syntaxe est assez courante dans Compose, en particulier pour les éléments de mise en page, comme Column. Le paramètre "last" est une expression lambda définissant les enfants de l'élément, qui sont spécifiés entre accolades après l'appel de la fonction.

Champs d'application et récepteurs

Certaines méthodes et propriétés ne sont disponibles que dans un certain champ d'application. Le champ d'application limité vous permet de proposer des fonctionnalités lorsque cela est nécessaire et d'éviter de les utiliser accidentellement lorsque cela n'est pas approprié.

Prenons un exemple utilisé dans Compose. Lorsque vous appelez le composable de mise en page Row, votre lambda de contenu est automatiquement appelé dans un RowScope. Cela permet à Row d'exposer une fonctionnalité qui n'est valide que dans une Row. L'exemple ci-dessous montre comment Row a exposé une valeur spécifique à la ligne pour le modificateur 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)
    )
}

Certaines API acceptent les lambdas, qui sont appelés dans le champ d'application du destinataire. Ces lambdas ont accès aux propriétés et fonctions définies ailleurs, en fonction de la déclaration des paramètres :

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

Pour en savoir plus, consultez la section littéraux de fonction avec récepteur dans la documentation de Kotlin.

Propriétés déléguées

Kotlin accepte les propriétés déléguées. Ces propriétés sont appelées comme des champs, mais leur valeur est déterminée dynamiquement par l'évaluation d'une expression. Vous pouvez reconnaître ces propriétés grâce à la syntaxe by :

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

Un autre code peut accéder à la propriété à l'aide d'un code de ce type :

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

Lorsque println() s'exécute, nameGetterFunction() est appelé pour renvoyer la valeur de la chaîne.

Ces propriétés déléguées sont particulièrement utiles lorsque vous utilisez des propriétés reposant sur un état :

var showDialog by remember { mutableStateOf(false) }

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

Déstructuration des classes de données

Si vous définissez une classe de données, vous pouvez facilement y accéder à l'aide d'une déclaration de déstructuration. Par exemple, supposons que vous définissiez une classe Person :

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

Si vous disposez d'un objet de ce type, vous pouvez accéder à ses valeurs à l'aide d'un code semblable à celui-ci :

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

// ...

val (name, age) = mary

Ce type de code apparaît souvent dans les fonctions 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.

    // ...
}

Les classes de données offrent de nombreuses autres fonctionnalités utiles. Par exemple, lorsque vous définissez une classe de données, le compilateur définit automatiquement des fonctions utiles telles que equals() et copy(). Pour en savoir plus, consultez la documentation sur les classes de données.

Objets Singleton

Kotlin permet de déclarer facilement des Singletons, c'est-à-dire des classes qui n'ont qu'une seule instance. Ces singletons sont déclarés avec le mot clé object. Compose utilise souvent ces objets. Par exemple, MaterialTheme est défini comme un objet singleton. Les propriétés MaterialTheme.colors, shapes et typography contiennent toutes les valeurs du thème actuel.

Compilateurs et DSL avec sûreté de typage

Kotlin permet de créer des langages spécifiques à un domaine (DSL) avec des compilateurs avec sûreté de typage. Les DSL permettent de construire des structures de données hiérarchiques complexes de manière plus facile à gérer et plus lisible.

Jetpack Compose utilise des DSL pour certaines API, telles que LazyRow et 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 garantit les compilateurs avec sûreté de typage à l'aide de littéraux de fonction avec récepteur. Si nous prenons l'exemple du composable Canvas, il utilise comme paramètre une fonction avec DrawScope comme récepteur, onDraw: DrawScope.() -> Unit, ce qui permet au bloc de code d'appeler les fonctions de membre définies dans 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)
        }
    }
}

Pour en savoir plus sur les compilateurs et les DSL avec sûreté de typage, consultez la documentation de Kotlin.

Coroutines Kotlin

Les coroutines offrent une compatibilité avec la programmation asynchrone au niveau du langage dans Kotlin. Les coroutines peuvent suspendre l'exécution sans bloquer les threads. Une interface utilisateur réactive est intrinsèquement asynchrone et Jetpack Compose résout cela en adoptant des coroutines au niveau de l'API au lieu d'utiliser des rappels.

Jetpack Compose propose des API qui permettent de sécuriser les coroutines dans la couche de l'UI. La fonction rememberCoroutineScope renvoie un CoroutineScope avec lequel vous pouvez créer des coroutines dans les gestionnaires d'événements et appeler des API de suspension Compose. Consultez l'exemple ci-dessous à l'aide de l'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()
        }
    }
) { /* ... */ }

Les coroutines exécutent le bloc de code de manière séquentielle par défaut. Une coroutine en cours d'exécution qui appelle une fonction de suspension suspend son exécution jusqu'à ce que cette dernière soit renvoyée. Cela est vrai même si la fonction de suspension déplace l'exécution vers un autre CoroutineDispatcher. Dans l'exemple précédent, loadData ne sera exécuté que lorsque la fonction de suspension animateScrollTo sera renvoyée.

Pour exécuter du code simultanément, vous devez créer de nouvelles coroutines. Dans l'exemple ci-dessus, deux coroutines sont nécessaires pour charger en parallèle les données à partir de viewModel en haut de l'écran.

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

Les coroutines facilitent la combinaison des API asynchrones. Dans l'exemple suivant, nous combinons le modificateur pointerInput avec les API d'animation pour animer la position d'un élément lorsque l'utilisateur appuie sur l'écran.

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

Pour en savoir plus sur les coroutines, consultez le guide Coroutines Kotlin sur Android.