Mettre en place un ViewPager animé

Pour ce tutoriel, je souhaiter travailler à nouveau sur les animations. En effet, avec quelques animations simples, on peut augmenter le dynamisme de notre application, et son adoption auprès des utilisateurs.

J’ai eu la chance de tomber cette semaine sur le Tweet de Diego Velasquez (Développeur Flutter péruvien, dont vous pouvez retrouver les artciles sur Medium : Diego Velasquez), qui met en avant son développement d’un ViewPager animé, dans le contexte d’une application de billetterie pour le cinéma. Je vais donc m’inspirer de son résultat (voir ci-dessous) pour le reproduire en React Native.

Interface de Diego Velasquez développée en Flutter

Je vais donc créer un écran movie.js qui va contenir le code de mon ViewPager.

Comme d’habitude, je commence par vous présenter le code initial de la page, puis je ne vous présenterai que les blocs de codes ayant une différence à chaque étape. J’accentuerai les ajouts dans les blocs en les mettant en gras.

import React from ‘react’;
import { StyleSheet, View, Text, SafeAreaView } from ‘react-native’;
Notre page ‘vide’

Afin de créer un ViewPager — comme nous l’avions vu dans le tuto Mise en place d’un onboarding en React Native avec une simple ScrollView — nous allons tout d’abord mettre en place une ScrollView horizontal, contenant 3 vues : Film 1, Film 2 et Film3. Chaque vue aura la largeur de l’écran que nous allons récupérer avec Dimensions.get(‘window’).width. Également, on utilisera directement deux attributs pour notre ScrollView :

  • showsHorizontalScrollIndicator : la valeur de cet attribut sera false afin de masquer la barre horizontale s’affichant par défaut lorsque l’on fait défiler la ScrollView ;
  • pagingEnable : la valeur de cet attibut sera true afin d’activer la pagination. Cela va permettre “d’arrêter” la ScrollView à chaque swipe sur une page.
import React from ‘react’;
import { StyleSheet, View, Text, SafeAreaView, ScrollView, Dimensions } from ‘react-native’;
Mise en place du pager

Nous allons tout de suite optimiser notre code, en créant un tableau contenant les données de nos films. Si on reprend l’exemple très simple ci-dessous, nos films sont seulement représenté par un titre (Film 1, Film 2 et Film 3). Chaque objet représentant un film contiendra donc un seul attribut : title, contenant le titre du film, et notre tableau -movies- aura 3 entrées :

const movies = [{title:"Film 1"}, {title:"Film 2"}, {title:"Film 3"}];

Pour créer les vues de notre ViewPager, nous allons donc passer par une méthode getMoviePages(), dans laquel une boucle permettra de parcourir le tableau movies, et renverra l’ensemble des vues. Notre code va donc être modifié de la façon suivante :

