Android Love Kotlin

Cet article explique comment dessiner un coeur dans une vue avec le langage Kotlin, sous Android. En particulier, une vue personnalisée est utilisée, soit une classe qui hérite de android.view.View. Cela afin de dessiner un ♡ dans un Canvas à l’aide d’un pinceau, c’est-à-dire un objet Paint. De plus, la forme du coeur est dessinée à partir de courbes de Bézier et d’une liste de points secrets.

Dans le but d’optimiser le rendu des visuels de l’application Bachamada sur PlayStore (abréviation de Mon coeur bat la chamade), nous travaillons avec une vue personnalisée. Les visuels de Bachamada sont essentiellement des coeurs affichant la fréquence cardiaque de l’utilisateur au centre. Auparavant, une image .png était utilisée pour afficher un coeur. Cela dit, pour la montre connectée, ce n’est pas optimal car les images .png sont lourdes à charger et prennent de l’espace. D’autant plus qu’il faut afficher l’interface graphique avec des contrastes différents selon le mode : ‘ambiant’ ou ‘normal’; il faudrait donc une image .png pour chaque mode. Bref, utiliser des images était temporaire car ce n’est pas du tout optimisé, cela peut largement être mieux.

Afin d’optimiser l’affichage, sur la montre connectée, ainsi que sur le téléphone, il s’agit de dessiner un coeur dans un Canvas avec un objet Paint à l’aide des courbes de Bezier cubicTo().

La vidéo de Gautier Mechling, sur le cadran pour Android Wear, m’a inspirée dans la réalisation de cette mission d’optimisation de Bachamada. En effet, parmi ces précieux conseils, le numéro 9 est à retenir:
“Expérimenter dans une custom view d’une application Android (pas wear)”.

