Créer LILI en React Native — Partie 1 — naviguer dans l’application

Cette série d’articles fait partie de la série “Les Tutos du Vendredi”, avec la spécificité d’exposer la travail réalisé sur l’application LILI dont la première version a été mise en ligne le 22 septembre 2020.

LILI est une application qui propose des programmes individualisés Calme, Concentration et Confiance en soi, associant méditation et yoga, ateliers d’improvisation et podcasts de philosophie, pour aider chaque enfant à mieux gérer ses émotions, améliorer sa concentration et prendre confiance à l’oral.

LILI est disponible sur l’AppStore pour iOS et sur le Google Play Store pour Android.

Introduction

Commençons tout d’abord par la base. J’ai choisi de développer l’application LILI en React Native. En étudiant le projet, et nos objectifs pour celui-ci, j’ai jugé que mes connaissances en React Native nous permettraient de les atteindre. Nous souhaitions :

  • développer une application disponible sur les 2 OS ;
  • maitriser notre budget pour une première version ;
  • être fidèle au design de l’application réalisé par Agathe De Sutter ;
  • pouvoir s’amuser en développant l’app (donc ne pas devoir adapter le projet en fonction de contraintes techniques).

La mise en ligne de LILI 1.0 nous a prouvé que nous avions fait le bon choix.

Naviguer dans l’application

Nous souhaitions avoir :

  • une navigation fluide (proche du natif),
  • des composants dynamiques permettant d’ajouter du relief à l’UI de l’app.

Pour répondre à la première question, nous n’avons pas eu trop d’hésitations. Grâce à la bibliothèque React Native Navigation de WIX, nous avons pu mettre en place une navigation native, et assurer la fluidité de l’application lors de la transition entre les écrans.

Pour ajouter du dynamisme à l’application, nous avons utilisé la bibliothèque Animated de React Native dans quelques parties de l’app. J’aimerai donc commencer cette série d’articles par un exemple de l’utilisation d’Animated dans la navigation, avec le menu horizontal de la section Mon Programme.

Naviguer dans Mon Programme

La section Mon Programme permet, aux enfants utilisant LILI, d’avoir accès à des activités définies en fonction d’un but recherché (Calme, Concentration, Confiance en soi ou sommeil), ou de se laisser complètement guider dans un mix d’activités créé en fonction de l’utilisation de l’application.

Pour naviguer à travers la liste d’activités de chaque programme, nous avons souhaité mettre en place une navigation différente de celle présente dans le reste de l’app. Pour cela, nous avons créé un menu horizontal permettant de faire défiler les programmes. Avec la bibliothèque Animated, nous l’avons rendu dynamique, comme nous pouvons le voir ci-dessous.

Dans cet article, nous allons nous concentrer sur l’animation du menu haut, moteur de la navigation des listes du bas de l’écran.

Commençons par mettre en place nos éléments. Comme d’habitude, je vais maximiser l’utilisation de bibliothèques inclues dans React Native plutôt que d’en importer des nouvelles. Nous travaillerons donc avec des ScrollView.

Notre page est composée de deux ScrollView horizontales et de listes verticales. Mettons en place les deux ScrollView.

