Nous allons nous concentrer aujourd'hui sur le système d'animation offert par React Native à travers la création d'un carousel pilotable au touché.
Animations et Performances
Il est possible avec React Native d'animer une valeur à l'aide de l'objet Animated
.
let animation = new Animated.Value(0)
Animated.timing(
animation,
{
toValue: 600,
duration: 300,
useNativeDriver: true
}
).start()
Afin d'obtenir des animations fluides on peut utiliser l'option useNativeDriver
qui va permettre de réaliser l'animation sans passer par la passerelle React <=> Natif pour chaque frames. Ce système permet d'envoyer l'interpolation à effectuer dès le démarrage de l'animation, sans être bloqué par le thread JS. Cependant il existe quelques limitations :
- On ne peut interpoler que les propriétés qui n'agissent pas sur la structure (
opacity
,transform
,backgroundColor
...) - On peut réagir a des évènements "direct" via
Animated.event
mais pas aux évènements déclenchés de manière indirect (bubbling) comme ça peut être le cas avec lesPanResponder
par exemple.
Pour obtenir plus d'informations sur l'utilisation du Native Driver, n'hésitez pas à faire un tour sur le React Native Blog.
Composer les animations
Un autre point important concerne la composition d'animations. Dans notre exemple, nous souhaitons déplacer 2 images avec des amplitudes différentes. Il serait possible de créer 2 animations séparées et de leur donner une amplitude différente, mais une solution plus simple existe. En effet, il est possible de composer une animation à travers des opérations arithmétiques.
let animation = new Animated.Value(0)
let animationSlow = Animated.divide(animation, 2)
let animationTranslated = Animated.add(animation, -300)
Animated.timing(
animation,
{
toValue: 600,
duration: 300,
useNativeDriver: true
}
).start()
Cette méthode permet de lier plusieurs animations ensemble afin qu'elles se déroulent ensemble avec des amplitudes différentes. Si vous voulez connaitre l'ensemble des méthodes disponibles, regardez les méthodes static d'Animated.
Gérer le touch
Afin de pouvoir bouger le carousel en fonction des gestes de l'utilisateur il nous faut un moyen de détecter certains évènements. React Native permet de détecter la pression du doigt sur l'écran à l'aide d'un système de Gesture Responder qui permet aux vues, à travers une série de propriétés, d'observer les gestes qui les l'affectent.
onStartShouldSetResponder: (evt) => true
, permet de déterminer si la vue doit devenir le responder lors de la pressiononMoveShouldSetResponder: (evt) => true
, est appellé à chaque déplacement fait sur la vue tant qu'elle n'est pas le responder et permet de déterminer si la vue doit devenir le responder
Si la vue renvoie true et devient le responder les propriétés suivantes sont appellées :
onResponderGrant: (evt) => {}
, la vue est devenue le responderonResponderReject: (evt) => {}
, la vue n'est pas le responder, un autre élément dans l'application est le responder.
Lors la vue est le responder d'autres fonctions peuvent être utilisées.
onResponderMove: (evt) => {}
, l'utilisateur est en train de bouger son doigt.onResponderRelease: (evt) => {}
, l'utilisateur a relaché son doigt de l'écran.onResponderTerminationRequest: (evt) => true
, un autre élément essaie de devenir le responder, en renvoyant true on laissera cet autre élément prendre le contrôle du responder.onResponderTerminate: (evt) => {}
, le responder a été repris par un autre élément.
Par défaut, lorsqu'un évènement est déclenché l'enfant le plus profond qui renvoie true
lors de l'appel à onStartShouldSetResponder
ou onMoveShouldSetResponder
prend le contrôle du responder. C'est un comportement que l'on souhaite parfois éviter avec un parent qui prend le contrôle quoi qu'il arrive. Dans ce cas là on dispose de 2 propriétés supplémentaires.
onStartShouldSetResponderCapture: (evt) => true
, capture l'évènement et empêche les enfant de devenir responder.onMoveShouldSetResponderCapture: (evt) => true
Ce système sert de base à la gestion du touch mais il existe aussi un autre type de responder qui simplifie la détection de mouvement : le PanResponder
. Il fonctionne comme un Gesture Responder classique à quelques détails près :
- les propriétés sont renommées en
PanResponsder
, par exempleonStartShouldSetResponder
devientonStartShouldSetPanResponder
- Les fonctions prennent en second paramètre un
gestureState
qui vous offre plus de contrôle sur l'état du mouvement.
Notre composant Carousel !
Maintenant que l'on a ces concepts en tête on peut décomposer notre composant.
export default class MoviesCarousel extends React.Component {
static propTypes = {
movies: React.PropTypes.array
}
constructor (props) {
super(props)
let {width} = Dimensions.get('window')
Dimensions.addEventListener('change', this.onResize.bind(this))
this.state = {
width: width,
page: 0,
translate: new Animated.Value(0)
}
}
// ...
}
On crée ici notre state qui va contenir :
- La largeur de l'écran.
- La page courante à afficher (0 par défaut).
- La valeur animer qui servira à effectuer la translation.
- La liste des films, qui sera passé à travers une propriété.
On rajoute un écouteur afin de changer le state lors du redimensionnement.
Lorsqu'on monte le composant on crée un PanResponder
qui devient le responder si on détecte un déplacement horizontal supérieur à 7px. On utilise le Animated.event
pour transférer la translation du gestureState vers notre valeur animée. La méthode endGesture
sera déclenché à la fin d'un mouvement et sera chargée d'aller à la page suivante ou précédente au besoin.
componentWillMount () {
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => false,
onStartShouldSetPanResponderCapture: (evt, gestureState) => false,
onMoveShouldSetPanResponder: (evt, gestureState) => Math.abs(gestureState.dx) > 7,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
onPanResponderTerminationRequest: () => false,
// Cette ligne est importante, la magie est ici !
onPanResponderMove: Animated.event([null, {dx: this.state.translate}]),
onPanResponderRelease: this.endGesture.bind(this),
onPanResponderTerminate: (evt, gestureState) => {
Animated.timing(
this.state.translate,
{
toValue: 0,
duration: 300,
useNativeDriver: true
}
).start()
},
onShouldBlockNativeReponser: (evt, gestureState) => true
})
}
Enfin, dans nous créons une Animated.View
qui utilisera le panResponder et qui se verra appliquer une transformation en fonction de la valeur animée. On ajoutera des slides avant et après le carousel afin de simuler un carousel infini et on utilise la composition d'animation afin de déplacer le poster pour donner un effet de parallax.
getStyle () {
return {
slider: {
flexDirection: 'row',
height: 390,
backgroundColor: '#1B1B1B',
width: (this.props.movies.length + 2) * this.state.width,
left: (this.state.page + 1) * -1 * this.state.width,
transform: [{
translateX: this.state.translate
}]
},
// ...
}
}
posterTranslate(index) {
let factor = 2
if (index === this.state.page) {
return this.translateX(
Animated.divide(this.state.translate, factor)
)
}
if (index === this.state.page + 1) {
return this.translateX(
Animated.divide(
Animated.add(this.state.translate, this.state.width),
factor
)
)
}
if (index === this.state.page - 1) {
return this.translateX(
Animated.divide(
Animated.add(this.state.translate, this.state.width * -1),
factor
)
)
}
return this.translateX(new Animated.Value(0))
}
translateX (animation) {
return {
transform: [{
translateX: animation
}]
}
}
renderMovie (movie, k) {
const style = this.getStyle()
return (
<View key={k} style={style.slide}>
<TouchableHighlight onPress={() => this.showMovie(movie)}>
<Image source={movie.screen} style={style.screen}/>
</TouchableHighlight>
<Animated.Image source={movie.poster} style={[style.poster, this.posterTranslate(k)]}/>
<View style={style.titleContainer}>
<Animated.Text style={[style.title, this.posterTranslate(k)]}>{movie.name}</Animated.Text>
</View>
</View>
)
}
render () {
const style = this.getStyle()
return (
<Animated.View {...this.panResponder.panHandlers} style={style.slider}>
{this.renderMovie(this.props.movies[this.props.movies.length - 1], -1)}
{this.props.movies.map(this.renderMovie.bind(this))}
{this.renderMovie(this.props.movies[0], this.props.movies.length)}
</Animated.View>
)
}
Voila pour le principe de base, à vous d'imaginer des cas d'utlisation maintenant ;).