Depuis 2 ans Flutter bouscule les codes du développement mobile. La technologie est annoncée comme l’une des grandes tendances de l’année 2020. J’ai donc décidé d’entreprendre une série d’articles intitulée Flutter Tips of the month afin de partager des retours d’expériences, des astuces qui pourront vous être utiles dans vos futurs projets.

Mehdi Slimani

Le BottomSheet

En Material Design le BottomSheet est un composant graphique permettant de remplacer certaines actions qu’on avait l’habitude de trouver dans des vues modales.

Celui-ci est interactif et propose notamment une fermeture par glissement, améliorant ainsi l’ergonomie des applications au sein desquelles il est mis en oeuvre.

Il est souvent utilisé pour proposer des actions résultantes d’une action principale, comme un sous-menu par exemple.

Je vous invite à consulter les bonnes pratiques sur les usages de ce composant sur la documentation Material Design.

Dans cet article, nous allons voir comment personnaliser la shape d’un BottomSheet grâce aux widgets PhysicalShape et CustomClipper.

Place au développement !

L’idée est la suivante : appliquer un masque sur le contenu du BottomSheet afin d’obtenir des coins arrondis mais aussi une courbure tout autour d’un FloatingActionButton placé en son centre.

Tout d’abord comment modifier la shape d’un widget ?

PhysicalShape

PhysicalShape est un widget qui permet d’appliquer un masque sur un autre widget. Nous l’utilisons constamment sans le savoir lorsque nous ajoutons des widgets comme FloatingActionButton, RaisedButton, Card à notre interface. Ils appliquent dans leur arborescence un autre widget de type Material qui lui même applique un widget PhysicalShape pour peaufiner son rendu.

Voici un exemple de rendu sans application d’un PhysicalShape à gauche contre application d’un PhysicalShape à droite. Constatez que l’ombre d’élévation s’applique autour de la Shape rectangulaire à gauche et autour de la zone avec masque à droite :

Différence sur l’application d’une élévation entre un ClipOval et un PhysicalShape

Pour les formes simples comme le cercle, il est possible d’utiliser directement le widget Material qui abstrait la combinaison entre un PhysicalShape et un CustomClipper :

Material(
  color: Colors.transparent,
  type: MaterialType.circle,
  elevation: 2,
  child: Container(
    width: 200,
    height: 200,
    padding: const EdgeInsets.all(24.0),
    color: Colors.lightBlue[900],
    alignment: Alignment.center,
  ),
)

Ce code fonctionne, mais il est très limité. Il permet uniquement de changer l’aspect global du widget grâce à la propriété type. Celle-ci de type MaterialType permet uniquement de personnaliser le widget avec les valeurs suivantes : circle, card, button, canvas. Dans notre cas de figure nous avons besoin d’un objet qui permette de découper des formes complexes, mais qui soit également interprétable par le PhysicalShape.

C’est là que CustomClipper rentre en jeu !

CustomClipper<Path>

CustomClipper permet de spécifier la zone à masquer. Il vous suffira de surcharger la méthode getClip(Size size) contenant les informations de la taille du widget cible. À partir de ces informations vous serez capable de construire un objet Path qui contiendra la définition de la shape avec l’ensemble de vos points, les courbures, les lignes etc… Pour cette partie tout dépendra du design.

Voici un exemple de code pour construire un objet Path fidèle au rendu final :

Path()
  ..moveTo(host.left, host.top + cornerRadius)
  ..quadraticBezierTo( host.left, host.top, host.left + cornerRadius, host.top)
  ..lineTo(p[0].dx, p[0].dy)
  ..quadraticBezierTo(p[1].dx, p[1].dy, p[2].dx, p[2].dy)
  ..arcToPoint(p[3], radius: Radius.circular(notchRadius), clockwise: false)
  ..quadraticBezierTo(p[4].dx, p[4].dy, p[5].dx, p[5].dy) 
  ..lineTo(host.right - cornerRadius, host.top)
  ..quadraticBezierTo( host.right, host.top, host.right, host.top + cornerRadius) 
  ..lineTo(host.right, host.bottom) 
  ..lineTo(host.left, host.bottom)
  ..close();

 

Ligne, déplacement, arc de cercle, courbe de Bézier libre à votre imagination !

Si vous ne comprenez pas le code, pas d’inquiètude ! L’ensemble du code sera disponible en fin d’article. Le but est de comprendre la démarche

À ce stade vous avez presque toutes les informations permettant de construire le BottomSheet hormis les informations de position et de taille du FloatingActionButton situées en bas de page.

Généralement un FloatingActionButton est positionné en bas de page grâce au widget racine Scaffold. Pour récupérer les différentes informations des objets qu’il manipule, nous allons utiliser la méthode statique Scaffold.geometryOf.

