Ajouter du dynamisme à votre onboarding avec des animations simples en React Native
Le but de cet article est de nous concentrer sur la bibliothèque Animated inclue dans React Native, et ainsi nous soulager d’une nouvelle dépendance à une librairie externe.
Nous allons prendre comme exemple l’écran que nous avons créé précédemment : L’onboarding de l’article Mise en place d’un onboarding en React Native avec une simple ScrollView. Pour s’initier à l’animation, nous nous concentrerons dans cet article sur l’animation des indicateurs de positions.
Avant tout, commençons par importer la bibliothèque Animated dans notre page onboarding.js.
import { StyleSheet, Text, View, ScrollView, Dimensions, SafeAreaView, TouchableOpacity, Animated } from ‘react-native’;
Dans notre onboarding, nous avons mis en place 3 cercles qui indique la position de la page affichée, dans l’ensemble des pages de l’onboarding. Dans la version actuelle, le cercle X est plein quand l’utilisateur est en train de visualiser la page X, sinon il est vide.
Nous gardons cette logique, mais nous allons changer la manière de remplir le cercle, en animant cette transition. Je vais vous proposer 3 animations possibles :
- dans la première nous allons simplement jouer sur l’opacité des points pour avoir un effet de fade in / fade out,
- dans la deuxième, nous allons déplacer l’indicateur en jouant sur sa position,
- enfin dans la troisième, nous allons faire passer le ‘contenu’ d’un cercle plein vers le cercle vide ‘à remplir’ en jouant sur sa taille.
Le travail initial à faire pour les 3 animations est le même. Nous devons d’abord définir quelles sont les vues qui vont être prises en compte dans notre animation. Il va s’agir :
- de la ScrollView horizontale dont le mouvement va changer la position des pages,
- de la vue qui va contenir la couleur de fond blanche, et qui va passer de cercle en cercle.
Pour qu’une vue soit intégrée dans une animation, il faut la préfixer par Animated. Nous pouvons donc déjà le faire pour la ScrollView.
<Animated.ScrollView
style={styles.onboardingScrollView}
scrollEventThrottle={16}
horizontal={true}
pagingEnabled={true}
showsHorizontalScrollIndicator={false}
onScroll={this.onScrollHandler}
>
[…]
</Animated.ScrollView>
Comme vous l’avez peut être remarqué, j’ai également profité de ce changement pour ajouter l’attribut scrollEventThrottle en lui donnant la valeur 16. Cela permet à notre code d’appeler la méthode onScroll toutes les 16ms. Si vous ne définissez pas cette valeur, onScroll ne sera appelée qu’une seule fois. Cela va nous permettre d’avoir une animation plus fine. Vous pouvez augmenter cette valeur si vous n’avez pas besoin d’être aussi précis.
La deuxième vue à animer n’existe pas encore. En effet, pour le moment nous avons 3 vues qui constituent les cercles, dont nous changeons la couleurs de fond. Nous allons définir la couleur de fond de ces 3 vues à blanc, et nous en ferons varier l’opacité en fonction de la page en cours de consultation.
Notre code pour les indicateurs de position devient alors le suivant.
render() {
return (
[...]
<View style={styles.positionIndicatorsRow}>
<View style={styles.positionIndicatorCircle}>
<Animated.View style={[styles.positionIndicatorPoint, { opacity: 1 }]} />
</View>
<View style={styles.positionIndicatorCircle}>
<Animated.View style={[styles.positionIndicatorPoint, { opacity: 1 }]} />
</View>
<View style={styles.positionIndicatorCircle}>
<Animated.View style={[styles.positionIndicatorPoint, { opacity: 1 }]} />
</View>
</View>
[...]
)
}
[...]const styles = StyleSheet.create({
[...]
positionIndicatorCircle: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: '#fff',
justifyContent: 'center',
alignItems: 'center'
},
positionIndicatorPoint: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#fff'
}
});
Comme vous pouvez le voir nous n’utilisons plus les couleurs de fond des cercles. Vous pouvez donc supprimer ces deux lignes de votre code, qui ne sont plus utiles.
let circleBackgroundColor = [‘transparent’, ‘transparent’, ‘transparent’];
circleBackgroundColor[this.state.pagePosition-1] = ‘#fff’;
Sur l’application, vous avez maintenant vos 3 cercles blancs. Avant d’animer nos indicateurs, nous allons faire en sorte de retrouver le comportement précédent avec cette nouvelle manière de remplir / vider les cercles, en fonction de la position.
Pour cela nous allons jouer sur l’opacité des nouvelles vues. Les mettre à 1 quand la position est celle de l’écran en cours de consultation et à 0 dans la cas contraire. Nous allons créer une méthode getOpacityForPosition qui prendra en argument la position du cercle (1, 2 ou 3), et qui renverra 1 si la position envoyé en argument est égale à la position en cours (this.state.pagePosition), ou 0 dans la cas contraire. Nous remplacerons la valeur des opacités mise à 1 dans le code précédent par l’appel à la méthode. Ce qui nous donnera le code suivant.
export default class Onboarding extends React.Component {
[…]
getOpacityForPosition = (position) => {
if (position === this.state.pagePosition) {
return 1;
} else {
return 0;
}
}render() {
return (
<SafeAreaView style={styles.container}>
<View>
[...]
<View style={styles.bottomOptions}>
<View style={styles.positionIndicatorsRow}>
<View style={styles.positionIndicatorCircle}>
<Animated.View style={[styles.positionIndicatorPoint, { opacity: this.getOpacityForPosition(1) }]} />
</View>
<View style={styles.positionIndicatorCircle}>
<Animated.View style={[styles.positionIndicatorPoint, { opacity: this.getOpacityForPosition(2) }]} />
</View>
<View style={styles.positionIndicatorCircle}
<Animated.View style={[styles.positionIndicatorPoint, { opacity: this.getOpacityForPosition(3) }]} />
</View>
</View>
[...]
</View>
</View>
</SafeAreaView>
);
}
Vous retrouvez maintenant le comportement de départ. Il nous reste plus qu’à ajouter l’animation.
Animation des changements d’opacité
Notre animation va dépendre de la position de la ScrollView. Nous allons donc mettre à jour l’opacité de nos cercles non plus en fonction de la page à l’écran, mais directement en fonction de l’abscisse de notre ScrollView. Pour cela nous allons ajouter un nouvelle variable à notre composant scrollViewAbscissa, qui évoluera dynamiquement grâce à la méthode onScroll. Encore une fois, c’est la bibliothèque Animated qui va nous fournir les outils pour que toutes les mises à jour de ces valeurs se fassent de manière fluide. Nous allons déclarer la valeur initiale de notre abscisse, non pas en la déclarant simplement à 0, mais avec une Animated Value initiée à 0.
constructor(props) {
super(props);
this.state = {
scrollViewAbscissa: new Animated.Value(0)
}
}
J’en profite pour supprimer pagePosition qui ne nous sera plus utile par la suite.
Nous allons maintenant faire en sorte que scrollViewAbscissa se mette à jour lorsque nous scrollons notre vue. Pour cela nous allons modifier l’appel fait dans onScroll, et utiliser les outils d’Animated pour faire cette mise à jour. Nous remplacerons donc la ligne :
onScroll={this.onScrollHandler}
par :
onScroll={Animated.event(
[
{
nativeEvent: { contentOffset: { x: this.state.scrollViewAbscissa } }
}
]
)}
Nous pouvons donc supprimer la méthode onScrollHandler et tout son contenu. Le code ci-dessus permet de modifier la valeur de scrollViewAbscissa à chaque mouvement de la ScrollView.
Enfin, il ne nous reste plus qu’à modifier la méthode getOpacityForPosition pour qu’elle prenne en compte la valeur de scrollViewAbscissa dans le calcul de l’opacité de chaque cercle. Pour se faire, nous allons utiliser la méthode interpolate permettant de faire correspondre les valeurs de l’abscisse de la ScrollView avec celle de l’opacité.
Les valeurs de l’abscisse possible sont 0 pour la page 1, Dimensions.get(‘screen’).width pour la page 2 et 2*Dimensions.get(‘screen’).width pour la page 3. Nous allons donc affecter les valeurs des opacités de chaque vue en fonction de ces 3 possibilités.
Voici donc notre nouvelle méthode getOpacityForPosition
getOpacityForPosition = (position) => {
const { scrollViewAbscissa } = this.state;
//Nous indiquons dans un tableau la valeur des opacités en fonction de l'abscisse de la ScrollView pour chacune des 3 positions de page
let outputRange = [1, 0, 0];
if (position === 2) {
outputRange = [0, 1, 0];
} else if (position === 3) {
outputRange = [0, 0, 1];
}
return scrollViewAbscissa.interpolate({
inputRange: [0, Dimensions.get(‘screen’).width, 2 * Dimensions.get(‘screen’).width],
outputRange: outputRange,
extrapolate: ‘clamp’,
useNativeDriver: true
});
}
Vous pouvez maintenant observer une transition beaucoup plus fluide entre les indicateurs de position.
Animation du changement de position
Une autre idée consisterait à faire partir le point blanc d’un cercle plein vers le cercle vide suivant. Pour cela, nous allons simplifier la vue des cercles. Nous n’aurons plus que 3 cercles vides, “sur lesquels” nous allons positionner un cercle plein.
Pour passer ce cercle plein d’un cercle vide à un autre, nous allons utiliser l’attribut de style position, prenant comme valeur absolute, et nous allons jouer sur l’attribut left qui indiquera la position du cercle plein dans la vue contenant les 3 cercles vides. Étant donné que chaque cercle vide a un diamètre de 20, et que la largeur de la vue les contenant est de 100, nous aurons les positions suivantes :
- cercle 1 : 0
- cercle 2 : 40
- cercle 3 : 80
Commençons par simplifier la vue, et mettre en place cette logique.
Modifions le code ci-dessous
<View style={styles.positionIndicatorsRow}>
<View style={styles.positionIndicatorCircle}>
<Animated.View style={[styles.positionIndicatorPoint, { opacity: this.getOpacityForPosition(1) }]} />
</View>
<View style={styles.positionIndicatorCircle}>
<Animated.View style={[styles.positionIndicatorPoint, { opacity: this.getOpacityForPosition(2) }]} />
</View>
<View style={styles.positionIndicatorCircle}>
<Animated.View style={[styles.positionIndicatorPoint, { opacity: this.getOpacityForPosition(3) }]} />
</View>
</View>
Par le code suivant :
<View style={styles.positionIndicatorsRow}>
<View style={styles.positionIndicatorCircle} />
<View style={styles.positionIndicatorCircle} />
<View style={styles.positionIndicatorCircle} />
<Animated.View style={[styles.positionIndicatorPoint, {position:’absolute’, left:0}]} />
</View>
Nous avons alors une vue avec le premier cercle plein. Si vous modifier la valeur de left, vous pourrez voir le cercle plein bouger.
Maintenant ajoutons de l’animation, pour que la valeur de la position left change en fonction de la valeur de l’abscisse de la ScrollView. Nous n’avons plus besoin de la méthode getOpacityForPosition, nous allons donc la modifier pour qu’elle renvoie la position du cercle plein. Nous allons nommer cette méthode getIndicatorPosition et supprimer l’attribut position dont nous n’avons plus besoin.
getIndicatorPosition = () => {
const { scrollViewAbscissa } = this.state;
return scrollViewAbscissa.interpolate({
inputRange: [0, Dimensions.get(‘screen’).width, 2 * Dimensions.get(‘screen’).width],
outputRange: [0, 40, 80],
extrapolate: ‘clamp’,
useNativeDriver: true
});
}
Et nous remplaçons la valeur statique de l’attribut left par l’appel à cette méthode.
<Animated.View style={[styles.positionIndicatorPoint, { position: ‘absolute’, left: this.getIndicatorPosition() }]} />
On peut observer maintenant le cercle plein d’un cercle vide à l’autre.
Animation du changement de taille
Pour cette dernière animation, nous allons jouer encore sur la position du cercle plein, mais surtout sur sa taille pour donner l’impression qu’il s’étire jusqu’au prochain cercle vide pour ensuite se rétracter et remplir ce cercle.
Quand l’utilisateur fait glisser la ScrollView d’un écran à l’autre, nous allons vouloir étendre le cercle plein pour qu’il aille vers le prochain cercle vide. Le cercle plein a une taille de 20, il lui faudra donc atteindre la taille de 60 pour remplir l’espace entre le début du cercle ‘en cours’ et la fin du cercle ‘à remplir’. Puis il lui faudra reprendre sa taille de 20 pour ne remplir que la cercle ‘à remplir’. Il y aura donc 3 étapes pour passer d’un point à l’autre.
Nous devrons jouer sur la position du point, comme pour la précédente animation, mais cette fois ci le point ne doit commencer à bouger qu’à partir du moment où notre indicateur de position aura atteint la fin du cercle à remplir. Il faudra donc aussi travailler sur 3 étapes.
Nous allons donc déplacer l’attribut de style width qui était static dans le style positionIndicatorPoint, pour le mettre au niveau de la vue, et jouer sur sa valeur grâce à une nouvelle méthode getIndicatorSize(), qui renverra la taille du cercle plein en fonction de l’abscisse de la ScrollView.
Voici le code de la méthode getIndicatorSize()
getIndicatorSize = () => {
const { scrollViewAbscissa } = this.state; return scrollViewAbscissa.interpolate({
inputRange: [0, Dimensions.get(‘screen’).width/2,
Dimensions.get(‘screen’).width,
Dimensions.get(‘screen’).width*3/2, 2 * Dimensions.get(‘screen’).width],
outputRange: [20, 60, 20, 60, 20],
extrapolate: ‘clamp’,
useNativeDriver: true
});
}
Comme on peut le voir, quand l’abscisse de la ScrollView est à 0 (page 1), la taille de notre cercle est de 20. Puis il va s’étendre jusqu’à 60 quand la moitié de la page 2 sera visible, et enfin revenir à 20 quand la page 2 sera entièrement visible à l’écran (puis nous appliquons la même logique pour la page 3).
La méthode getIndicatorPosition() est également légèrement modifiée pour que la position ne change pas tant que la taille du cercle plein n’atteint pas 60, puis la position change jusqu’à ce que le cercle plein atteigne 20, pour que celui-ci soit ‘arrivé à destination’. Voici notre nouvelle méthode getIndicatorPosition().
getIndicatorPosition = () => {
const { scrollViewAbscissa } = this.state; return scrollViewAbscissa.interpolate({
inputRange: [0, Dimensions.get(‘screen’).width/2, Dimensions.get(‘screen’).width, Dimensions.get(‘screen’).width*3/2, 2 * Dimensions.get(‘screen’).width],
outputRange: [0, 0, 40, 40, 80],
extrapolate: ‘clamp’,
useNativeDriver: true
});
}
Le reste du code change peu, si ce n’est le code de la vue qui représente notre cercle plein, à laquelle on ajoute une largeur dynamique.
<Animated.View style={[styles.positionIndicatorPoint, { width: this.getIndicatorSize(), position: ‘absolute’, left: this.getIndicatorPosition() }]} />
Et la simplification du style positionIndicatorPoint pour enlever la valeur statique de la largeur du cercle plein.
positionIndicatorPoint: {
height: 20,
borderRadius: 10,
backgroundColor: ‘#fff’
}
Une fois le code rafraîchit, nous obtenons cette nouvelle animation plus ‘dynamique’ que la précédente.
Voilà pour les 3 exemples d’animations possibles. Il est également envisageable de travailler également sur la taille de cercles pour que celui indiquant la position de l’onboarding soit plus gros que les autres ou encore sur la couleur du point. Ce qui est intéressant dans ces exemples, est de voir que React Native a simplifié la mise en place d’animations grâce à sa bibliothèque Animated, et qu’il est rapide d’ajouter du dynamisme à son app grâce à celle-ci.