Une application mobile responsive avec Flutter ?

Concevoir une application mobile ergonomique et qui a fière allure sur tous les appareils Android/Apple peut être une tâche difficile pour les développeurs. La principale raison, sur Android et dans une moindre mesure sur iOS, est la grande diversité des modèles de téléphones (et notamment de leurs écrans).

Sur les projets Front-end, une maquette du rendu de l’application est souvent fournie par des UX/UI designers. Cette pratique ne déroge pas aux applications mobiles. C’est alors aux développeurs d’implémenter ces designs en plus de la partie logique propre au métier.

En général, ces maquettes sont dépendantes du modèle préalablement choisi par le designer. Par exemple, ce dernier pourrait faire le choix de maquetter l’application sur un Iphone 14. Néanmoins, il existe pléthore de modèles différents par leur taille, le ratio d’aspect* ou encore le type d’écran.

Le développeur doit donc composer avec ces différences sans parler des performances qui diffèrent et s’assurer que d’un appareil à l’autre, l’expérience utilisateur ne soit pas dégradée. Il doit également ne pas limiter son champ d’action aux téléphones car les iPad et tablettes sont aussi des cibles privilégiées.

Idéalement, on voudrait que la maquette de notre application soit fidèlement respectée peu importe la résolution du device sur lequel sera lancée notre application. Cela permettra à nos utilisateurs d’avoir une expérience fluide recherchée par le design métier.

Je vous propose donc de voir un ensemble de pistes pour améliorer le responsive de vos applications en Flutter.

Responsive vs Adaptive

Dans un premier temps, clarifions ce que le terme “responsive” signifie qui est souvent confondu avec le terme “adaptive”. D’autant plus qu’en français, on le traduit par “adapté à la taille d’écran” dans les deux cas.

Source

En effet, un design se veut responsive lorsque ce dernier vient à faire en sorte que les composants s’affichent convenablement selon la taille du support sans changer leur ordonnancement.

Quant au design adaptive, il va amener un changement de comportement de certains composants pour améliorer l’expérience utilisateur. Prenons un exemple simple :

  • Sur mobile, nous pourrions avoir une page avec une liste d’éléments, qui, lorsqu'on clique sur l'un d'entre eux, ouvrirait une page “détails”.
  • Sur tablette, avec un design adaptive, il est recommandé d’afficher cette liste à la gauche du détail de l’item. Ceci est possible grâce à la taille beaucoup plus importante de la tablette. Et cela évite à l’utilisateur un clic supplémentaire, nécessaire côté mobile.

Tout cela étant dit, je vous propose de nous intéresser à la mise en œuvre d’un design responsive en Flutter.

Pixel logique VS pixel physique

Il est important de rappeler que Flutter nous donne la main sur chaque pixel dessiné à l’écran. Mais comme vous le savez, de grands pouvoirs impliquent de grandes responsabilités ! 🕷️

En Flutter, on parle de pixel logique. Néanmoins, à l'affichage, il s'agit bien de pixels physiques. Quelle est donc la différence entre ces deux dénominations ?

Le ratio entre les deux est appelé device pixel ratio et est spécifique à chaque appareil. Pour un pixel logique et un ratio de 2 sur un appareil donné, le système de rendu dessinera 2 pixels physiques : <pixel logique> * ratio = <pixel physique>

Ce système permet de fixer certaines dimensions, comme la marge ou le padding de nos éléments, sans trop se soucier du rendu qui s'adaptera grâce à ce "device pixel ratio".

centered image

Source

Néanmoins, ces pixels logiques ont leurs limites lorsque le ratio n’est pas un entier car il n’est pas possible de dessiner des fractions de pixels physiques sous le risque d’avoir un rendu flou.

Pour avoir un affichage “pixel-perfect”, à cause de ces limitations, il est recommandé d’utiliser la méthode d’extension pixelSnap() sur les valeurs numériques comme le padding, margin, tel que : width: 10.pixelSnap()

Cette méthode permet de faire en sorte que le pixel logique soit toujours entier afin d’éviter l’effet flou d’une fraction de pixel physique. La librairie PixelSnap donne même la possibilité d’avoir un wrapper des widgets les plus utilisés pour s’affranchir de l’utilisation de cette méthode.

Voici un exemple des conversions effectuées :

Les contraintes de Flutter

“Constraints go down, sizes go up, and the parent sets the position.”

