Shape ton Widget comme si c’était un SVG

le 17/11/2023 par Rémi Dormoy
Tags: Software Engineering

Dans la majorité des cas, en tant que développeur front, que ce soit sur mobile ou sur du web, nous avons à dessiner des formes classiques. Des rectangles, des cercles, des ovales ou des rectangles avec des bords arrondis. Et les frameworks que nous utilisons nous permettent donc très facilement ces formes. Et si demain, notre designer nous demande de faire une carte de la forme de la France ? Ou un bouton en forme de cheval ? Certes, ça n’a que peu de chances de pouvoir vous arriver, mais ce serait dommage d’avoir à dire que vous ne savez pas faire. Et puis, où est le fun dans les carrés et les ronds ?

Comprendre comment fonctionnent les SVG

Si vous ne vous sentez pas l’âme d’un artiste, rassurez vous, aucune créativité n’est nécessaire pour la suite de cet article. Nous allons simplement utiliser des images au format svg, et les transformer en Widget.

Le format svg est un langage, basé sur le XML, qui permet de dessiner des images vectorielles. Bien sûr, les possibilités offertes par le format svg dépassent largement le cadre de ce que l’on va utiliser dans cet article, où nous allons simplement nous concentrer sur son élément central : les paths. Si vous souhaitez aller plus loin, ou plus en détail, sur le fonctionnement des svg, je vous conseille la documentation de mozilla, très complète et très bien faite.

Dans la suite de cet article, nous allons utiliser trois fichiers svg différents : une carte de la France Image de la franceun cheval

Image de chevalun fromage Image de fromage

La carte de France

Commençons par regarder le contenu du svg de la carte de france, qui est le plus simple des trois malgré les apparences :

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
  xmlns:svg="http://www.w3.org/2000/svg"
  xmlns="http://www.w3.org/2000/svg"
  version="1.0"
  width="507"
  height="520"
  id="svg2">
 <defs
    id="defs4" />
 <path id="France-outline" fill="#86aae0"
    d="M 204.3125,99.211183 L 198.65625,98.054933 L 194.75,95.117433 L 195.71875,89.648683 ...[~15000 charactères]... L 203.40625,99.367433"/>
</svg>

Dans ce fichier, seules deux informations nous intéressent :

  • la taille : 507 de largeur et 520 de hauteur
  • L’attribut path, et notamment le fait qu’il n’y ait qu’une seule balise

Regardons de plus près cet attribut path. Il est composé d’une suite de lettres et de nombres. C’est une succession d’informations qui nous servent à créer une ligne qui se refermera sur elle-même pour former notre carte de France. Les lettres correspondent à un type d’instruction et les nombres à des coordonnées.

  • M est la commande Move To. Les nombres suivants seront donc 2, l’abscisse et l’ordonnée.

    • M 204.3125,99.211183 => déplaçons le curseur jusqu’au point x=204.3125 et y=99.211183
    • Lorsque l’on fait un Move To on se déplace mais on ne dessine pas. Le mouvement du curseur ne traçera pas de ligne et ne sera donc pas pris en compte dans notre shape finale.
  • L est la commande Line To. Les nombres suivants seront également 2, l’abscisse et l’ordonnée.

    • L 198.65625,98.054933 => faisons une ligne depuis le curseur actuel jusqu’au point x=198.65625 et y=98.054933
    • Cette fois-ci, le déplacement sera bien pris en compte dans notre shape finale.

Notre SVG de la carte de France est donc simplement une succession de lignes d’un point à un autre, répété un peu plus de 700 fois.

Le cheval

Regardons maintenant le contenu du svg de l’image de cheval :

<?xml version="1.0" standalone="no"?>
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
 width="1280.000000pt" height="957.000000pt" viewBox="0 0 1280.000000 957.000000"
 preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,957.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1678 9550 c-171 -32 -454 -124 -716 -235 -71 -30 -150 -59 -175 -65
