Créer une BottomBar/TabBar personnalisée en se basant sur React-Native-Navigation

La majorité des projets que je développe utilise la BottomBar/TabBar pour leur menu (dans la suite de l’article, j’utiliserai le terme de TabBar uniquement).

Les porteurs de projet sont souvent demandeurs de rester dans les schémas établis par les éditeurs, mais certains préféreraient une TabBar complètement personnalisée, pour marquer une différence.

Alors j’ai voulu faire un test pour utiliser le navigation native de React-Native-Navigation, tout en ajoutant une TabBar personnalisé en lieu et place de la TabBar native.

Création de la TabBar personnalisée

Nous allons créer un nouveau fichier dans notre dossier component, que nous allons tout simplement nommé tabbar.js. Comme pour les autres écrans nous allons le déclarer dans constants.js et dans screens.js.

export const screenIDTabBar = ‘fr.onthebeachdemo.tabbar’;

et

import { Navigation } from ‘react-native-navigation’;import {
[...]
screenIDTabBar
} from ‘./constants’;
[...]import TabBar from ‘../components/tabbar’;export function registerScreens() {
[...]
Navigation.registerComponent(screenIDAbout, () => TabBar);
}

Le but va être d’ouvrir cet écran en tant qu’overlay avec React-Native-Navigation. Ainsi, il va s’afficher par dessus, notre application. Dans cet écran, nous allons donc créer une vue qui aura la même taille que la TabBar, pour qu’elle vienne s’afficher par dessus, et nous spécifierons dans les options de l’overlay, que cet écran ne doit pas intercepter les actions faites en dehors de cette vue (avec l’option interceptTouchOutside à false). Egalement, nous devrons préciser dans l’objet layout que le fond doit être transparent pour que sur iOS, il ne soit pas blanc, ce qui masquerait le contenu de l’application.

Commençons par créer cet écran, en affichant une vue de 60 de haut, prenant toute la largeur, et alignée vers le bas, pour comprendre le principe de cet overlay.

Voici le code de notre écran tabbar.js

import React from ‘react’;
import { StyleSheet, View, Dimensions } from ‘react-native’;
export default class TabBar extends React.Component {
constructor(props) {
super(props);
this.state = {
}
}
componentDidMount() {
}
render() {
return (
<View style={styles.tabbar} />
);
}
}
const styles = StyleSheet.create({
tabbar: {
height:60,
width:Dimensions.get(‘screen’).width,
backgroundColor:’#f00',
position:’absolute’,
bottom:0
}
});

Nous allons afficher cet écran en overlay. Pour cela nous allons appeler la méthode showOverlay de React-Native-Navigation, juste après avoir défini notre TabBar en tant que root, dans notre fichier index.js.

import { Navigation } from ‘react-native-navigation’;
import {
[...]
screenIDTabBar
} from ‘./app/utils/constants’;
[...]Navigation.events().registerAppLaunchedListener(() => {
initIcons().then(() => {
Navigation.setRoot({
[...]
});
Navigation.showOverlay({
component: {
name: screenIDTabBar,
options: {
layout: {
componentBackgroundColor: “transparent”
},
overlay: {
interceptTouchOutside: false
}
}
},
})
});
});

Dans mon fichier demos.js, je rajoute un bouton pour afficher une alerte qui dit “coucou”, afin de montrer que l’affichage de cet overlay n’empêche pas les actions dans l’application.

Premier étape de la mise en place d’un TabBar personnalisée

On peut voir que sur iOS, la TabBar n’est que partiellement recouverte, alors que sur Android, elle l’est complètement. Pour être sûr d’avoir une TabBar personnalisé de la même dimensions que la TabBar native, nous allons utiliser les constantes de React-Native-Navigation qui nous donne les hauteurs de la NavigationBar, de la StatusBar et de la TabBar.

Dans la méthode componentDidMount() appelée au chargement de l’écran tabbar.js, nous allons donc appeler la méthode constants(), pour enregistrer la hauteur de la TabBar native, et appliqué cette hauteur à notre TabBar personnalisée. En cas d’erreur de la méthode constants(), je ne fais rien de plus. La hauteur par défaut étant 0, cela signifie que nous n’auront pas de vue au dessus de notre TabBar native, et donc l’application fonctionnera toujours.

Le code de notre écran tabbar.js devient :