Késako ?

Un widget tient ses propres contraintes de son parent. Les tailles sont demandées par le parent, qui les récupère en parcourant sa liste de widgets enfants. Ensuite, le widget parent place chacun de ses enfants horizontalement et verticalement.

Pour simplifier, si vous avez un widget enfant à l'intérieur d'un widget parent et que vous souhaitez décider de sa taille, elle doit respecter les contraintes définies par son parent.

La documentation donne des exemples très parlant à ce sujet dont voici 2 extraits :

Utiliser les composants Flutter

Il est bien connu qu'en programmation, il n'y a pas une seule et unique solution pour arriver à nos fins. Cela est particulièrement vrai lors de l'implémentation du design d'application. Pour obtenir un résultat identique, nous pouvons utiliser plusieurs composants, méthodes ou plugins.

Dans cet article, nous allons explorer différents cas d'utilisation pour faire notre choix en nous rappelant que, en Flutter, tout est widget !

Row & Column

La première approche est de bien structurer son arbre de rendu graphique composé de widgets.

Nous pouvons utiliser des widgets dit “containers”; c’est-à-dire des widgets contenant d’autres widgets telles des poupées russes. Parmi ces containers, nous pouvons citer les Row et Column pour structurer les différents éléments de nos pages (respectivement horizontalement et verticalement). Attention, il ne suffit pas d’utiliser des widgets containers pour rendre notre design responsive. Certains sont plus adaptés à cette problématique que d’autres.

Les Row et Column sont simplement les composants de bases pour structurer nos pages et un bon point de départ. Nous verrons par la suite, que leurs widgets enfants peuvent eux-mêmes s’adapter en fonction des dimensions disponibles.

Spacer

Commençons par le Spacer qui permet d’ajouter à des containers flexibles*, tels que les row et column vus précédemment, un espace ajustable. Ce widget, prendra donc l’espace disponible restant dans le container parent.

Exemple :

On pourrait donc préférer ce widget plutôt que d’utiliser des valeurs fixes pour espacer nos widgets. Si vous utilisez des valeurs fixes, pensez à les référencer dans un thème par exemple.

SizedBox(
    height: 100,
    width: 500,
    child: Row(
        children: <Widget>[
            Container(
                width: 50,
                color: Colors.red,
            ),
            const Spacer(),
            Container(
                width: 50,
                color: Colors.green,
            ),
            const Spacer(),
            Container(
                width: 50,
                color: Colors.blue,
            ),
            Container(
                width: 50,
                color: Colors.yellow,
            ),
        ],
    ),
);

*On appelle “containeur flexible”, un conteneur dont la taille s’adapte à son contenu par défaut.

Flex / Expanded

Le widget Expanded, comme son nom l’indique, permet à ses enfants de s’étendre en prenant la place disponible (dans l’axe principal) du widget parent.

Si son parent est une Row par exemple, l’axe principal étant l’axe horizontal, celui-ci s’étendra sur toute la longueur et vice versa avec une Column comme parent.


Si plusieurs éléments Expanded sont présents dans une même Row/Column, l’espace disponible sera réparti en fonction du flex factor utilisé. Pour un flex factor de 1 pour chaque enfant, l’espace sera uniformément réparti. Dans le cas contraire, on aura une répartition en fonction du flex factor de l’élément :

Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: <Widget>[
        Expanded(
            flex: 2,
            child: Container(
                alignment: Alignment.center,
                color: Colors.red,
                child: const Text("Flex = 2"),
            ),
        ),
        Expanded(
            flex: 3,
            child: Container(
                alignment: Alignment.center,
                color: Colors.blue,
                child: const Text("Flex = 3"),
        ),
        ),
        Expanded(
            flex: 1,
            child: Container(
                alignment: Alignment.center,
                color: Colors.green,
                child: const Text("Flex = 1"),
            ),
        ),
    ],
)

Expanded est un raccourci pour Flexible avec un paramètre fit: FlexFit.tight.

Ce paramètre indique au composant de s'étendre alors que le comportement par défaut de Flexible est de prendre l'espace nécessaire :

Container(
    width: 350,
    height: 250,
    color: Colors.blue,
    child: Column(
        children: <Widget>[
            Row(children: <Widget>[
                buildExpanded(),
                buildFlexible(),
            ],
        ),
    Row(children: <Widget>[
        buildExpanded(),
        buildExpanded(),
    ],
),
Row(children: <Widget>[
    buildFlexible(),
    buildFlexible(),
    ],),
    ],
),
);

