Réaliser un effet de parallaxe avec React Native : à la découverte d’Animated
Du fait de la petite taille de nos écrans de téléphone, de nombreuses astuces ont été imaginées afin de maximiser la quantité d’informations affichées tout en garantissant à l’utilisateur une expérience optimale. Une de ces techniques, parfois appelée “effet parallaxe”, consiste à animer des éléments d’une liste défilante à une vitesse différente de celle du défilement afin par exemple de réduire la taille d’un entête au dessus d’une liste. Le terme de parallaxe provient du monde du jeu vidéo 2D qui a démocratisé ce genre d’effet en animant les décors lointains à une vitesse différente de celle du personnage afin de donner au joueur une impression de profondeur.
Dans la suite de cet article, nous allons voir comment réaliser cet effet dans une application React Native, ce qui va nous amener à étudier le fonctionnement interne de React Native. Le projet React Native ainsi que le code correspondant aux différentes étapes de cet article sont disponibles sur le repo Github : https://github.com/simontreny/react-native-parallax-example.
Le squelette de notre application
Commençons notre application par la création d’un écran simple constitué d’une liste défilante contenant un entête avec une image de fond et un texte suffisamment long afin de pouvoir faire défiler le contenu de l’écran :
Afin de ne pas alourdir inutilement le code de cet exemple, nous récupérons le texte à afficher depuis l’API loripsum.net à l’aide de la fonction fetch(). L’écran réalisé ressemble alors à l’image suivante :
L’approche React
La première étape afin de réaliser notre effet de parallaxe consiste à rendre fixe l’entête. Pour cela, il est nécessaire de compenser le déplacement de la liste défilante en déplaçant l’entête dans la direction opposée.
Dans une application React, l’approche habituelle pour faire cela consiste à écouter l’évènement de défilement et à déclencher un nouveau rendu de notre composant en modifiant son état à l’aide de la fonction setState(). Au sein de la méthode render(), nous calculerons alors la nouvelle position de l’entête en fonction du niveau de défilement contenu dans l’état :
Si nous relançons maintenant notre application avec ces modifications, voici ce que l’on observe (les résultats peuvent différer en fonction du modèle de téléphone) :
Le résultat n’est pas vraiment à la hauteur de nos espérances. Lorsque l’écran est immobile, l’entête est correctement positionné. Mais lors du défilement, le déplacement de l’entête semble être en retard ce qui conduit à ces sauts de position.
Que se passe-t-il vraiment ?
Deux raisons expliquent ce comportement :
- La première explication vient du fonctionnement de setState(). Pour des questions d’optimisation, setState() est asynchrone ce qui permet à React d’aggréger plusieurs setState() et de ne déclencher qu’un seul render(). Pour contourner ce problème, il serait possible d’utiliser la fonction setNativeProps() afin de mettre à jour la position de l’entête immédiatement sans avoir à redéclencher de rendu.
- La deuxième explication vient de l’architecture même de React Native. Une application React Native est composée de deux threads principaux : le thread natif, qui crée/met à jour les vues natives et transmet les évènements ; et le thread Javascript qui fait tourner la VM Javascript et exécute notre code. La communication entre ces deux threads se fait via le bridge React Native et est nécessairement asynchrone. C’est cet asynchronisme qui permet aux applications React Native d’offrir des performances proches de celles des applications natives : quelque soit le code Javascript exécuté, il est par exemple impossible de bloquer le défilement d’une liste défilante, garantissant ainsi un maximum de fluidité.
Si l’on étudie les communications entre ces deux threads lorsque l’utilisateur défile, on obtient le diagramme suivant :
À chacune des flèches de ce diagramme correspond une opération asynchrone. Plusieurs dizaines de millisecondes peuvent ainsi se dérouler entre le moment où l’utilisateur fait défiler l’écran et le moment où la position de nos vues est mise à jour. La seule solution à ce problème est donc de réagir à l’évènement de défilement directement dans le thread natif et de mettre à jour nos vues de manière synchrone.
Mais avant de lancer Xcode et Android Studio et de réécrire notre écran en Swift et en Kotlin, regardons du côté du module Animated de React Native qui répond exactement à cette problématique tout en nous permettant de continuer à écrire du Javascript.
Animated à la rescousse
La solution proposée par Animated consiste à décrire les opérations à réaliser lors de l’émission d’un évènement natif et à envoyer la liste de ces opérations au thread natif lors du premier rendu. Ces opérations seront alors exécutées de manière synchrone par le thread natif.
Animated propose un nombre restreint d’opérations pouvant être réalisées :
- Extraction d’une valeur depuis un évènement: Animated.event()
- Mise à jour d’une props d’une vue depuis une valeur animée : Animated.View, Animated.Image, Animated.Text, etc..
- Opérations arithmétiques : Animated.add(), Animated.subtract(), Animated.multiply(), Animated.divide(), Animated.modulo()
- Interpolation linéaire : Animated.interpolate()
- Et quelques autres
Il existe cependant des restrictions fortes liées à l’usage d’Animated sur le thread natif : seules les props opacity
et transform
d’une vue peuvent être mises à jour avec cette technique. Il n’est également pas possible d’extraire les valeurs de certains type d’évènements (les évènements de gestures par exemple).
Animated en pratique
Nous avons besoin d’apporter très peu de modifications à notre code existant pour le migrer vers Animated :
À noter, l’usage de useNativeDriver: true
dans Animated.event(). Sans ce paramètre, les opérations Animated ne seront pas envoyées au thread natif et s’exécuteront sur le thread Javascript, ce qui ne sera au final d’aucune aide dans notre cas. useNativeDriver: false
est cependant utile pour des animations où la fluidité et la latence n’est pas critique, car cette solution permet d’animer d’autres propriétés qu’opacity
et transform
. Elle est également plus performante que l’animation à base de setState() car elle utilise en interne setNativeProps().
Le résultat obtenu correspond maintenant à ce que nous espérions : l’entête reste fixe en haut de l’écran, sans saut de position.
Place au fun
Si vous êtes arrivés à ce niveau de l’article, vous êtes probablement déçu par notre “animation” : on aurait finalement pu obtenir le même résultat sans l’aide d’Animated, simplement en sortant l’entête de la liste défilante. Mais nous possédons maintenant la base nécessaire pour ajouter très simplement des effets à notre entête, en calculant des changements d’opacité et des déplacements de vues à partir de la valeur animée scrollOffset
.
C’est ce que nous allons faire, en effectuant les animations suivantes :
- Réduction de la hauteur de l’entête en le déplaçant vers le haut au début du défilement
- Déplacement de l’image de fond vers le bas à une vitesse deux fois inférieure
- Apparition d’une vue noire semi-transparente qui va venir recouvrir l’entête
- Apparition d’un titre dans l’entête en le déplaçant du bas vers le haut lorsque le titre du contenu est caché par le défilement
Toutes ces animations sont obtenues simplement en interpolant la valeur scrollOffset
sur différents intervalles :
Après avoir relancé notre application, nous obtenons alors le résultat suivant :
En très peu de lignes de code, nous sommes parvenus à une animation assez proche de celle de l’application Marmiton. Cette animation permet d’agrandir l’espace réservé au contenu tout en apportant un côté ludique à notre application. En animant uniquement l’opacité et les transformations des vues, nous assurons également une excellente fluidité à notre animation.
Pour aller plus loin
Nous avons vu à travers cet article comment animer des vues selon le niveau de défilement. Cette technique peut être réutilisée pour réaliser d’autres effets, comme l’animation d’élements graphiques dans un écran d’onboarding ou l’ajout d’un effet de profondeur sur des images intégrées au sein d’un article texte.
Certaines contraintes d’Animated en font cependant une solution limitée à certains cas d’usage. La plupart de ces limitations, comme l’extraction de valeurs animées depuis une gesture ou l’animation fluide des propriétés de largeur ou de hauteur d’une vue, peuvent être contournées par l’utilisation de bibliothèques externes telles que react-native-reanimated au prix d’une mise en œuvre plus compliquée.
Merci ! 👨💻
Vous avez aimé cet article ? Cliquez sur 👏 en bas de page pour que d’autres lecteurs puissent le trouver !