import React from ‘react’;
import { StyleSheet, View, Dimensions } from ‘react-native’;
import { Navigation } from ‘react-native-navigation’;
export default class TabBar extends React.Component {
constructor(props) {
super(props);
this.state = {
tabbarHeight:0
}
}
componentDidMount() {
this.getBottomTabsHeight();
}
render() {
return (
<View style={[styles.tabbar, {width:Dimensions.get(‘screen’).width, height:this.state.tabbarHeight}]} />
);
}
getBottomTabsHeight = () => {
Navigation.constants().then((constants) => {
this.setState({tabbarHeight:constants.bottomTabsHeight});
}).catch((error) => {
//TODO : Gestion de l’erreur
});
}
}
const styles = StyleSheet.create({
tabbar: {
backgroundColor:’#f00',
position:’absolute’,
bottom:0
}
});
La TabBar native est maintenant entièrement recouverte

Il faut maintenant gérer le cas de l’orientation. En effet, vous verrez que si vous mettez vos simulateurs en paysage, la TabBar n’est pas redessinée et ne recouvre que la moitié de la TabBar native en largeur. Nous allons donc faire appel à la méthode addEventListener de la bibliothèque Dimensions, qui sera déclenchée à chaque changement de dimensions de l’écran (donc quand il est tourné). À chaque déclenchement, on appellera à nous la méthode getBottomTabsHeight(), qui mettra à jour la hauteur et donc l’écran.

Les modifications à apporter à tabbar.js sont très simples.

componentDidMount() {
this.getBottomTabsHeight();
Dimensions.addEventListener(‘change’, () => {
this.getBottomTabsHeight();
});
}
componentWillUnmount() {
Dimensions.removeEventListener('change');
}

Il nous faut maintenant, ajouter les 2 boutons de notre TabBar pour afficher les écrans correspondant. Pour cela nous appellerons la méthode mergeOptions de la bibliothèque React-Native-Navigation. Nous devons donner un ID unique à notre bottomTabs, puis indiquer à React-Native-Navigation l’index de l’écran à afficher (les index étant 0 pour l’écran demos.js, 1 pour l’écran articles.js et 2 pour l’écran about.js).

L’idée va être d’afficher seulement les icônes, sauf pour l’élément sélectionné qui aura son titre également. Pour cela, nous allons créer un tableau contenant les 3 éléments de la TabBar, avec pour chacun l’icône et le titre associé. Une boucle nous permettra de créer la vue de chaque élément, et d’afficher ou non le titre de l’élément si celui-ci est sélectionné.

Commençons par donner un ID unique à notre bottomTabs. Je vais déclarer cet ID dans le fichier constants.js, et y faire appel dans mon fichier index.js pour l’affecter à la bottomTabs, puis dans mon fichier tabbar.js pour changer les écrans.

Commençons par le changement dans index.js

Navigation.setRoot({
root: {
bottomTabs: {
id:’bottomTabsId’,
[…]
}
}
});

Puis le changement de tabbar.js

import React from ‘react’;
import { StyleSheet, View, Dimensions, Text, TouchableOpacity } from ‘react-native’;
import { Navigation } from ‘react-native-navigation’;
import IconFont from ‘react-native-vector-icons/MaterialIcons’;
export default class TabBar extends React.Component {
constructor(props) {
super(props);
this.state = {
tabbarHeight: 0,
selectedIndex: 0
}
}
[...]
render() {
const tabArray = [{ icon: ‘code’, title: ‘Démos’ }, { icon: ‘menu’, title: ‘Articles’ }, { icon: ‘info’, title: ‘À propos’ }];
let tabsView = [];
for (const aTabIndex in tabArray) {
const aTabElement = tabArray[aTabIndex];
const aTitleView = parseInt(aTabIndex) === this.state.selectedIndex ? <Text style={styles.tabTitle}>{aTabElement.title}</Text> : null;
tabsView.push(<TouchableOpacity key={aTabIndex} style={styles.tab} onPress={() => this.showScreenWithIndex(parseInt(aTabIndex))}>
<IconFont name={aTabElement.icon} size={24} color=’#fff’ />
{aTitleView}
</TouchableOpacity>);
}
return (
<View style={[styles.tabbar, { width: Dimensions.get(‘screen’).width, height: this.state.tabbarHeight }]}>
{tabsView}
</View>
);
}
[...]
showScreenWithIndex = (screenIndex) => {
if (screenIndex !== this.state.selectedIndex) {
Navigation.mergeOptions(‘bottomTabsId’, {
bottomTabs: {
currentTabIndex: screenIndex
}
});
this.setState({ selectedIndex: screenIndex });
}
}
}
const styles = StyleSheet.create({
tabbar: {
flexDirection: ‘row’,
backgroundColor: ‘#f00’,
position: ‘absolute’,
bottom: 0,
paddingTop: 5
},
tab: {
flex: 1,
height: ‘100%’,
justifyContent: ‘flex-start’,
alignItems: ‘center’
},
tabTitle: {
color: ‘#fff’
}
});

