Un écran pour toutes les tailles de police en Flutter

Lorsqu’il s’agit de rendre nos applications accessibles, on peut voir le verre à moitié plein ou le verre à moitié vide. D’un côté, on ne sait pas par où commencer et on peut prendre peur devant la multitude des tâches à accomplir. De l’autre, on peut se réjouir de la quantité d’opportunités, et de sujets différents qui s’offrent à nous.
Pour vous aider à basculer du côté du verre à moitié plein, je vous propose de regarder un exemple concret et directement applicable sur la plupart des applications mobiles : rendre vos pages d’empty state compatibles et agréables à une utilisation par des utilisateurs augmentant leur taille de police.
Cet exemple fonctionne également avec les pages d’erreur, ou toute autre page comportant principalement une illustration et du texte.

Ce qu’on recherche d’un point de vue utilisateur

Commençons par définir ce qu’est notre page d’empty state : c’est la page que va rencontrer notre utilisateur lorsqu’il veut consulter une liste d’items, mais que dans son cas, il n’en a aucun. Dans la suite de cet article, notre utilisateur veut voir les documents contenus dans un dossier alors qu’il est vide.

_Page vide en taille de police normal_e

Sur cet écran, nous avons une image qui est simplement là pour habiller l’écran mais qui n’apporte pas d’information, un titre, un sous titre et un bouton d’ajout. Quand l’utilisateur arrive sur cet écran, l’important sera de garder le contenu écrit, mais sans l’image, ce qui nous donnera :

Écran en police grossie x2 (contenu scrollable)

Mais entre cette taille nécessitant de rendre l’écran scrollable, même sans l’image, et le cas nominal, nous avons toute une multitude de tailles de police. Il va donc nous falloir décider de “breakpoints”, c'est-à-dire de certaines tailles minimales en dessous desquelles on passe au format d’écran suivant. Ici nous allons appliquer les règles suivantes :

  • L’image a une taille maximale de 160 pixels et une taille minimale de 64 pixels

  • On affiche l’image seulement si cela ne nécessite pas de rendre l’écran scrollable

  • On ne touche pas aux marges entre les éléments

  • On a un minimum de marge de 32 pixels en haut et en bas de l’écran

  • L’image réduit en fonction de l’espace disponible en respectant les règles précédentes

Ces règles donnent alors différentes possibilités dont les 2 exemples suivants :

Écran avec image réduite

Écran avec sans image mais pas encore scrollable

Implémentation en Flutter

Maintenant que l’on sait ce qu’on veut atteindre d’un point de vue utilisateur, lançons nous dans l’implémentation en Flutter, pas à pas, en regardant pour les 4 tailles de police du dessus ce que cela donne à chaque étape. Pour ça nous allons essayer de commencer par le plus simple, et de finir par le plus compliqué. C’est parti !

1 - Les petites tailles de police

Commençons par créer l’écran en pensant seulement aux plus petites tailles de police, avec lesquelles on aura donc l’image en taille maximale avec des marges en haut et en bas de l’écran :

class PiecesAdministrativesScreen extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _EmptyPage(),
      appBar: const MyAppBar(title: 'Dossier Octobre 2023'),
    );
  }
}

Puis isolons notre body (hors AppBar) dans un Widget _EmptyPage. Cela nous servira à éviter la duplication des lignes qui ne seront plus modifiées dans la suite de cet article.

class _EmptyPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          mainAxisSize: MainAxisSize.max,
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            SvgPicture.asset(
              'my_illustration.svg',
              height: 160,
              width: 160,
              excludeFromSemantics: true,
            ),
            const SizedBox(height: 16),
            const Text(
              'J\'ajoute des documents',
              textAlign: TextAlign.center,
              style: MyTextStyle.text24_w400_normal_title,
            ),
            const SizedBox(height: 16),
            const Text(
              'Je peux ajouter depuis ma gallerie, les documents de mon téléphone, mon drive ou directement en prennant une photo',
              textAlign: TextAlign.center,
              style: MyTextStyle.text16_w400_normal_body,
            ),
            const SizedBox(height: 24),
            MyButton(
              label: 'Ajouter un document',
              onTap: () {
                // ...
              },
            ),
          ],
        ),
      ),
    );
  }
}

Voici le résultat pour nos 4 tailles de police :

2 - Un petit refacto pour la lisibilité de l'article

Comme nous avons pris la décision plus haut de ne pas modifier les écarts entre les différents éléments, et que dans tous les cas, les textes et le bouton seront affichés, je vous propose d’extraire la suite de notre travail dans un sous widget, que nous appellerons DisappearingIllustrationPage :