import React from ‘react’;
[...]
const movies = [{title:"Film 1"}, {title:"Film 2"}, {title:"Film 3"}];
[...]
export default class Movies extends React.Component {
[...]
render() {
return (
<SafeAreaView style={styles.container}>
<ScrollView
[...]>
{this.getMoviePages()}
</ScrollView>
</SafeAreaView>
);
}

Vous verrez que votre application n’a pas changé. Par contre le code est beaucoup plus dynamique. Pour preuve, si vous ajoutez un autre film dans votre tableau, vous verrez que votre ViewPager aura automatiquement une quatrième page.

const movies = [{title:"Film 1"}, {title:"Film 2"}, {title:"Film 3"}, {title:"Film 4"}];

Nous allons maintenant afficher des données de films qui nous seront utiles pour construire proprement notre animation. Pour cela nous allons modifier les titres de nos films, et également ajouter un attribut image à nos objets, pour que chaque film soit représenté également par son affiche.

Pour mon exemple, j’ai choisi les 4 films suivants : Toy Story 4, Joker, Star Wars 9 et Alita : Battle Angel. Chaque affiche sera représentée par son URL. Mon tableau movies va changer ainsi :

const movies = [
{
title:”Toy Story 4",
image:”https://aotb.xyz/imgs/toystory4.jpg"
},
{
title:”Joker”,
image:”https://aotb.xyz/imgs/joker.jpg"
},
{
title:”Star Wars — L’ascension de Skywalker”,
image:”https://aotb.xyz/imgs/starwars9.jpg"
},
{
title:”Alita : Battle Angel”,
image:”https://aotb.xyz/imgs/alita.jpg"
}

];

Vous pouvez déjà voir que vos pages ont changé, que vous voyez maintenant les titres des films à la place de Film 1, Film 2, Film 3 et Film 4.

Nous allons maintenant afficher l’affiche du film au dessus du titre, pour commencer à créer la fiche du film qui se trouvera en bas de chaque page de notre ViewPager.

Pour cela nous ajoutons simplement une vue Image au dessus de la vue Text dans les vues renvoyées par le getMoviePages().

import React from 'react';
import { StyleSheet, View, Text, ScrollView, Dimensions, Image } from 'react-native';
[...]
export default class Movies extends React.Component {
[...]
getMoviePages = () => {
let moviePages = [];
for (const aMovieIndex in movies) {
if (movies.hasOwnProperty(aMovieIndex)) {
const aMovie = movies[aMovieIndex];
moviePages.push(<View key={aMovieIndex.toString()} style={styles.page}>
<Image source={{uri:aMovie.image}} size style={styles.movieImage} resizeMode='cover' />
<Text>{aMovie.title}</Text>
</View>);
}
}
return moviePages;
}
}

Nous obtenons maintenant une présentation plus imagée de nos films.

Première version de notre fiche de film

Nous allons maintenant créer les fiches de nos films telles qu’elles apparaîtront à la fin de ce code.

Pour pouvoir travailler de manière plus simple sur la fiche du film, nous allons créer une nouvelle méthode getMovieCard() dans laquelle nous allons déplacer le code de la vue contenant les infos du film. Cette méthode prendra les objets movie et index en argument.

export default class Movies extends React.Component {
[...]
getMoviePages = () => {
let moviePages = [];
for (const aMovieIndex in movies) {
if (movies.hasOwnProperty(aMovieIndex)) {
const aMovie = movies[aMovieIndex];
moviePages.push(this.getMovieCard(aMovie, aMovieIndex));
}
}
return moviePages;
}

La fiche de notre film va être sur fond blanc, les coins arrondis, avec une ombre. Elle contiendra dans sa partie haute l’affiche du film suivi de son titre, et dans sa partie basse un espace blanc suffisamment grand pour y loger le bouton permettant d’acheter un ticket. Pour s’assurer qu’aucun élément de la fiche ne soit ‘collé’ à ses bords, nous allons lui assigner un padding général de 20. Également, je vais tout de suite utiliser une constante pour stocker la largeur de la fiche du film, car cela nous sera utile pour la suite.

import React from ‘react’;
import { StyleSheet, View, Text, ScrollView, Dimensions, Image } from ‘react-native’;
Nos fiches films sont prêtes

Pour la version finale, nous souhaitons que les fiches soient positionnées en bas de l’écran. Nous allons donc modifier la page pour que l’ensemble de ses éléments soit affiché en bas de page avec l’attribut justifyContent auquel nous allons attribuer la valeur flex-end. Pour que notre fiche ne soit pas complètement collée au bas de l’écran, nous allons ajouter l’attribut bottom à notre ficher et lui donner la valeur 10. Je profite aussi de ce changement pour modifier le titre du film. On va le décoller de l’affiche, augmenter la taille de la police, le centrer et le mettre en gras.

Notre fiche est prête et correctement positionnée

Il est temps de créer le carrousel des fiches. Le but est qu’elles soient plus proches les unes des autres, afin qu’on voit la fiche suivante, s’il y en a une, partiellement affichée à droite de la fiche du film mis en avant, et la ficher précédente, s’il y en a une, à sa gauche. Pour cela, nous allons modifier la taille de la vue ‘page’, pour passer de Dimensions.get(‘window’).width (qui prend donc toute la largeur de l’écran visible) à la largeur de la fiche à laquelle nous allons ajouter 10, afin de laisser un peu d’espace de chaque côté. Pour cela nous allons créer une nouvelle constante pageWidth pour y stocker cette largeur.

import React from ‘react’;
import { StyleSheet, View, Text, SafeAreaView, ScrollView, Dimensions, Image } from ‘react-native’;
Première étape de la création du carrousel

Maintenant que les cartes sont correctement positionnées les unes à côtés des autres, on se rend compte que le paging ne fonctionne plus correctement, et ne s’arrête pas sur la seconde carte. En effet, l’attribut pagingEnable fonctionne bien, mais uniquement si nos vues ont la même largeur que l’écran. Nous allons donc modifier les attributs de la ScrollView pour y remédier. Tout d’abord nous allons supprimer l’attribut pagingEnable, puis nous allons ajouter 3 attributs : snapToInterval, snapToAlignment et decelerationRate.