On peut maintenant voir que la TabBar fonctionne bien.

Fonctionnement basique de la TabBar

Le but d’avoir une TabBar personnalisée est d’avoir un comportement original. Or ici, nous avons simplement reproduit un comportement de base. Pour produire un résultat original, je me suis inspiré du widget Flutter google_nav_bar, designé par Aurelien Salomon et développé par sooxt98, dont la version 2 a été publiée cette semaine.

Démonstration du comportement de la google_nav_bar

Dans un premier temps nous allons :

  • passer la couleur de fond de notre TabBar en blanc,
  • associer une couleur à chaque élément,
  • associé cette couleur en tant que couleur de fond de chaque élément, s’ils sont sélectionnés,
  • et enfin afficher le titre de l’élément à droite de l’icône et non en dessous.

Nous allons légèrement modifier les boutons pour qu’il puisse prendre la forme souhaitée quand ils sont sélectionnés.

Voici les changements à effectuer dans tabbar.js

[…]
export default class TabBar extends React.Component {
[…]
render() {
const tabArray = [{ icon: ‘code’, title: ‘Démos’, color:’#81C784' }, { icon: ‘menu’, title: ‘Articles’, color:’#4FC3F7' }, { icon: ‘info’, title: ‘À propos’, color:’#FF8A65' }];
let tabsView = [];
for (const aTabIndex in tabArray) {
const aTabElement = tabArray[aTabIndex];
const aTitleView = parseInt(aTabIndex) === this.state.selectedIndex ? <Text style={styles.tabTitle}>{aTabElement.title}</Text> : null;
const aBackgroundColor = parseInt(aTabIndex) === this.state.selectedIndex ? aTabElement.color : ‘#fff’;
tabsView.push(<View style={styles.tab} key={aTabIndex}>
<TouchableOpacity style={[styles.tabButton, {backgroundColor:aBackgroundColor}]} onPress={() => this.showScreenWithIndex(parseInt(aTabIndex))}>
<IconFont name={aTabElement.icon} size={24} color=’#909090' />
{aTitleView}
</TouchableOpacity>
</View>);
}
return (
<View style={[styles.tabbar, { width: Dimensions.get(‘screen’).width, height: this.state.tabbarHeight }]}>
{tabsView}
</View>
);
}
[…]
}
const styles = StyleSheet.create({
tabbar: {
flexDirection: ‘row’,
backgroundColor: ‘#fff’,
position: ‘absolute’,
bottom: 0,
paddingTop: 5
},
tab: {
flex: 1,
height: ‘100%’,
justifyContent: ‘flex-start’,
alignItems: ‘center’
},
tabButton: {
paddingVertical:5,
paddingHorizontal:10,
flexDirection:’row’,
justifyContent: ‘center’,
alignItems: ‘center’,
borderRadius:20
},
tabTitle: {
color: ‘#fff’,
paddingLeft:10
}
});

Nous obtenons déjà un résultat différent, mais il manque de fluidité. Nous allons donc ajouter des animations pour le rendre plus fluide. Le but est d’agrandir la zone colorée, et ensuite d’y afficher le texte.

Nous n’allons pas aller au bout de la finesse du package google_nav_bar, mais nous allons quand même essayer d’arriver à un résultat appréciable.

Nous allons donc utiliser la bibliothèque Animated. Nous allons ajouter la variable animatedSelectedIndex qui nous permettra de déclencher les animations. Cette variable sera une copie du selectedIndex, et aura pour effet de changer la taille des éléments de la TabBar, la couleur de fond de l’élément, ainsi que l’opacité du texte. Nous allons donc également créer 3 méthodes pour gérer ces 3 valeurs. Nous changerons également la couleur de l’icône pour qu’elle soit blanche quand l’élément est sélectionné, et grise sinon. Enfin nous changerons le code de la méthode showScreenWithIndex() pour changer la valeur de notre variable animatedSelectedIndex, et déclencher les animations.

Voilà donc le code de tabbar.js pour prendre en compte ces changements.

import React from ‘react’;
import { StyleSheet, View, Dimensions, Text, TouchableOpacity, Animated } from ‘react-native’;
[...]
export default class TabBar extends React.Component {
constructor(props) {
super(props);
this.state = {
tabbarHeight: 0,
selectedIndex: 0,
//Ajout d'une variable animée
animatedSelectedIndex: new Animated.Value(0)
}
}
[...]
render() {
[...]
for (const aTabIndex in tabArray) {
const aTabElement = tabArray[aTabIndex];
const aTabIndexAsInt = parseInt(aTabIndex);
//Animation du texte pour en changer l'opacité
const aTitleView = aTabIndexAsInt === this.state.selectedIndex ? <Animated.Text style={[styles.tabTitle, { opacity: this.getTitleOpacity(aTabIndexAsInt) }]}>{aTabElement.title}</Animated.Text> : null;
const anIconColor = aTabIndexAsInt === this.state.selectedIndex ? '#fff' : '#909090';
tabsView.push(<View style={styles.tab} key={aTabIndex}>
<Animated.View style={[styles.tabAnimated, { backgroundColor: this.getTabColor(aTabIndex, aTabElement.color), width: this.getTabWidth(aTabIndexAsInt) }]}>
<TouchableOpacity style={styles.tabButton} onPress={() => this.showScreenWithIndex(aTabIndexAsInt)}>
<IconFont name={aTabElement.icon} size={24} color={anIconColor} />
{aTitleView}
</TouchableOpacity>
</Animated.View>
</View>);
}
return (
<View style={[styles.tabbar, { width: Dimensions.get(‘screen’).width, height: this.state.tabbarHeight }]}>
{tabsView}
</View>
);
}
//Animation de la couleur de fond
getTabColor = (tabIndex, color) => {
const { animatedSelectedIndex } = this.state;
let outputRange = ['#fff', '#fff', '#fff'];
outputRange[tabIndex] = color;
return animatedSelectedIndex.interpolate({
inputRange: [0, 1, 2],
outputRange: outputRange,
extrapolate: 'clamp',
useNativeDriver: true
});
}
//Animation de la largeur de l'élément
getTabWidth = (tabIndex) => {
const { animatedSelectedIndex } = this.state;
let outputRange = [50, 50, 50];
outputRange[tabIndex] = 100;
return animatedSelectedIndex.interpolate({
inputRange: [0, 1, 2],
outputRange: outputRange,
extrapolate: ‘clamp’,
useNativeDriver: true
});
}
//Animation de l'opacité du titre
getTitleOpacity = (tabIndex) => {
const { animatedSelectedIndex } = this.state;
//Ici nous allons ajouter des valeurs intermédiaire entre les index, pour que le changement ne soit pas linéaire
let outputRange = [0, 0, 0, 0, 0];
outputRange[2*tabIndex] = 1;
outputRange = [1, 0, 0, 0, 0];
return animatedSelectedIndex.interpolate({
inputRange: [0, 0.75, 1, 1.75, 2],
outputRange: outputRange,
extrapolate: ‘clamp’,
useNativeDriver: true
});
}
[...]
showScreenWithIndex = (screenIndex) => {
if (screenIndex !== this.state.selectedIndex) {
Navigation.mergeOptions(‘bottomTabsId’, {
bottomTabs: {
currentTabIndex: screenIndex
}
});
this.setState({ selectedIndex: screenIndex });
Animated.timing(this.state.animatedSelectedIndex, {
toValue: screenIndex,
duration: 200
}).start(() => {});
}
}
}
const styles = StyleSheet.create({
[...]
tabAnimated: {
height: 40,
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 20,
justifyContent: ‘center’,
alignItems: ‘center’
},
tabButton: {
flexDirection: ‘row’,
justifyContent: ‘flex-start’,
alignItems: ‘center’
},
[...]
});

Et voilà le résultat !

Animation finale des changements de tab !

--

--

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

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Axel de Sainte Marie

Axel de Sainte Marie

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