class _EmptyPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return DisappearingIllustrationPage(
      asset: 'my_illustration.svg',
      children: [
        const Text(
          'J\'ajoute des documents',
          textAlign: TextAlign.center,
          style: MyTextStyle.text24_w400_normal_title,
        ),
        const SizedBox(height: 16),
        const Text(
          'Je peux ajouter depuis ma gallerie, les documents de mon téléphone, mon drive ou directement en prennant une photo',
          textAlign: TextAlign.center,
          style: MyTextStyle.text16_w400_normal_body,
        ),
        const SizedBox(height: 24),
        MyButton(
          label: 'Ajouter un document',
          onTap: () {
            // ...
          },
        ),
      ],
    );
  }
}

Et le premier contenu de notre nouveau widget :

class DisappearingIllustrationPage extends StatelessWidget {

  final String asset;
  final List<Widget> children;
  final double horizontalPadding;

  const DisappearingIllustrationPage({
    required this.asset,
    required this.children,
    this.horizontalPadding = 24,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 32),
      child: SafeArea(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          mainAxisSize: MainAxisSize.max,
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            SvgPicture.asset(
              asset,
              height: 160,
              width: 160,
              excludeFromSemantics: true,
            ),
            const SizedBox(height: 16),
            ...children,
          ],
        ),
      ),
    );
  }
}

À partir de maintenant, nous ne toucherons donc plus qu’au Widget DisappearingIllustrationPage afin d'illustrer cet article.

3 - Ajout du scroll (sans casser les écrans non scrollables)

La première étape consiste à  rendre scrollable les écrans qui ont de l’overflow, sans que cela impacte les autres.

  • Ajoutons tout d’abord une SingleChildScrollView autour de notre composant à la racine, c'est-à-dire le premier Padding.

  • Spécifions ensuite à notre SingleChildScrollView que son enfant doit avoir une taille minimale, égale à la taille de notre body:

    • Entourons notre SingleChildScrollView d’un LayoutBuilder pour connaitre cette taille.

Mettons une ConstrainedBox appliquant une taille minimale en premier enfant de notre SingleChildScrollView

class DisappearingIllustrationPage extends StatelessWidget {
  final String asset;
  final List<Widget> children;
  final double horizontalPadding;