OrientationBuilder

Ce widget permet de construire notre design en fonction de l’orientation de l’appareil. Ainsi, dès que l’orientation change, les éléments enfants sont redessinés. Néanmoins, si votre application n’a pas vocation à être utilisée avec les deux orientations (portrait et paysage), mieux vaudrait fixer l’orientation de votre application comme on le voit souvent dans les jeux vidéos par exemple. Cela vous évitera d’avoir à gérer chaque page selon ce paramètre.

OrientationBuilder(
builder: (context, orientation) {
    if (orientation == Orientation.portrait) {
        return Column(
            children: [
                Expanded(
                    child: Container(
                        color: Colors.red,
                    ),
                ),
                Expanded(
                    child: Container(
                        color: Colors.green,
                    ),
                ),
            ],
    );
    } else {
        return Row(
            children: [
                Expanded(
                    child: Container(
                        color: Colors.red,
                    ),
                ),
                Expanded(
                    child: Container(
                        color: Colors.green,
                    ),
                ),
            ],
        );
    }
},
);

LayoutBuilder

Ce widget “container” permet de récupérer les informations du widget parent et ainsi d’utiliser ces dernières pour dessiner les widgets enfants. Grâce aux min/max width/height, nous pourrons adapter le rendu du widget enfant.

Dans l’exemple ci-dessous, on se base sur la taille du widget parent (Container bleu) pour adapter la taille et la couleur du Container enfant.

Container(
    width: 290, // 500 dans le second cas
    height: 250,
    color: Colors.blue,
    child: LayoutBuilder(
        builder: (BuildContext ctx, BoxConstraints constraints) {
            if (constraints.maxWidth >= 480) {
                return buildContainer(
                    height: constraints.maxHeight * 0.3,
                    color: Colors.red,
                );
            // If screen size is < 480
            } else {
                return buildContainer(
                    height: constraints.maxHeight * 0.5,
                    color: Colors.green,
                );
            }
        }
    ),
);

MediaQuery

La classe MediaQuery fournit des informations sur l'environnement d'affichage, telles que la taille de l'écran, la densité de pixels et l'orientation de l'appareil. Vous pouvez utiliser ces informations pour rendre votre application Flutter responsive en adaptant la mise en page en fonction de ces informations.

Contrairement au Layout Builder, elle permet d’avoir les dimensions de l’écran en entier et non d’un widget en particulier. On peut l’utiliser pour déterminer la taille d’un élément si l’on souhaite que ce dernier prenne la moitié de l’écran par exemple.

⚠️
MediaQuery utilise le context de l’ensemble de l’écran alors que LayoutBuilder utilise celui d’un widget donné.

Place aux pourcentages

Nous pouvons également fixer la taille d’un élément en fonction de la longueur ou de la largeur de l’écran. Cette information est récupérable au runtime :

MediaQuery.of(context).size.height * .20 // 20% de la hauteur de l'écran
MediaQuery.of(context).size.width * .40 // 40% de la longueur de l'écran

Par exemple, pour une taille d’écran de 800 px de hauteur, afin d’avoir une box de 150px, elle devra prendre environ 18,5% de la taille de l’écran.

Beaucoup de cas de figure peuvent se baser sur un calcul de pourcentage simple. Cela dit, cette méthode atteint également ses limites.

Aspect Ratio

Rappelons que le ratio d’aspect est le rapport entre la hauteur et la largeur d’une zone prédéfinie (ici, le composant enfant de ce dernier).

Les plus connus sont les suivants :

Source

Ce widget nous permet donc de spécifier un ratio dit standard comme ceux présentés précédemment mais aussi personnalisés. Prenons par exemple une grille d’image censées présenter une image avec une largeur deux fois supérieure à sa hauteur. On pourrait aisément utiliser GridLayout.builder et lui attribuer la valeur suivante :

aspectRatio : 2 / 4; // On pourrait simplifier par 0.5

Si l’on se base sur un calcul plus dynamique de ce ratio, pouvant se baser sur la largeur totale de l’écran par exemple, le rendu pourra être fonction du device sur lequel il s’affiche et de la largeur de ce dernier. D’un device à l’autre, ce ratio permettra donc d’afficher autant d’images qui respecteront ce ratio.

Fitted box