import React from ‘react’;
import { StyleSheet, View, Dimensions, Text, ScrollView, SafeAreaView } from ‘react-native’;
export default class AnimationLili extends React.Component {
constructor() {
super();
this.state = {
};
}
render() {
return (
<SafeAreaView style={{flex:1}}>
<View style={{ height: 200 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}>
<View style={{ backgroundColor: ‘#FF0000’, width: Dimensions.get(‘screen’).width }}>
<Text>Menu du haut</Text>
</View>
</ScrollView>
</View>
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}>
<View style={{ backgroundColor: ‘#00FF00’, width: Dimensions.get(‘screen’).width }}>
<Text>Listes du bas</Text>
</View>
</ScrollView>
</View>
</SafeAreaView>
)
}
}
Mise en place des 2 sections de notre écran

Nous allons maintenant mettre en place les listes du bas. Le but de cet article n’étant pas la mise en place de listes, nous allons tout simplement créer 5 vues contenant chacune une image distincte.

Dans la liste du bas, chaque image occupera une vue de la largeur de l’écran. Dans le menu du haut, nous aurons une miniature carrée de l’image, centrée dans le menu. Mettons à jour notre code afin d’obtenir le rendu désiré.

[...]
import { StyleSheet, View, Dimensions, Text, ScrollView, SafeAreaView, Image } from ‘react-native’;
export default class AnimationLili extends React.Component {
[...]
render() {
return (
<SafeAreaView style={{flex:1}}>
<View style={{ height: 200 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}>
<View style={styles.menu}>
<View style={styles.menuView}>
<Image
resizeMode="cover"
source={require('../assets/img/photo1.jpg')}
style={styles.icon} />
</View>
<View style={styles.menuView}>
<Image
resizeMode="cover"
source={require('../assets/img/photo2.jpg')}
style={styles.icon} />
</View>
<View style={styles.menuView}>
<Image
resizeMode="cover"
source={require('../assets/img/photo3.jpg')}
style={styles.icon} />
</View>
<View style={styles.menuView}>
<Image
resizeMode="cover"
source={require('../assets/img/photo4.jpg')}
style={styles.icon} />
</View>
<View style={styles.menuView}>
<Image
resizeMode="cover"
source={require('../assets/img/photo5.jpg')}
style={styles.icon} />
</View>
</View>

</ScrollView>
</View>
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}>
<View style={{ flexDirection: 'row' }}>
<Image
resizeMode="cover"
source={require('../assets/img/photo1.jpg')}
style={styles.fullScreen} />
<Image
resizeMode="cover"
source={require('../assets/img/photo2.jpg')}
style={styles.fullScreen} />
<Image
resizeMode="cover"
source={require('../assets/img/photo3.jpg')}
style={styles.fullScreen} />
<Image
resizeMode="cover"
source={require('../assets/img/photo4.jpg')}
style={styles.fullScreen} />
<Image
resizeMode="cover"
source={require('../assets/img/photo5.jpg')}
style={styles.fullScreen} />
</View>

</ScrollView>
</View>
</SafeAreaView>
)
}
}
const styles = StyleSheet.create({
menu: {
flexDirection: 'row'

},
menuView: {
width: Dimensions.get('screen').width,
height: '100%',
justifyContent: 'center',
alignItems: 'center'
},
icon: {
width: 100,
height: 100,
borderRadius: 20
},
fullScreen: {
width: Dimensions.get('screen').width,
height: '100%'
}
});

Voici les crédits des images utilisées dans cet exemple et provenant du site Unsplash.com.

Photo #1 : par Lital Levy sur Unsplash

Photo #2 : par Derek Story sur Unsplash

Photo #3 : par Bradley Dunn sur Unsplash

Photo #4 : par Kimi Albertson sur Unsplash

Photo #5 : par Megan Ellis sur Unsplash

Nous avons maintenant deux ScrollView horizontales, avec le contenu désiré. Il faut maintenant effectuer les actions suivantes :

  • faire en sorte que le menu du haut soit paginé, pour qu’il s’arrête automatiquement sur l’élément suivant ou précédent au swipe. Pour cela nous utiliserons l’attribut pagingEnabled, dont la valeur sera true ;
  • et faire en sorte que l’utilisateur ne puisse pas scroller lui même la liste du bas. Pour cela nous utiliserons l’attribut scrollEnabled que nous initierons à false.

Nous en profiterons pour ne pas afficher les scrollbars horizontales des ScrollView grâce à l’attribut showsHorizontalScrollIndicator à false.

[...]
render() {
return (
<SafeAreaView style={{flex:1}}>
<View style={{ height: 200 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}
pagingEnabled={true}
showsHorizontalScrollIndicator={false}
>
[...]
</ScrollView>
</View>
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
>
[...]
</ScrollView>
</View>
</SafeAreaView>
)
}
[...]

Maintenant, nous allons faire en sorte que le menu du haut est un effet sur les écrans du bas. Quand nous swipons en haut, cela doit afficher l’image correspondante en bas. Pour cela, nous allons utiliser le callback onScroll — associé à scrollEventThrottle pour définir la fréquence d’appel du callback — des ScrollView, et attribuer une référence à notre ScrollView basse pour pouvoir la faire scroller programmaticalement, grâce à la méthode scrollTo.

[...]
export default class AnimationLili extends React.Component {
constructor() {
[...]
this.onMenuScroll = this.onMenuScroll.bind(this);
}
render() {
return (
<SafeAreaView style={{flex:1}}>
<View style={{ height: 200 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}
pagingEnabled={true}
showsHorizontalScrollIndicator={false}
onScroll={this.onMenuScroll}
scrollEventThrottle={16}
>
[...]
</ScrollView>
</View>
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
ref={component => lowerScrollView = component}
>
[...]
</ScrollView>
</View>
</SafeAreaView>
)
}
onMenuScroll(event) {
if (lowerScrollView !== undefined) {
lowerScrollView.scrollTo({ x: event.nativeEvent.contentOffset.x, y: 0, animated: true })
}
}

}
[...]

Nous avons maintenant un menu fonctionnel. Pour l’améliorer, il faut maintenant montrer à l’utilisateur :

  • qu’il y a des éléments dans le menu à gauche et à droite, et qu’il peutdonc swiper pour les faire défiler ;
  • quelle est la page sélectionnée en mettant en avant l’icône de la photo affichée dans l’écran du bas.

Pour montrer qu’il y a des éléments à gauche et à droite, nous allons réduire la taille des vues du menu. Elles occupent actuellement toute la largeur de l’écran (Dimensions.get(‘screen’).width). Nous allons maintenant définir qu’elles n’en occupent plus que la moitié.

[...]
const styles = StyleSheet.create({
[...]
menuView: {
width: Dimensions.get(‘screen’).width/2,
height: ‘100%’,
justifyContent: ‘center’,
alignItems: ‘center’
}
[...]
});

Nous voyons maintenant 2 icônes à la fois, mais cela ne nous donne pas vraiment la vision d’un menu, et de la présence d’autres éléments. Pour cela, nous allons décaler le menu vers la droite du quart de la largeur de l’écran. Ainsi nous centrerons l’icône de notre page en cours en plein centre du menu, mais nous verrons également une partie de l’icône située à côté.

[...]
const styles = StyleSheet.create({
menu: {
flexDirection: 'row',
paddingLeft:Dimensions.get('screen').width/4
}
[...]
});
Le menu est en place et compréhensible

Maintenant nous observons 2 choses :

  • quand nous swipons, nous avançons de 2 icônes dans le menu du haut, et d’une image dans l’écran du bas ;
  • quand nous arrivons au bout du menu, le dernier élément n’est pas centré.

Pour résoudre le second point, il nous suffit de décaler le menu d’un quart de la taille de l’écran vers la gauche cette fois-ci, et le problème est règlé.

[...]
const styles = StyleSheet.create({
menu: {
flexDirection: 'row',
paddingLeft:Dimensions.get('screen').width/4,
paddingRight:Dimensions.get('screen').width/4

}
[...]
});

Pour le premier point, il faut modifier la ScrollView du menu pour qu’elle ne s’arrête plus après avoir scroller d’une largeur d’écran, mais bien d’une demi largeur d’écran. Pour cela nous allons désactiver la pagination, et ajouter les attributs snapToInterval, decelerationRate et snapToAlignment. SnapToInterval est le plus important. Il nous permet de donner la taille des intervalles pour laquelle la ScrollView va s’arrêter quand elle fini de scroller. Ce n’est pas aussi efficace que la pagination (si l’utilisateur scroll rapidement, il peut passer plusieurs icônes d’un coup) mais ça fonctionne quand même très bien.

[...]
render() {
return (
<SafeAreaView style={{flex:1}}>
<View style={{ height: 200 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
onScroll={this.onMenuScroll}
scrollEventThrottle={16}
decelerationRate='fast'
snapToInterval={Dimensions.get('screen').width/2}
snapToAlignment={"start"}
>
[...]
</ScrollView>
</View>
[...]
</SafeAreaView>
)
}
[...]

Comme on peut le voir maintenant, le scroll fonctionne bien dans le menu, mais on s’arrête entre deux images dans la vue du bas. Dans le code initialement créé, la ScrollView du bas se déplace d’autant que le menu du haut. C’est toujours le cas. Quand le menu du bas se déplace de la moitié de la largeur de l’écran, la liste du bas fait de même. Pour que la liste du bas affiche une photo complète, il faut qu’elle se déplace d’une largeur d’écran, quand le menu ne se déplace que d’une demi largeur. Il faut donc multiplier par deux le déplacement.

   [...]
onMenuScroll(event) {
if (lowerScrollView !== undefined) {
lowerScrollView.scrollTo({ x: event.nativeEvent.contentOffset.x*2, y: 0, animated: true })
}
}
[...]

Maintenant que notre menu est correctement coordonné avec la liste du bas, il nous faut ajouter la mise en avant des icônes du haut. C’est maintenant que la bibliothèque Animated va nous aider.

Nous allons initialiser une nouvelle variable “dynamique” xMenuPosition dans laquelle sera stockée la position de la ScrollView sur son axe des abscisses. (1) Pour cela, nous allons transformer la ScrollView de notre menu en une Animated.ScrollView et utiliser le callBack onScroll, qui nous permet de stocker cette valeur dynamiquement, sans influence sur le rendu. (2) Il ne faut pas oublier de garder l’appel à notre méthode onMenuScroll, qui gère le déplacement des listes. (3)

Nous allons lier la taille des images à la position du menu sur l’axe des abscisses. Nous transformons les composants Image du menu en Animated.Image. La taille des images du menu ne sera plus statique, mais dynamique, calculée en fonction de la position de la ScrollView. Nous allons donc créer une méthode getImageSize qui renverra la taille de l’image en fonction de son index et de la position de la ScrollView. (4)

[...]
import { StyleSheet, View, Dimensions, Text, ScrollView, SafeAreaView, Image, Animated } from 'react-native';
[...]
export default class AnimationLili extends React.Component {
constructor() {
[...]
this.state = {
xMenuPosition: new Animated.Value(0), //1
};
[...]
}
render() {
return (
<SafeAreaView style={{flex:1}}>
<View style={{ height: 200 }}>
<ScrollView
style={{ flex: 1 }}
horizontal={true}
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
onScroll={Animated.event(
[
{
nativeEvent: {
contentOffset: {
x: this.state.xMenuPosition //2
}
}
}
],
{
listener: this.onMenuScroll, //3
useNativeDriver: false
}
)}

scrollEventThrottle={16}
decelerationRate='fast'
snapToInterval={Dimensions.get('screen').width/2}
snapToAlignment={"start"}>
<View style={styles.menuView}>
<Animated.Image
resizeMode="cover"
source={require('../assets/img/photo1.jpg')}
style={[styles.icon, { width: this.getIconSize(0), height: this.getIconSize(0) }]} />

</View>
<View style={styles.menuView}>
<Animated.Image
resizeMode="cover"
source={require('../assets/img/photo2.jpg')}
style={[styles.icon, { width: this.getIconSize(1), height: this.getIconSize(1) }]} />

</View>
<View style={styles.menuView}>
<Animated.Image
resizeMode="cover"
source={require('../assets/img/photo3.jpg')}
style={[styles.icon, { width: this.getIconSize(2), height: this.getIconSize(2) }]} />

</View>
<View style={styles.menuView}>
<Animated.Image
resizeMode="cover"
source={require('../assets/img/photo4.jpg')}
style={[styles.icon, { width: this.getIconSize(3), height: this.getIconSize(3) }]} />

</View>
<View style={styles.menuView}>
<Animated.Image
resizeMode="cover"
source={require('../assets/img/photo5.jpg')}
style={[styles.icon, { width: this.getIconSize(4), height: this.getIconSize(4) }]} />

</View>
</ScrollView>
</View>
[...]
</SafeAreaView>
)
}
getIconSize(index) { //4
const { xMenuPosition } = this.state;
const spaceBetweenMenuIcon = Dimensions.get('screen').width / 2;
let outputRange = [75, 75, 75, 75, 75];
outputRange[index] = 150;
return xMenuPosition.interpolate({
inputRange: [0, spaceBetweenMenuIcon, 2 * spaceBetweenMenuIcon, 3 * spaceBetweenMenuIcon, 4 * spaceBetweenMenuIcon],
outputRange: outputRange,
extrapolate: 'clamp',
useNativeDriver: false
});
}

}
[...]

Et voici le rendu de cette modification. Nous avons maintenant le rendu espéré, avec un menu dont les images s’adaptent dynamiquement en fonction de leur position, et qui permet facilement de naviguer à travers nos listes.

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.