-29 -8 -52 -22 -66 -40 -11 -16 -55 -54 -96 -86 -42 -31 -108 -93 -148 -138
-39 -45 -76 -82 -83 -84 -7 -2 -60 29 -118 69 -80 54 -110 69 -118 61 -15 -15
...[~6000 characteres]...
m10550 -7296 c81 -40 60 -72 -29
-43 -49 17 -72 39 -58 56 16 18 28 17 87 -13z"/>
</g>
</svg>

On peut voir que la structure est identique, mais on y voit apparaître quelques nouveautés :

  • Une transformation appliquant une translation et un scale
  • Deux nouvelles instructions c et m (c-171 -32 -454 -124 -716 -235 et m10550 -7296)

Nous regarderons la transformation quand nous passerons à l’implémentation en Flutter mais pour le moment ignorons là. Concentrons nous sur ces deux instructions :

  • m est la commande Relative Move To. Comme la commande M, elle prend deux nombres en argument et déplace le curseur sans dessiner. Mais le fait qu’elle soit en minuscule veut dire que le mouvement est relatif. On ne va donc pas se déplacer à des coordonnées mais d’une certaine distance.

    • m10550 -7296 indique qu’il faut se déplacer de 10550 vers la droite et de 7296 vers le haut.
  • c est la commande Relative Cubic Bezier. Comme m elle est en minuscule et donc relative, et elle prend cette fois 6 arguments que l’on appellera dx1 dy1 x2 y2 et dx3 dy3

    • Nous allons dessiner une courbe de Bézier du curseur actuel vers une distance de dx3 dy3.
    • dx1 dy1 et dx2 dy2 sont les distances vers les deux points de contrôle servant à calculer la courbure

Graphique expliquant les différents dx et dy

  • Le fait qu’il n’y ait pas de nouvelle lettre après le premier c signifie qu’après les 6 premiers nombres correspondant à la première courbe de Bézier, nous devons démarrer une nouvelle courbe avec les 6 nombre suivants, et ainsi de suite, jusqu’à trouver une nouvelle lettre.

Le fromage

Passons enfin au dernier SVG, le fromage :

<?xml version="1.0" encoding="iso-8859-1"?>
<svg fill="#000000" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"  viewBox="0 0 32 32">
<path
 id="cheese_1_" d="M30,30.36H13c-0.199,0-0.36-0.161-0.36-0.36c0-1.318-0.921-2.351-2.097-2.351
c-1.213,0-2.201,1.055-2.201,2.351c0,0.199-0.161,0.36-0.36,0.36H2c-0.199,0-0.36-0.161-0.36-0.36v-4.829
...[~1600 characteres]...	
s0.89-0.399,0.89-0.89S23.491,10.61,23,10.61z"/>
</svg>

Deux nouveaux types d’instructions apparaissent :

  • v et H sont similaires au L vu plus haut. Ce sont aussi des Line To mais horizontal ou vertical et qui ne prennent donc qu’un seul argument

    • v-4.829 trace une droite de 4.829 vers le haut
    • H2 trace une droite horizontale vers l’abscisse 2
  • S et s sont des courbes de Béziers comme c mais qui prennent seulement 4 arguments car le premier point de contrôle (dx1 dy1 pour c) sera calculé à partir de la courbe de Bézier précédente. S et s doivent donc suivre une instruction C, c, S ou s.

L’implémentation en Flutter avec ClipPath

Dans la deuxième partie de cet article, nous allons implémenter ces trois fichiers à l’aide du Widget ClipPath, qui permet de donner une forme à ses enfants en suivant un path. Pour être précis, il ne dessinera ses enfants qu’à l’intérieur de ce path.
Il prend la forme suivante :

ClipPath(
  clipper: _FranceClipper(),
  child: Container(color: Colors.black87),
);