Il s’agit d’un “single child layout widget”. Cela signifie qu’il ne peut contenir qu’un seul et unique enfant. Son but principal est de contraindre cet enfant à s’adapter à lui selon des contraintes définies grâce à l’attribut “fit” notamment. Il peut ainsi être ajusté en largeur ou en hauteur selon la boîte, la remplir complètement quitte à se déformer ou non. Voici quelques exemples :

Source

Contraindre notre design

police gif

const BoxConstraints(
    minWidth: 100.0,
    maxWidth: 250,
    minHeight: 100,
    maxHeight: 350
);

Avec le ConstrainedBox, on peut contraindre les dimensions d’un widget enfant. Pour cela, on doit lui fournir les contraintes souhaitées telle que :

En complément, les SizedBox et LimitedBox peuvent également constituer des alternatives pour contraindre la taille d’un widget enfant.

const SizedBox(
    width: 100.0,
    height: 100,
    child: Card(child: Text("Hello World")),
)

La SizedBox est la version “allégée” du Container. Elle n’exige qu’une hauteur, largeur et un widget enfant alors que le Container peut customiser des paramètres tels que la couleur, la decoration ou encore définir des contraintes.

Les mains en l’air, police !

Souvent, en pensant au design de notre application, nous oublions souvent l’impact du texte et de l’espace qu’il peut prendre. Ceux-ci peuvent prendre de la place et mal s’afficher d’un appareil à l’autre si l’on n’anticipe pas les différents cas de figure.

Cela dépend de la police choisie mais également de la taille, de la longueur du texte, de sa graisse*ou encore même de l’espacement des lettres.

Il est donc indispensable de pouvoir les ajuster ou les tronquer dans certains cas de figure.

  • Ajuster

    Il est possible de manuellement définir des “breakpoints” qui signifierait qu’à partir d’une certaine largeur ou hauteur d’écran ou de widget, nous décidons de changer la taille ou encore l’espacement d’un texte donné.

    Cela dit, cette opération peut vite être assez chronophage. Je vous propose donc d’utiliser un plugin permettant automatiquement d'ajuster votre texte en fonction du rendu voulu. Il s’agit d’AutoSizeText. Cette librairie permet entre autre, grâce à des contraintes personnalisées, de :

 → Réduire la taille du texte si un nombre de lignes maximum est fixé pour respecter cette contrainte

 → Changer le texte si l’espace est beaucoup trop réduit par exemple

 → Définir une taille minimum ou maximum ou encore prédéfinir une liste de tailles à respecter en fonction de l’espace disponible. Cette liste peut par exemple se caler sur le thème de notre application. Par exemple avec trois types de tailles (small, medium et large) qui auront respectivement les valeurs suivantes (12sp, 16sp et 24sp).

  • Tronquer

Le widget *Text (*tout comme AutoSizeText), peut être tronqué de différentes façons grâce à la propriété TextOverflow . Voici un exemple avec la propriété TextOverflow.ellipsis :

  • Les faire défiler : Une autre solution serait de permettre un défilement du texte si celui-ci est beaucoup trop long.

*Graisse de texte = épaisseur d'un trait ou d'un caractère


Nous avons ainsi vu que Flutter nous propose pléthore de solutions relatives à notre cas d’usage. Mais dans le monde merveilleux des développeurs, les problématiques les plus courantes sont souvent adressées par des librairies que l’on retrouve sur pub.dev dans le cas de Flutter. Je vous propose de vous en citer quelques-unes si vous souhaitez gagner du temps.

Make it easy

Certains plugins nous permettent de nous affranchir de cette réflexion et nous facilitent la vie. Nous pouvons citer par exemple :

Screen util

Il suffit de définir la taille de la frame de la maquette sur laquelle se base votre design et de se conformer au pixel près à cette maquette.

Il est également possible d’aller plus loin grâce aux différentes propriétés mises à disposition :

Sizer

Ce plugin, lui, nous permet notamment de nous affranchir du MediaQuery pour récupérer la hauteur ou la largeur de l’écran ou encore d’ajuster la taille de nos textes en sp*. Voici quelques exemples d’utilisation :

// Widget Size
Container(
    width: 20.w,    //It will take a 20% of screen width
    height:30.h     //It will take a 30% of screen height
)
// Padding
Padding(
    padding: EdgeInsets.symmetric(vertical: 5.h, horizontal: 3.h),
    child: Container(),
);
// Font Size
Text('Sizer',style: TextStyle(fontSize: 15.sp));