Mise en place de l’environnement de travail

  1. C’est parti, on ouvre Android Studio 3.
  2. On créé un projet incluant le support du langage Kotlin, cochez Include Kotlin support.
  3. On ajoute une classe qui hérite de android.view.View, nommez la, par exemple, HeartView, elle devrait ressembler à cela :

     package com.chillcoding.ilove.view
     class HeartView : View {
         private var mPaint = Paint()
         private val mHeartPath = Path()
    
         constructor(context: Context) : super(context) {
             init()
         }
    
         constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
             init()
         }
    
         companion object {
             private val TAG = "CUSTOM VIEW"
         }
     }
    

    La classe HeartView déclare les attributs :

    • mPaint de type Paint
    • mPath de type Path

    Remarquez les parenthèses, (), elles indiquent l’appel des constructeurs, respectivement de Paint et de Path.

    Il est nécessaire d’avoir les deux constructeurs afin d’assurer le bon fonctionnement de notre vue HeartView :

    • un constructeur prenant en paramètre un Context
    • un constructeur prenant en paramètres un AttributeSet et un Context

    Ces constructeurs font appel à une fonction init(), voir point suivant, pour initialiser les attributs.

    Enfin, en Kotlin, le companion object est utilisé pour les variables statiques de classe.

  4. La méthode init() sert à initialiser notre objet Paint :

     private fun init() {
         mPaint.style = Paint.Style.FILL
         mPaint.color = Color.RED
     }
    

    Une métaphore possible est : l’objet Paint équivaut à un pinceau.

  5. Dans HeartView, l’implémentation de la fonction onLayout permet de récupérer la taille de la zone d’affichage :

     override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
         super.onLayout(changed, left, top, right, bottom)
         createGraphicalObject()
     }
    

    Pour le moment, on ne récupère pas encore la hauteur et la largueur de l’écran. Cela dit, une fois les dimensions fixées, on peut appeler la fonction createGraphicalObject(), elle initialise tous les Path de la vue.

  6. Toujours dans HeartView, on créé la fonction createGraphicalObject() :

     private fun createGraphicalObject() {
         mHeartPath.set(createHeart())
     }
    

    Ici, on commence petit avec un seul Path à dessiner, via la fonction createHeart().

  7. Là, c’est le moment d’écrire la fonction renvoyant un Path correspondant à un coeur (cf. code pour dessiner un ♡) :

     private fun createHeart(): Path {
         val path = Path()
         path.moveTo(75F, 40F)
         path.cubicTo(75F, 37F, 70F, 25F, 50F, 25F)
         path.cubicTo(20F, 25F, 20F, 62.5F, 20F, 62.5F)
         path.cubicTo(20F, 80F, 40F, 102F, 75F, 120F);
         path.cubicTo(110F, 102F, 130F, 80F, 130F, 62.5F)
         path.cubicTo(130F, 62.5F, 130F, 25F, 100F, 25F)
         path.cubicTo(85F, 25F, 75F, 37F, 75F, 40F)
         return path
     }
    
  8. Et puis quoi encore ? Il faut implémenter la fonction d’héritage onDraw() :

     override fun onDraw(canvas: Canvas) {
         super.onDraw(canvas)
         canvas.drawPath(mHeartPath, mPaint)
     }
    

    Voilà, le Path coeur, mHeartPath, est dessiné via drawPath() dans la zone d’affichage, canvas, avec le pinceau mPaint.

  9. Pour finir, il s’agit d’ajouter cet objet créé dans notre vue principale (sachant que la classe HeartView est dans le package com.chillcoding.ilove.view :

     <com.chillcoding.ilove.view.HeartView
             android:layout_width="match_parent"
             android:layout_height="match_parent" />
    

    Normalement, ce code va dans activity_main.xml du projet Android Studio de défaut.

First one Android Heart

Le résultat est là mais c’est pas ça. Les coordonnées sont fixées dans un carré de 130 par 130 pixels. L’idéal est d’avoir un coeur avec des coordonnées dynamiques, c’est-à-dire inscrits dans un carré dont les dimensions peuvent varier. En effet, les dimensions de la zone d’affichage ne sont pas figées. Elles varient selon le support.
De plus, dans le cas de Bachamada, parfois le coeur s’affiche dans une fenêtre, si ce n’est pas sur le cadran de la montre connectée, ou sur le téléphone.

Dessiner un magnifique coeur avec des coordonnées dynamiques

Dans cette partie, les deux points suivants sont approfondis :

  • avoir des coordonnées dynamiques (s’adapte à la taille de la fenêtre)
  • obtenir un coeur de type ‘I ♡’ (un joli coeur)

Pour le moment, on utilise exactement dix neuf points pour dessiner le ♡, à l’aide d’un point de départ accompagné de 6 courbes de Bezier prenant chacune 3 points.

Nous conviendrons d’utiliser la notation classique ‘(X, Y)’ avec ‘X’ la valeur sur l’axe des abscisses (horizontal) et ‘Y’ la valeur sur l’axe des ordonnées (vertical). À savoir, l’origine du repère, ‘O’, en informatique est en haut à gauche. Par conséquent, les valeurs en ‘Y’ croissent vers le bas (le contraire de ce qu’on voit jusqu’au lycée, cf. référence mathématiques).

À partir du code figurant en point 7, nous avons donc le tableau de coordonnées suivantes :

Points X Y
P0 75 40
P1 75 37
P2 70 25
P3 50 25
P4 20 25
P5 20 62.5
P6 20 62.5
P7 20 80
P8 40 102
P9 75 120
P10 110 102
P11 130 80
P12 130 62.5
P13 130 62.5
P14 130 25
P15 100 25
P16 85 25
P17 75 37
P18 75 40

Maintenant, nous allons nous atteler à rendre les coordonnées adaptable à n’importe quelles dimensions de la zone d’affichage. Le secret est de prendre un papier, un crayon, de dessiner des coeurs partout, de prendre le plus joli et c’est partie.

Les coordonnées avant

En vrai, cette feuille de papier millimétré va nous permettre de mettre en évidence les doublons afin de factoriser le code.

Afin de calculer des coordonnées dynamique, nous allons nous baser sur une valeur de référence mSize. Ce sera la largeur de l’écran en mode portait, la longueur en mode paysage ou bien la taille de l’écran de la montre connectée.

  1. Il s’agit de déclarer un nouvel attribut de classe mSize de type Int, puis de récupérer les dimensions dans l’implémentation existante de la fonction d’héritage onLayout() :

     private var mSize = 0
     override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
         super.onLayout(changed, left, top, right, bottom)
         if (width < height)
           mSize = width
         else
           mSize = height
         createGraphicalObject()
     }
    
  2. Ensuite, nous allons factoriser le code du point 7, avec deux tableaux. L’un contiendra les coordonnées en ‘X’ et l’autre en ‘Y’ :

     var mX: FloatArray = floatArrayOf(75f, 70f, 50f, 20f, 40f, 110f, 130f, 100f, 85f)
     var mY: FloatArray = floatArrayOf(40f, 37f, 25f, 62.5f, 80f, 102f, 120f)
    

    Les doublons sont enlevés, c’est pourquoi on a deux tableaux de tailles différentes. Enfin, il y a 9 abscisses et 7 ordonnées nécessaires à l’obtention de notre coeur interpollé via les courbes de Beziers.

  3. La fonction createHeart() devient alors :

     fun createHeart(): Path {
       val path = Path()
       path.moveTo(mX[0], mY[0])
       path.cubicTo(mX[0], mY[1], mX[1], mY[2], mX[2], mY[2])
       path.cubicTo(mX[3], mY[2], mX[3], mY[3], mX[3], mY[3])
       path.cubicTo(mX[3], mY[4], mX[4], mY[5], mX[0], mY[6]);
       path.cubicTo(mX[5], mY[5], mX[6], mY[4], mX[6], mY[3])
       path.cubicTo(mX[6], mY[3], mX[6], mY[2], mX[7], mY[2])
       path.cubicTo(mX[8], mY[2], mX[0], mY[1], mX[0], mY[0])
       return path
     }
    
  4. Puis, afin que la taille du coeur s’adapte à la taille de l’écran. Nous utiliserons la fonction suivante :

     fun calculateHeartCoordinates() {
         for (i in mX.indices) {
             mX[i] = (mSize * mX[i]) / 150
         }
         for (i in mY.indices)
             mY[i] = (mY[i] * mSize) / 150
     }
    

    Elle multiplie toutes les coordonnées par un ratio basé sur mSize. Nous l’appellerons dans la fonction onLayout(), juste avant d’appeler la méthode createGraphicalObject().

     override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
       super.onLayout(changed, left, top, right, bottom)
       if (width < height)
         mSize = width
       else
         mSize = height
       calculateHeartCoordinates()
       createGraphicalObject()
     }
    
  5. Après quelques ajustements sur les coordonnées en ‘X’ et ‘Y’, afin d’obtenir un coeur de type ‘I ♡’ :

     var mX = floatArrayOf(75f, 60f, 40f, 5f, 110f, 145f, 90f)
     var mY = floatArrayOf(22f, 20f, 5f, 40f, 80f, 102f, 135f)
    
     private fun createHeart() {
       path = Path()
       path.moveTo(mX[0], mY[0])
       path.cubicTo(mX[0], mY[1], mX[1], mY[2], mX[2], mY[2])
       path.cubicTo(mX[3], mY[2], mX[3], mY[3], mX[3], mY[3])
       path.cubicTo(mX[3], mY[4], mX[2], mY[5], mX[0], mY[6])
       path.cubicTo(mX[4], mY[5], mX[5], mY[4], mX[5], mY[3])
       path.cubicTo(mX[5], mY[3], mX[5], mY[2], mX[4], mY[2])
       path.cubicTo(mX[6], mY[2], mX[0], mY[1], mX[0], mY[0])
     }
    