  • snapToInterval : comme son nom l’indique, il permet de spécifier à la ScrollView qu’elle doit s’arrêter à tous les multiples de sa valeur;
  • decelerationRate : indique la vitesse de décélération de la ScrollView après un swipe. Cette attribut est obligatoire pour que l’attibrut snapToInterval fonctionne.
render() {
return (
<SafeAreaView style={styles.container}>
<ScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
decelerationRate=’fast’
snapToInterval={pageWidth}
>
{this.getMoviePages()}
</ScrollView>
</SafeAreaView>
);
}
Le pager est de nouveau fonctionnel

Maintenant nous souhaitons que notre fiche ‘principale’ soit centrée. Pour que cela soit possible il faut faire deux modifications.

La première est d’ajouter un padding horizontal à notre ScrollView, afin que les fiches ne soient pas ‘collées’ à ses bords. Pour qu’une fiche soit centrée, il faut que les espaces sur ses côtés soient égale à la largeur de l’écran, à laquelle on soustrait à largeur de la fiche, puis qu’on divise par deux. Ces donc cette valeur qui définira le padding horizontal de notre ScrollView. Pour qu’un padding horizontal fonctionne bien sur une ScrollView, il lui faut une largeur de référence. Cette largeur doit être tenue par la vue portant le contenu de la ScrollView.

C’est la seconde modification. Nous allons donc créer une nouvelle vue qui va encapsulter nos fiches, et dont la largeur sera égale à la somme des largeurs des fiches (taille de notre tableau movies multiplié par la largeur d’une fiche), à laquelle on ajoutera la valeur des paddings de la ScollView.

export default class Movies extends React.Component {
[...]
render() {
const scrollViewPadding = (Dimensions.get('window').width - pageWidth) / 2;
const numberOfMovie = movies.length;
const widthOfTheCardsLine = numberOfMovie * pageWidth + 2 * scrollViewPadding;

return (
<SafeAreaView style={styles.container}>
<ScrollView
style={{paddingHorizontal:scrollViewPadding}}
horizontal={true}
showsHorizontalScrollIndicator={false}
decelerationRate='fast'
snapToInterval={pageWidth}>
<View style={{ flexDirection: 'row', width: widthOfTheCardsLine }}>
{this.getMoviePages()}
</View>
</ScrollView>
</SafeAreaView>
);
}
}

Passons maintenant à l’animation du carrousel. Nous avons besoin d’animer notre ScrollView (notamment pour qu’elle déclenche les autres animations) et les fiches des films. Nous allons donc ajouter la bibliothèque Animated pour gérer ces animations, passer la ScrollView en Animated.ScrollView et les vues des fiches en Animated.View.

import React from ‘react’;
import { StyleSheet, View, Text, SafeAreaView, Dimensions, Image, Animated } from ‘react-native’;
[...]
export default class Movies extends React.Component {
[...]
render() {
[...]
return (
<SafeAreaView style={styles.container}>
<Animated.ScrollView
[...]>
[...]
</Animated.ScrollView>
</SafeAreaView>
)
}
[...]
getMovieCard = (movie, index) => {
return (
<View key={index.toString()} style={styles.page}>
<Animated.View style={styles.movieCard}>
[...]
</Animated.View>
</View>
)
}
}

Nous avons deux animations à faire :