sp*:  Scaleable Pixels or scale-independent pixels - c'est comme l'unité dp, mais elle est également mise à l'échelle en fonction des préférences de taille de police de l'utilisateur.

De la même acabit que les deux précédemment cités, je peux également vous proposer ces librairies : responsive_framework (basée sur des breakpoints), responsive_sizer, responsive_ui, responisve_grid, et bien d’autres.

Comment je vérifie que mon affichage est correct d’un téléphone à l’autre ?

Cool ! Flutter nous propose des composants pour adapter nos designs mais aussi des librairies qui nous font gagner un temps précieux. Eh oui, le temps c’est de l’argent…

Mais comment s’assurer que notre design pourra être adapté à tous les modèles de téléphones sur lesquels il sera susceptible de s’afficher ?

Il est bien sûr inimaginable d’acheter tous les modèles de téléphone existants et de builder notre application sur chacun d’eux. Alors comment on fait ?

Tester ≠ douter

Partons du postulat que tester, ce n’est pas douter (je vous laisse débattre là-dessus) !

Grâce aux tests, nous pourrons nous assurer, et ce au pixel près du bon affichage de notre design. Je vous l’accorde, cela représente un certain coût en énergie et en temps.

Tester c'est douter

En fait, non ! Pas tant que ça, car si le test devient non plus une contrainte mais une habitude, alors ce temps sera pris en compte dans votre temps de développement. Et cela reste vrai pour toute la stratégie de mise en place d’un design responsive. Mais maintenant que tout ça est dit, concrètement, je teste comment mes pages avec Flutter ?

Il est recommandé de séparer son code en “morceau réutilisable”, les rendant ainsi facilement testables. Ensuite, grâce à ces tests, nous pouvons nous assurer qu’un composant est bien présent sur la page en fonction du contexte donné et on peut même aller plus loin grâce aux golden tests.

Ces golden tests permettent de s’assurer qu’un design n’aura pas bougé d’un pixel à chaque modification de code. Ils permettent de générer une capture d’écran du widget que l’on souhaite tester et comparent cette capture à chaque nouvelle capture réalisée au run de nos tests.

Pixel perfect



Cette librairie permet d’avoir par dessus notre écran, l’image représentative du design à implémenter. Elle nous donne l’occasion d’adapter nos widgets au rendu souhaité, et ce, au pixel près.

Ce procédé peut notamment être utile dans le cas où nous prenons partie d’utiliser une librairie telle que screen_util par exemple. En se fiant au frame de la maquette fournie, on s’assure que pour ces dimensions, notre design respecte la maquette. Quant au côté responsive, la librairie se chargera du reste comme on l’a vu précédemment.

Device preview

Grâce à ce plugin, nous pouvons facilement voir le rendu de notre application sur différents modèles prédéfinis ou en précisant des dimensions personnalisées.

Démo : https://flutter-device-preview.firebaseapp.com/#/

Parmi les fonctionnalités principales, nous pouvons :

  • Choisir un modèle spécifique parmi une liste donnée (Iphone, Android ou même des résolutions standards pour desktop)
  • Changer l’orientation du téléphone
  • Mettre en place une configuration personnalisée (langue, dark mode, text scaling factor, ...)
  • Ajuster la résolution
  • Faire une capture d’écran ou encore accéder à l’explorateur de fichiers…
  • Ajouter des plugins personnalisables

https://github.com/aloisdeniel/flutter_device_preview


Conclusion

En somme, Flutter nous permet grâce à de nombreux widgets de rendre nos applications responsives. Libre à vous de choisir celui correspondant le mieux à votre cas d’usage. Par ailleurs, dans un souci de gain de temps, des alternatives s’offrent à nous et nous facilitent la vie en nous affranchissant presque de toute la réflexion nécessaire.

Cependant, un design responsive ne suffira pas à fluidifier l’expérience utilisateur, d’autres leviers devront être actionnés comme l’accessibilité, un workflow utilisateur efficace, un design représentatif du besoin et j’en passe.

Je vous invite donc à lire l’article sur l’accessibilité appliquée au mobile d’Audrey qui constitue un bon début dans ce cas.

Pour plus de contenu sur Flutter, je vous invite à assister à la conférence FFDC qui a lieu le 12 Septembre prochain.

Sources :