class _FranceClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    final path = Path();
    // ...
    // TODO construire le path
    // ...
    return path;
  }

  @override
  bool shouldReclip(covariant CustomClipper<Path> oldClipper) {
    return false;
  }
}

La carte de France

Maintenant que tout est en place, il est temps d’afficher notre premier Widget ! La carte de France étant la plus simple, il sera aussi notre premier. Pour cela nous allons utiliser deux méthodes de l’objet Path :

  • moveTo(x, y) : même principe que pour le svg (pratique pour nous !), on déplace le curseur, mais sans dessiner
  • lineTo(x, y) : même principe que pour le svg (la vie est décidément bien faite !), on dessine jusqu’aux coordonnées indiquées en arguments

Attention ici à un dernier détail. Les coordonnées indiquées dans le SVG correspondent à sa taille (507x520 dans le cas de la France) qui ne sont pas les mêmes que celles de notre Widget. Il faudra donc bien penser à les convertir. Notre SVG ayant plus de 700 Line To, nous allons également créer une méthode se chargeant de faire cette conversion pour nous.

 @override
  Path getClip(Size size) {
    final width = size.width;
  final height = size.height;
  final path = Path();   
  final void Function(double x, double y) L = (x, y) {
    path.lineTo((x / 507) * width, (y / 520) * height);
  };
  path.moveTo((204.3125 / 507) * width, (99.211183 / 520) * height);
    L(198.65625, 98.054933);
    L(198.65625, 98.054933);
   .... ~ 700 autres lignes similaires
  L(203.40625, 99.367433);
    return path;
  }

PS : il est évident qu’il y a très probablement une manière d’automatiser l’écriture de ces 700 lignes, en parsant le fichier svg puis en bouclant dessus, mais je n’ai pas eu le temps de le faire. Et bien que ce soit un petit peu rébarbatif d’écrire ces lignes, c’est aussi très simple, et avec un peu de sélection multiple dans Android Studio, ça va assez vite.

Capture d'écran d'un téléphone avec l'image de la FranceEt voilà le résultat !

Le cheval

L’objet Path ayant également à disposition les méthodes relativeLineTo et relativeCubicTo à disposition, nous pouvons reprendre pour le cheval le même principe que plus haut. Il faudra également appliquer les transformations inverses de celles décrites dans le SVG. Il faudra donc multiplier par 0.1 les ordonnées et par -0.1 les abscisses, et appliquer une translation de (0, -957) sur les coordonnées absolues. Ce qui nous donne :

class _ChevalClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    final width = size.width;
    final height = size.height;
    final path = Path();

    final void Function(double dx, double dy) l = (dx, dy) {
      path.relativeLineTo((dx / 1280) * width * 0.1, (dy / 957) * height * -0.1);
    };
    final void Function(double dx1, double dy1, double dx2, double dy2, double dx3, double dy3) c = (dx1, dy1, dx2, dy2, dx3, dy3) {
      path.relativeCubicTo(
        (dx1 / 1280) * width * 0.1,
        (dy1 / 957) * height * -0.1,
        (dx2 / 1280) * width * 0.1,
        (dy2 / 957) * height * -0.1,
        (dx3 / 1280) * width * 0.1,
        (dy3 / 957) * height * -0.1,
      );
    };
    final void Function(double x, double y) M = (x, y) {
      path.moveTo(
        (x / 1280) * width * 0.1,
        (y / 32) * height * -0.1,
      );
    };
    M(1678, 0);
    c(-171, -32, -454, -124, -716, -235);
    c(-71, -30, -150, -59, -175, -65);
    c(-29, -8, -52, -22, -66, -40);
    ....
  return path;
  }

  @override
  bool shouldReclip(covariant CustomClipper<Path> oldClipper) {
    return false;
  }
}

Capture d'écran avec une image de cheval

Et voilà le résultat !

Le fromage