Afin d’avoir un aperçu du résultat final, n’hésitez pas à jeter un coup d’oeil à l’application jeu I Love, disponible en test ouvert sur le Play Store.

Pour résumer, la partie la plus complexe est le refactoring en points 2 et 3 de la deuxième partie de ce tutoriel. La fameuse feuille de papier millimétré ainsi que le tableau de coordonnées ont été une boussole dans cet océan de points. Finalement, le résultat est pas mal, cela dit, le code peut encore être refactorisé par rapport aux symétries mises en évidence sur la feuille de papier. L’utilisation des deux tableaux de coordonnées en point 2 permet de tester facilement différentes géométries pour la forme du coeur. Il est donc intéressant de le factoriser à fond : x6=x1+30, x4=x2+70, x5=x3+140.

Par ailleurs, ce projet a donné naissance à une nouvelle application de type jeu : I Love. Cela a été l’occasion de mettre en pratique le langage Kotlin. Une série d’articles est en cours (cf. [AK n]), ils abordent les points techniques utilisés tout au long du développement de l’application I ♡.

Références

  1. developer.mozilla : Canvas - Drawing shapes
  2. Another code frawing heart in canvas
  3. Wiki : Coordonnées cartésiennes
  4. Le coeur de Kathy
  5. The Coeur trop beautiful
  6. Wiki : Coordonnées cartésiennes
  7. Kotlin Array

Partagez ou réagissez sur Twitter.

Vous avez trouvé une erreur ou voulez améliorer cet article ? Editez le directement !

Comments