Elle retourne un objet ValueListenable<ScaffoldGeometry> qui contiendra les valeurs des différents widgets qu’il positionne, tel que le FloatingActionButton.

Pour récupérer les informations de position et de taille il faut utiliser le code suivant :

final geometryListenable = Scaffold.geometryOf(context);
final Rect fabArea = geometryListenable.value.floatingActionButtonArea;

Important : Scaffold.geometryOf(context) ne peut être appelé que dans un état de build. La méthode sera accessible uniquement dans les méthodes didChangeDependencies() et build() de votre StatefulWidget. En dehors de ce scope, le message d’erreur suivant sera retourné : « The ScaffoldGeometry is only available during the paint phase »

Comment implémenter PhysicalShape avec CustomClipper

Rien de plus simple, le widget PhysicalShape prend en paramètre un clipper de type CustomClipper<Path> :

return PhysicalShape(
  clipper: _BottomSheetClipper(
    scaffoldGeometry: _geometryListenable,
  ),
  child: content_of_bottom_sheet_child,
);

Puis l’implémentation de notre clipper _BottomSheetClipper est la suivante :

class _BottomSheetClipper extends CustomClipper<Path> {
  final ValueListenable<ScaffoldGeometry> scaffoldGeometry;

  _BottomSheetClipper({this.scaffoldGeometry});

  @override
  Path getClip(Size size) {
    Rect host = Offset.zero & size;
    final floatingActionButtonSize =
        scaffoldGeometry.value.floatingActionButtonArea.size;
    final floatingActionButtonOffset = Offset(
        size.width / 2 - floatingActionButtonSize.width / 2,
        -floatingActionButtonSize.height / 3);
    Rect guest = floatingActionButtonOffset & floatingActionButtonSize;
    final double notchRadius = guest.width / 1.5;
    const double s1 = 30.0;
    const double s2 = 1.0;
    final double r = notchRadius;
    final double a = (-1.0 * r - s2) * 1.1;
    final double b = host.top - guest.center.dy;
    final double n2 = math.sqrt(b * b * r * r * (a * a + b * b - r * r));
    final double p2xA = ((a * r * r) - n2) / (a * a + b * b);
    final double p2xB = ((a * r * r) + n2) / (a * a + b * b);
    final double p2yA = math.sqrt(r * r - p2xA * p2xA);
    final double p2yB = math.sqrt(r * r - p2xB * p2xB);
    final List<Offset> p = List<Offset>(6);
    p[0] = Offset(a - s1, b);
    p[1] = Offset(a, b);
    final double cmp = b < 0 ? -1.0 : 1.0;
    p[2] = cmp * p2yA > cmp * p2yB ? Offset(p2xA, p2yA) : Offset(p2xB, p2yB);
    p[3] = Offset(-1.0 * p[2].dx, p[2].dy);
    p[4] = Offset(-1.0 * p[1].dx, p[1].dy);
    p[5] = Offset(-1.0 * p[0].dx, p[0].dy);
    for (int i = 0; i < p.length; i += 1) p[i] += guest.center;
    final cornerRadius = notchRadius / 1.8;
    return Path()
      ..moveTo(host.left, host.top + cornerRadius)
      ..quadraticBezierTo(
          host.left, host.top, host.left + cornerRadius, host.top)
      ..lineTo(p[0].dx, p[0].dy)
      ..quadraticBezierTo(p[1].dx, p[1].dy, p[2].dx, p[2].dy)
      ..arcToPoint(p[3], radius: Radius.circular(notchRadius), clockwise: false)
      ..quadraticBezierTo(p[4].dx, p[4].dy, p[5].dx, p[5].dy)
      ..lineTo(host.right - cornerRadius, host.top)
      ..quadraticBezierTo(
          host.right, host.top, host.right, host.top + cornerRadius)
      ..lineTo(host.right, host.bottom)
      ..lineTo(host.left, host.bottom)
      ..close();
  }

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

 

Note: Le code est inspiré de CircularNotchedRectangle qui permet de modifier la shape d’une BottomAppBar autour d’un FloatingActionButton. Puis il a été modifié pour le besoin de l’article afin de l’adapter dans une BottomSheet.

À cette étape vous avez tous les éléments pour effectuer le rendu final. Je vous propose de visualiser l’ensemble du code et le rendu dans un snippet Dartpad qui est maintenant fonctionnel Flutter grâce à Flutter Web 😎(pour information le snippet est hébergé dans un gist).

Démonstration

Un mot pour la fin

J’espère que vous trouverez ce format intéressant et n’hésitez-pas à le partager sur les réseaux sociaux 😁💙.