Passons maintenant à notre dernière image : le fromage. Il sera un tout petit peu plus complexe que les autres car les instructions S et s sont dépendantes de l’instruction précédente pour pouvoir être calculées. Nous aurons besoin de calculer à chaque instruction la position actuelle du curseur, ainsi que de retenir quel était la dernière position du second point de contrôle pour les courbes de Bézier. Ce qui nous donne :

 @override
  Path getClip(Size size) {
    final width = size.width;
    final height = size.height;
    final path = Path();
    double lastDx2 = 0;
    double lastDy2 = 0;
    double currentX = 0;
    double currentY = 0;
    final void Function(double a, double b) l = (a, b) {
      currentX = currentX + a;
      currentY = currentY + b;
      path.relativeLineTo((a / 32) * width, (b / 32) * height);
    };
    final void Function(double dx1, double dy1, double dx2, double dy2, double dx3, double dy3) c = (dx1, dy1, dx2, dy2, dx3, dy3) {
      lastDx2 = dx3 - dx2;
      lastDy2 = dy3 - dy2;
      currentX = currentX + dx3;
      currentY = currentY + dy3;
      path.relativeCubicTo(
        (dx1 / 32) * width,
        (dy1 / 32) * height,
        (dx2 / 32) * width,
        (dy2 / 32) * height,
        (dx3 / 32) * width,
        (dy3 / 32) * height,
      );
    };
    final void Function(double dx2, double dy2, double dx3, double dy3) s = (dx2, dy2, dx3, dy3) {
      currentX = currentX + dx3;
      currentY = currentY + dy3;
      path.relativeCubicTo(
        (lastDx2 / 32) * width,
        (lastDy2 / 32) * height,
        (dx2 / 32) * width,
        (dy2 / 32) * height,
        (dx3 / 32) * width,
        (dy3 / 32) * height,
      );
      lastDx2 = dx3 - dx2;
      lastDy2 = dy3 - dy2;
    };
    final void Function(double x2, double y2, double x3, double y3) S = (x2, y2, x3, y3) {
      path.cubicTo(
        ((lastDx2 + currentX) / 32) * width,
        ((lastDy2 + currentY) / 32) * height,
        (x2 / 32) * width,
        (y2 / 32) * height,
        (x3 / 32) * width,
        (y3 / 32) * height,
      );
      lastDx2 = x3 - x2;
      lastDy2 = y3 - y2;
      currentX = x3;
      currentY = y3;
    };
    final void Function(double x, double y) M = (x, y) {
      currentX = x;
      currentY = y;
      path.moveTo(
        (x / 32) * width,
        (y / 32) * height,
      );
    };
  ...
  return path;
  }

Capture d'écran avec une image de fromageEt voilà le résultat !

Est-ce que c’est vraiment utile ?

Pour être tout à fait honnête je ne suis pas sûr que vous ayez l’occasion d’utiliser ça sur un “vrai” projet. Comme je l’ai dit au début de cet article, nous travaillons quasi exclusivement des formes simples, rectangulaires avec des angles plus ou moins arrondies. Malgré tout, je retiens plusieurs choses de cette expérience :

  • Dessiner un SVG et un Path en Flutter sont des processus très proches, ce qui peut ouvrir beaucoup de portes le jour où on veut implémenter un design sortant vraiment de l’ordinaire.
  • Flutter est un framework qui rend beaucoup de choses ayant l’air compliquées, assez simples. Et on trouve toujours une solution à notre problème en creusant un peu.
  • Si pour vos projets personnels vous cherchez une petite dose de fun, n’oubliez pas que nous avons créé des Widgets et pas des images, et que l’on peut mettre ce que l’on veut en enfant : une image, une scrollview, ou même un bouton avec Inkwell

Une photo croppée suivant la forme d’un fromage

Capture d'écran avec une photo en forme de fromage

Un élément scrollable dans une carte de France Capture d'écran animé montrant un composant scrollable avec la forme de la France

Un bouton en forme de cheval Capture d'écran d'un bouton en forme de cheval