  const DisappearingIllustrationPage({
    required this.asset,
    required this.children,
    this.horizontalPadding = 24,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        return SingleChildScrollView(
          child: ConstrainedBox(
            constraints: BoxConstraints(minHeight: constraints.maxHeight),
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
              child: SafeArea(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  mainAxisSize: MainAxisSize.max,
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    SvgPicture.asset(
                      asset,
                      height: 160,
                      width: 160,
                      excludeFromSemantics: true,
                    ),
                    const SizedBox(height: 16),
                    ...children,
                  ],
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

Ce qui nous donne maintenant sur nos 4 tailles de police :

Il n’y a ici aucun changement par rapport à l’étape précédente, si ce n’est la disparition de l’overflow au profit d’un écran qui est maintenant scrollable pour les deux plus grandes tailles de police.

4 - Ajustement de la taille de l’image en fonction de la place disponible

Il va nous falloir créer un Widget dont le rôle est de détecter quelle taille doit faire notre image (0 dans le cas où l'écran est scrollable, ou une valeur comprise entre 64 et 160).

Dans ce but, introduisons tout de suite le widget IntrinsicHeight qui fixe la taille de son enfant en fonction de la taille intrinsèque des widget plus bas dans l’arbre.

Nous allons donc placer notre IntrinsicHeight autour de notre Column, pour essayer de déterminer la taille de celle-ci.
Notre Column contient trois éléments, dont les childrens et une SizedBox qui ont une taille fixe et qui ne nous intéressent donc pas. Il ne reste alors que notre image à faire réduire.

En premier lieu, entourons notre image par un Flexible pour lui donner la possibilité d’avoir une taille variable en fonction de l’espace disponible.

Et enfin, dernière étape, mais aussi étape la moins intuitive (ou la plus complexe, au choix) nous allons devoir créer un Widget qui remplit les conditions suivantes :

  • Avoir une taille intrinsèque de 0 pour ne pas augmenter la taille de notre Column si elle est déjà supérieure à la taille du body

  • Autoriser une taille comprise entre 65 et 160 pixels

  • Afficher notre image dans le cas où sa taille n’est pas 0 pixels

Vous suivez toujours ?

Alors introduisons deux nouvelles classes : SingleChildRenderObjectWidget et  RenderProxyBox.
 Tout d’abord, pour remplir les conditions du dessus, notre widget va étendre SingleChildRenderObjectWidget qui permet d’entourer un widget (notre image ici) à l’intérieur du scope d’un RenderObject (un objet de l’arbre de rendu) que nous pourrons définir par la suite.

Notre RenderObject en question sera ici une RenderProxyBox (une extension de RenderObject servant à gérer les tailles de ses enfants) car nous ne cherchons à influer que sur la taille de notre enfant. Et c’est à l’intérieur de cette RenderProxyBox que nous allons pouvoir appliquer les conditions du dessus.

Toujours abstrait ? Voyons ce que donne l’implémentation :

class DisappearingIllustrationPage extends StatelessWidget {
  final String asset;
  final List<Widget> children;
  final double horizontalPadding;

  const DisappearingIllustrationPage({
    required this.asset,
    required this.children,
    this.horizontalPadding = 24,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        return SingleChildScrollView(
          child: ConstrainedBox(
            constraints: BoxConstraints(minHeight: constraints.maxHeight),
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
              child: SafeArea(
                child: IntrinsicHeight(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    mainAxisSize: MainAxisSize.max,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Flexible(
                        child: MyIllustration(asset),
                      ),
                      const SizedBox(height: 16),
                      ...children,
                    ],
                  ),
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

Notre classe DisappearingIllustrationPage n’a pas beaucoup changé, on voit juste l’apparition d’un IntrinsicHeight et de MyIllustration qui est la classe qui va contenir notre image.

class MyIllustration extends StatelessWidget {
  final String asset;
  static const _MIN_SIZE = 64.0;
  static const _MAX_SIZE = 160.0;

  const MyIllustration(this.asset);

  @override
  Widget build(BuildContext context) {
    return MinMaxHeightContainer(
      minHeight: _MIN_SIZE,
      maxHeight: _MAX_SIZE,
      child: SvgPicture.asset(
        asset,
        height: _MAX_SIZE,
        width: _MAX_SIZE,
        excludeFromSemantics: true,
      ),
    );
  }
}

Notre nouvelle classe MyIllustration entoure notre image et applique les règles de hauteur minimale et maximale au travers de la classe MinMaxHeightContainer qui étend la classe SingleChildRenderObjectWidget et qui s’appuie sur la classe MinMaxHeightRenderContainer qui sera notre RenderProxyBox.

class MinMaxHeightContainer extends SingleChildRenderObjectWidget {
  final double minHeight;
  final double maxHeight;

  const MinMaxHeightContainer({super.key, super.child, required this.minHeight, required this.maxHeight});

  @override
  RenderObject createRenderObject(BuildContext context) {
    return MinMaxHeightRenderContainer(minHeight: minHeight, maxHeight: maxHeight);
  }
}

Enfin, pour la classe MinMaxHeightRenderContainer , rien de bien sorcier, on lui transmet simplement les hauteurs minimale et maximale.

class MinMaxHeightRenderContainer extends RenderProxyBox {
  final double minHeight;
  final double maxHeight;

  MinMaxHeightRenderContainer({required this.minHeight, required this.maxHeight}) : super();

  @override
  double computeMinIntrinsicHeight(double width) => 0; // Taille intrinsèque nulle

  @override
  double computeMaxIntrinsicHeight(double width) => 0; // Taille intrinsèque nulle

  @override
  Size computeDryLayout(BoxConstraints constraints) {
    final double height;
    if (constraints.maxHeight >= maxHeight) {
      height = maxHeight;
    } else if (constraints.maxHeight > minHeight) {
      height = constraints.maxHeight;
    } else {
      height = 0;
    }
    return Size(constraints.maxWidth, height);
  } // On applique les règle sur la taille vues plus haut

  @override
  void performLayout() {
    size = computeDryLayout(constraints);
    if (child != null && size.height > 0) {
      child!.layout(BoxConstraints.tight(size));
    }
  } // On ne fait le layout que dans le cas ou la size n'est pas nulle

  @override
  void paint(PaintingContext context, Offset offset) {
    if (size.height > 0) {
      super.paint(context, offset);
    }
  } // On ne fait le paint que dans le cas ou la size n'est pas nulle
}

Et voilà ! Vous n’avez plus qu’à savourer le résultat dans la taille de police que vous voulez !

Pour résumer

Même si elle n’est jamais la fonctionnalité principale de votre application, l’accessibilité est aujourd’hui un pré requis avant de publier sur les stores. Pour transformer cette contrainte réglementaire en opportunité, je résumerais cet article en ces trois points :

  • Il est important de commencer par réfléchir avec les designers en amont de l’implémentation, et dans l’idéal de se mettre d’accord sur des maquettes. La solution n’est jamais que technique.

  • Flutter est pensé pour fonctionner avec une seule base de code pour toutes les tailles d’écrans (et de police !). Avec un peu de patience, on arrive toujours à faire rentrer nos contraintes dans un seul Widget.

  • L’accessibilité est une super occasion pour apprendre des choses nouvelles et améliorer son expertise et sa connaissance du framework.