  • La première est de changer l’opacité des fiches. La fiche principale a une opacité de 1, et les autres fiches auront une opacité de 0.5.
  • La seconde est de changer la position verticale des fiches. La fiche principale sera affichée de la même manière qu’actuellement, alors que les autres fiches seront légèrement masquées en bas. On changera donc la valeur de l’attribut bottom pour jouer sur cette position.

Les animations doivent se déclencher en fonction de la position de la ScrollView. Pour cela nous allons ajouter la méthode onScroll à notre ScrollView, qui va nous permettre de récupérer la position de la ScrollView quand l’utilisateur scroll. Pour la récupérer, et l’utiliser pour les animations, il faut créer une variable ‘animée’, que nous nommerons scrollX.

export default class Movies extends React.Component {
constructor(props) {
super(props);
this.state = {
scrollX: new Animated.Value(0)
}
}
[...]
render() {
[...]
return (
<SafeAreaView style={styles.container}>
<Animated.ScrollView
[...]
scrollEventThrottle={16}
onScroll={Animated.event(
[
{
nativeEvent: { contentOffset: { x: this.state.scrollX } }
}
]
)}
>
[...]
</Animated.ScrollView>
</SafeAreaView>
)
}
[...]
}

Ainsi la valeur de la position de la ScrollView renvoyée par contentOffset.x est stocké dans la variable this.state.scrollX à chaque scroll. Nous avons également rajouté l’attribut scrollEventThrottle en lui donnant la valeur 16 pour avoir un rafraîchissement de cette valeur régulier.

Nous allons maintenant changer l’opacité de notre ficher en fonction de la valeur de this.state.scrollX. Pour cela nous allons créer une méthode getCardOpacity(), qui va recevoir en argument l’index de la vue, et qui renverra la valeur de l’opacité. Nous allons ajouté l’attribut opactity dans le style de la vue, pour qu’il reçoivent la valeur retourné par cette méthode.

Lorsqu’on change les valeurs de manière animée en fonction d’une autre, nous avons besoins les intervalles pour lesquels la valeur doit changer en entrée (input), et les valeurs de l’opacité correspondant à ces intervalles en sortie (output). La valeur de l’opacité d’une carte va changer en fonction de 3 positions : avant la carte principale, à la position de la carte principale et après la carte principale. La position d’une ficher en tant que carte principale peut être connu en fonction de sa position dans le tableau (index). En effet, une fiche sera mise en avant quand la ScrollView aura la position correspondant à l’index du film multiplié par la taille d’une carte. Nous avons donc l’input correspondant à la position de la carte principale, qui donnera l’ouput 1 pour l’opacité. Pour la position avant et après, il suffit respectivement d’enlever ma taille d’une fiche à la position de la carte principale et d’en rajouter une. Cela nous donnera les inputs pour l’output 0.5 de l’opacité.

Nous aurons donc le code suivant :

export default class Movies extends React.Component {
getMovieCard = (movie, index) => {
return (
<View key={index.toString()} style={styles.page}>
<Animated.View style={[styles.movieCard, {opacity:this.getCardOpacity(index)}]}>
<Image source={{ uri: movie.image }} size style={styles.movieImage} resizeMode=’cover’ />
<Text style={styles.movieTitle}>{movie.title}</Text>
</Animated.View>
</View>
)
}
L’opacité se modifie correctement en fonction de la position de la carte

Le changement de position est un copier-coller de la méthode de changement d’opacité. Nous allons créer une méthode getBottomPositionForCard(), avec l’attribut index et nous renverrons 10 quand la carte est au centre, et -40 quand elle est sur les côtés. Nous attribuerons la valeur retournée par getBottomPositionForCard() à l’attribut bottom du style de la carte.

export default class Movies extends React.Component {
getMovieCard = (movie, index) => {
return (
<View key={index.toString()} style={styles.page}>
<Animated.View style={[styles.movieCard, {opacity:this.getCardOpacity(index), bottom:this.getBottomPositionForCard(index)}]}>
<Image source={{ uri: movie.image }} size style={styles.movieImage} resizeMode=’cover’ />
<Text style={styles.movieTitle}>{movie.title}</Text>
</Animated.View>
</View>
)
}
[...]
getBottomPositionForCard = (index) => {
const { scrollX } = this.state;
const pageIndex = parseInt(index);
let inputRange = [pageWidth * (pageIndex-1), pageWidth * pageIndex, pageWidth * (pageIndex+1)];
Notre carrousel est terminé

Maintenant que le carrousel est terminé, il nous reste 2 points à terminer :

  • Le bouton d’achat d’un billet
  • Le fond de la vue qui doit contenir l’affiche du film mis en avant

Pour le bouton d’achat d’un billet, c’est simple. C’est en effet le seul élément fixe de notre vue. Nous allons donc ajouter un bouton en bas de l’écran, et le fixer grâce au style position et sa valeur absolue. Afin qu’il reste fixe, ce bouton doit évidemment être positionné en dehors de la ScrollView.

import React from ‘react’;
import { StyleSheet, View, Text, SafeAreaView, Dimensions, Image, Animated, TouchableOpacity } from ‘react-native’;
[...]
export default class Movies extends React.Component {
[...]
render() {
[...]
return (
<SafeAreaView style={styles.container}>
<Animated.ScrollView
[...]>
[...]
</Animated.ScrollView>
<TouchableOpacity style={styles.buyButton}>
<Text style={styles.buyButtonLabel}>ACHETER UN BILLET</Text>
</TouchableOpacity>

</SafeAreaView>
)
}
[...]
}
Mise en place de notre bouton d’achat

Maintenant il ne nous reste plus qu’à ajouter le fond. Si nous ajoutons l’image de fond à chaque page, l’image n’aura pas la bonne dimensions, car nous souhaitons qu’elle recouvre tout l’écran. De plus nous souhaitons avoir l’effet d’une pile de carte qui change à chaque scroll. L’idée est donc d’aouter une pile d’affiche qui auront une position fixe en fond d’écran. Quand on scrollera la position de la carte changera pour l’enlever de la pile ou la remettre.

Nous allons déjà créer notre pile d’affiche. Nous devons mettre la première affiche en haut de la pile pour qu’elle soit vue en premier. Au lieu de faire un push dans le tableau de vue à afficher qui ajoute les nouvelles vues en fin de tableau, nous effectuerons un unshift pour les positionner en début. Nous allons créer une méthode getBackgroundStack() pour créer cette pile et la récupérer dans la vue principale.

export default class Movies extends React.Component {
[...]
render() {
[...]
return (
<SafeAreaView style={styles.container}>
{this.getBackgroundStack()}
<Animated.ScrollView
[...]>
[...]
</Animated.ScrollView>
[...]
</SafeAreaView>
)
}
[...]
getBackgroundStack = () => {
let movieImageStack = [];
for (const aMovieIndex in movies) {
if (movies.hasOwnProperty(aMovieIndex)) {
const aMovie = movies[aMovieIndex];
movieImageStack.unshift(<View style={styles.backgroundView}>
<Image style={styles.backgroundImage} resizeMode='cover' source={{ uri: aMovie.image }} />
</View>);
}
}
return movieImageStack;
}

}
Ajout du fond d’écran

Le problème est que nous n’avons rien fait pour que l’image de fond change au scroll. Donc quelque soit le film nous aurons l’affiche de Toy Story 4. Il faut que nos vues ‘backgroundView’ soient animées, et que nous changions leur position en fonction du scroll. Pour cela nous allons ajouter l’attribut left au style de nos vues, et lui donner une valeur dépendante de la position de la ScrollView. Si la ScrollView est positionné sur la fiche du film, sa position left sera à 0, pour qu’on la voie en plein écran. Sinon on la cachera vers la gauche en lui attribuant une valeur négative égale à la largeur de l’écran. Cette position sera calculé avec la méthode getLeftPositionForBackground(), prenant l’index de la carte en attribut. Lors de ce calcul, nous allons ajouter une condition pour que la dernière affiche ne bouge pas, même si l’utilisateur essaye de swiper après la dernière fiche.

export default class Movies extends React.Component {
[...]
getBackgroundStack = () => {
let movieImageStack = [];
for (const aMovieIndex in movies) {
if (movies.hasOwnProperty(aMovieIndex)) {
const aMovie = movies[aMovieIndex];
movieImageStack.unshift(<Animated.View style={[styles.backgroundView, {left:this.getLeftPositionForBackground(aMovieIndex)}]}>
<Image style={styles.backgroundImage} resizeMode='cover' source={{ uri: aMovie.image }} />
</Animated.View>);
}
}
return movieImageStack;
}
Résultat final de notre pager animé

Et voilà, notre pager animé est terminé.

Show Runner de projets mobiles. Développeur React Native et passionné par les challenges du monde mobile.

Show Runner de projets mobiles. Développeur React Native et passionné par les challenges du monde mobile.