The Movie Db / React Native Partie 1 : Les Composants

Dans cette série d'articles nous allons créer une simple application de découverte de série qui sera basée sur l'api The Movie Db. Cette série seras composé de trois articles :

  • ‍Partie 1 : Création des composants
  • ‍Partie 2 : La navigation
  • ‍Partie 3 : Les données

Un aperçu du résultat est disponnible sur youtube.

Dans cette partie vous n'aurez pas besoin d'api key le projet contiendra des données par défaut. Commençons par créer un nouveau projet :

Durant la création des composants nous allons avoir besoin d'utiliser des icônes pour cela installons la librairie react-native-vector-icons

yarn add react-native-vector-icons
react-native link

Maintenant passons à la création des composants commençons par créer un dossier src à la racine de l'application et un fichier index.js qui sera le point d'entrée de l'application.

mkdir src
cd src && touch index.js‍

Collez le code suivant dans le fichier index.js‍

import React, { Component } from 'react'
import { AppRegistry, StyleSheet, Text, View } from 'react-native'

export default class App extends Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.welcome}>Welcome to React Native!</Text>
        <Text style={styles.instructions}>
          To get started, edit index.ios.js
        </Text>
        <Text style={styles.instructions}>
          Press Cmd+R to reload,
          {'\n'}
          Cmd+D or shake for dev menu
        </Text>
      </View>
    )
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
  },
  welcome: {
    fontSize: 20,
    textAlign: 'center',
    margin: 10,
  },
  instructions: {
    textAlign: 'center',
    color: '#333333',
    marginBottom: 5,
  },
})

‍Remplacez le code des fichiers index.ios.js et index.android.js par le code suivant :

import { AppRegistry } from 'react-native'
import App from './src/index.js'
AppRegistry.registerComponent('AwesomeSerie', () => App)

Créons un dossier components dans lequel nous ajouterons l'ensemble des composants

‍mkdir components

Le premier composant que nous allons créer est le composant ImageWithOverlay qui sera utilisé pour présenter les séries dans l'écran principale.

ImageWithOverlay.js

'use strict'
import React, { Component } from 'react'
import { StyleSheet, View, Image } from 'react-native'

class ImageWithOverlay extends Component {
  constructor(props) {
    super(props)
  }

  render() {
    return (
      <View>
        <Image
          resizeMode="stretch"
          style={{ height: this.props.height, width: this.props.width }}
          source={{ uri: this.props.src }}
        />
        <View style={styles.overlay} />
      </View>
    )
  }
}

ImageWithOverlay.defaultProps = {
  src:
    'https://image.tmdb.org/t/p/w600_and_h900_bestv2/3iYNC7Iw6a65ed5GZz7KbInSHBd.jpg',
  width: 375, //iphone 6
  height: 667, // iphone 6
}

ImageWithOverlay.propTypes = {
  src: React.PropTypes.string.isRequired,
  width: React.PropTypes.number.isRequired,
  height: React.PropTypes.number.isRequired,
}

export default ImageWithOverlay

const styles = StyleSheet.create({
  overlay: {
    position: 'absolute',
    left: 0,
    top: 0,
    bottom: 0,
    right: 0,
    opacity: 0.7,
    backgroundColor: 'black',
  },
})

Ensuite dans l'aperçu de l'application on peut voir que sur la fiche de description d'une série il y à une image avec une diagonale nous allons donc créer le composant DiagonalImage.

DiagonalImage.js

'use strict'
import React, { Component } from 'react'
import { StyleSheet, View, Image } from 'react-native'

class DiagonalImage extends Component {
  constructor(props) {
    super(props)
  }

  render() {
    return (
      <View>
        <Image
          resizeMode="stretch"
          style={{ height: this.props.height, width: this.props.width }}
          source={{ uri: this.props.src }}
        />
        <View
          style={[
            {
              borderRightWidth: this.props.width,
              borderTopWidth: this.props.height / 3.5,
            },
            styles.triangle,
          ]}
        />
      </View>
    )
  }
}

DiagonalImage.defaultProps = {
  src:
    'https://image.tmdb.org/t/p/w600_and_h900_bestv2/3iYNC7Iw6a65ed5GZz7KbInSHBd.jpg',
  width: 375, //iphone 6
  height: 667, // iphone 6
}

DiagonalImage.propTypes = {
  src: React.PropTypes.string.isRequired,
  width: React.PropTypes.number.isRequired,
  height: React.PropTypes.number.isRequired,
}

export default DiagonalImage

const styles = StyleSheet.create({
  triangle: {
    width: 0,
    height: 0,
    backgroundColor: 'transparent',
    borderStyle: 'solid',
    borderRightColor: 'transparent',
    borderTopColor: 'white',
    transform: [{ rotate: '180deg' }],
    position: 'absolute',
    bottom: 0,
    right: 0,
  },
})

Revenons un instant sur le principe de ce composant. Afin de créer cet éffet de diagonnale nous devons créer une vue qui aura la forme d'un triangle, la couleur du background de l'écran et seras positionner en bas à droite de l'image.

Voici le style qui permet de un triangle à partir d'une vue :

triangle:{
  width: 0,
  height: 0,
  backgroundColor: 'transparent',
  borderStyle: 'solid',
  borderTopWidth: 90,
  borderRightWidth: 90,
  borderRightColor: 'transparent',
  borderTopColor: 'white',
  transform: [
    {rotate: '180deg'}
  ],
  position:'absolute',
  bottom:0,
  right:0,
}

Le code ci-dessus créera un triangle de 90*90 dans notre cas nous voulons que la largeur soit égale à la largeur de l'écran et que la hauteur soit un ratio de la hauteur de l'image, nous définirons donc les deux propriétés borderTopWidth et borderRightWidth de manière dynamique.

  • borderTopWidth : hauteur de l'image / 3.5
  • borderRightWidth : largeur de l'écran

Si vous voulez en savoir plus sur comment créer différentes formes avec de simples vues et du style vous pouvez aller voir cet article.

Avant la fiche détaillée de la série nous allons créer un composant qui permettras d'afficher les infomartions complémentaires sur la série.

IconInfos.js

'use strict'
import React, { Component } from 'react'
import { StyleSheet, Text, View, Image } from 'react-native'
import Icon from 'react-native-vector-icons/FontAwesome'

class IconInfos extends Component {
  constructor(props) {
    super(props)
  }

  render() {
    return (
      <View style={styles.container}>
        <Icon name={this.props.iconName} size={16} color={this.props.color} />
        <Text
          style={{
            marginLeft: 13,
            fontWeight: 'bold',
            color: this.props.color,
          }}
        >
          {this.props.text}
        </Text>
      </View>
    )
  }
}

IconInfos.propTypes = {
  iconName: React.PropTypes.string.isRequired,
  color: React.PropTypes.string.isRequired,
  text: React.PropTypes.string.isRequired,
}
IconInfos.defaultProps = {
  iconName: 'flag',
  color: '#575050',
  text: 'FR',
}

export default IconInfos

const styles = StyleSheet.create({
  container: {
    justifyContent: 'flex-start',
    flexDirection: 'row',
  },
})

Passons maintenant à la création de la fiche détaillée d'une série. Voici l'ensemble des propriétés que recevras ce composant.

  • serieItem : objet contenant les informations sur la série
  • goBack : fonction de navigation qui permettra de revenir à l'écran précédent

SerieDetail.js

'use strict'
import React, { Component } from 'react'
import {
  StyleSheet,
  Text,
  View,
  Image,
  ScrollView,
  TouchableOpacity,
} from 'react-native'
import DiagonalImage from './DiagonalImage'
import IconInfos from './IconInfos'
import Icon from 'react-native-vector-icons/FontAwesome'

class SerieDetail extends Component {
  constructor(props) {
    super(props)
    this.state = {
      width: 0,
      height: 0,
    }
  }

  _onLayout(event) {
    var { x, y, width, height } = event.nativeEvent.layout
    this.setState({
      width: width,
      height: height,
    })
  }

  render() {
    const diagonalImageHeight = this.state.height / 2.1
    return (
      <ScrollView
        style={{ backgroundColor: 'white' }}
        onLayout={event => {
          this._onLayout(event)
        }}
      >
        <View style={styles.container}>
          <DiagonalImage
            src={
              'https://image.tmdb.org/t/p/w500/' +
              this.props.serieItem.poster_path
            }
            height={diagonalImageHeight}
            width={this.state.width}
          />
          <TouchableOpacity
            style={styles.back}
            onPress={() => this.props.goBack()}
          >
            <Icon name="chevron-left" color="white" size={26} />
          </TouchableOpacity>
          <View style={[styles.subInfos, { width: this.state.width }]}>
            <IconInfos
              iconName="flag"
              text={this.props.serieItem.origin_country[0]}
            />
            <IconInfos
              iconName="star"
              text={this.props.serieItem.vote_average.toString()}
            />
            <IconInfos
              iconName="calendar"
              text={this.props.serieItem.first_air_date.split('-')[0]}
            />
          </View>
          <View>
            <View style={styles.infos}>
              <Text style={styles.title}>
                {this.props.serieItem.original_name}
              </Text>
              <Text style={styles.description}>
                {this.props.serieItem.overview}
              </Text>
            </View>
          </View>
        </View>
      </ScrollView>
    )
  }
}

SerieDetail.propTypes = {
  serieItem: React.PropTypes.object.isRequired,
  goBack: React.PropTypes.func.isRequired,
}
SerieDetail.defaultProps = {
  serieItem: {
    poster_path: '/mBDlsOhNOV1MkNii81aT14EYQ4S.jpg',
    popularity: 54.910076,
    id: 44217,
    backdrop_path: '/A30ZqEoDbchvE7mCZcSp6TEwB1Q.jpg',
    vote_average: 6.88,
    overview:
      "Vikings follows the adventures of Ragnar Lothbrok, the greatest hero of his age. The series tells the sagas of Ragnar's band of Viking brothers and his family, as he rises to become King of the Viking tribes. As well as being a fearless warrior, Ragnar embodies the Norse traditions of devotion to the gods. Legend has it that he was a direct descendant of Odin, the god of war and warriors.",
    first_air_date: '2013-03-03',
    origin_country: ['IE', 'CA'],
    genre_ids: [18, 10759],
    original_language: 'en',
    vote_count: 399,
    name: 'Vikings',
    original_name: 'Vikings',
  },
  goBack: () => console.log('go back'),
}

export default SerieDetail

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'flex-start',
    alignItems: 'center',
    backgroundColor: 'white',
  },
  back: {
    position: 'absolute',
    top: 24,
    left: 16,
    backgroundColor: 'transparent',
  },
  infos: {
    justifyContent: 'flex-start',
    alignItems: 'center',
    padding: 16,
    marginTop: 12,
  },
  subInfos: {
    justifyContent: 'space-between',
    flexDirection: 'row',
    paddingHorizontal: 16,
    marginTop: 12,
  },
  title: {
    color: '#575050',
    fontWeight: 'bold',
    fontSize: 18,
    marginBottom: 20,
  },
  description: {
    color: '#575050',
    fontWeight: '500',
    fontSize: 14,
    textAlign: 'center',
  },
})

Pour terminer nous allons créer la listView qui permettra d'afficher la liste des série récupérées via l'api ainsi que le composant listViewItem. Commençons par créer le composant listViewItem voici ces propriétés :

onItemPress : Affiche la fiche détaillé de l'image

  • image : Url de l'image de l'affiche
  • height : Hauteur
  • width : Largeur
  • title : Titre de la série
  • description : Description de la série ListViewItem.js
'use strict'
import React, { Component } from 'react'
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native'
import ImageWithOverlay from './ImageWithOverlay'

class ListViewItem extends Component {
  constructor(props) {
    super(props)
  }

  render() {
    return (
      <TouchableOpacity
        activeOpacity={0.5}
        onPress={() => this.props.onItemPress()}
      >
        <ImageWithOverlay
          src={this.props.image}
          height={this.props.height}
          width={this.props.width}
        />
        <View style={styles.infosContainer}>
          <Text elispsisMode="tail" numberOfLines={1} style={styles.title}>
            {this.props.title}
          </Text>
          <View style={styles.separator} />
          <Text
            elispsisMode="tail"
            numberOfLines={4}
            style={styles.description}
          >
            {this.props.description}
          </Text>
        </View>
      </TouchableOpacity>
    )
  }
}

export default ListViewItem

const styles = StyleSheet.create({
  infosContainer: {
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    height: 150,
    padding: 16,
    backgroundColor: 'transparent',
  },
  title: {
    color: 'white',
    fontWeight: 'bold',
    fontSize: 18,
  },
  description: {
    color: 'white',
    textAlign: 'justify',
  },
  separator: {
    backgroundColor: 'white',
    height: 1,
    marginTop: 8,
    marginBottom: 8,
  },
})

Enfin passons à la listView voici ces propriétés :

  • data: liste des données renvoyées par l'api
  • showDetail: fonction de navigation qui permet d'afficher la fiche détaillée d'une série,
  • nextPage: page suivante de résultat,
  • hasMoreResult: définit si il reste des pages à charger,

SerieListView.js

'use strict'
import React, { Component } from 'react'
import {
  StyleSheet,
  Text,
  View,
  ListView,
  TouchableOpacity,
} from 'react-native'
import ListViewItem from './ListViewItem'
import Icon from 'react-native-vector-icons/FontAwesome'

class SerieListView extends Component {
  constructor(props) {
    super(props)
    const ds = new ListView.DataSource({
      rowHasChanged: (r1, r2) => r1 !== r2,
    })
    this.state = {
      ds: ds,
      dataSource: ds.cloneWithRows(this.props.data),
    }
  }

  _onLayout(event) {
    var { x, y, width, height } = event.nativeEvent.layout
    this.setState({
      width: width,
      height: height,
    })
  }

  _onEndReached() {
    if (this.props.hasMoreResult) {
      //Fetch data
    }
  }

  render() {
    return (
      <View style={{ flex: 1 }}>
        <ListView
          style={{ backgroundColor: '#706666' }}
          onEndReached={() => this._onEndReached()}
          onEndReachedThreshold={10}
          enableEmptySections={true}
          onLayout={event => {
            this._onLayout(event)
          }}
          dataSource={this.state.dataSource}
          renderRow={rowData => (
            <ListViewItem
              onItemPress={() => this.props.showDetail(rowData)}
              title={rowData.original_name}
              description={rowData.overview}
              image={'https://image.tmdb.org/t/p/w500/' + rowData.poster_path}
              height={this.state.height}
              width={this.state.width}
            />
          )}
        />
      </View>
    )
  }
}
SerieListView.propTypes = {
  data: React.PropTypes.array.isRequired,
  showDetail: React.PropTypes.func.isRequired,
  nextPage: React.PropTypes.number.isRequired,
  hasMoreResult: React.PropTypes.bool.isRequired,
}

SerieListView.defaultProps = {
  data: [
    {
      poster_path: '/mBDlsOhNOV1MkNii81aT14EYQ4S.jpg',
      popularity: 54.910076,
      id: 44217,
      backdrop_path: '/A30ZqEoDbchvE7mCZcSp6TEwB1Q.jpg',
      vote_average: 6.88,
      overview:
        "Vikings follows the adventures of Ragnar Lothbrok, the greatest hero of his age. The series tells the sagas of Ragnar's band of Viking brothers and his family, as he rises to become King of the Viking tribes. As well as being a fearless warrior, Ragnar embodies the Norse traditions of devotion to the gods. Legend has it that he was a direct descendant of Odin, the god of war and warriors.",
      first_air_date: '2013-03-03',
      origin_country: ['IE', 'CA'],
      genre_ids: [18, 10759],
      original_language: 'en',
      vote_count: 399,
      name: 'Vikings',
      original_name: 'Vikings',
    },
    {
      poster_path: '/vHXZGe5tz4fcrqki9ZANkJISVKg.jpg',
      popularity: 35.357012,
      id: 19885,
      backdrop_path: '/bvS50jBZXtglmLu72EAt5KgJBrL.jpg',
      vote_average: 7.79,
      overview:
        'A modern update finds the famous sleuth and his doctor partner solving crime in 21st century London.',
      first_air_date: '2010-07-25',
      origin_country: ['GB'],
      genre_ids: [80, 18, 9648],
      original_language: 'en',
      vote_count: 381,
      name: 'Sherlock',
      original_name: 'Sherlock',
    },
    {
      poster_path: '/igDhbYQTvact1SbNDbzoeiFBGda.jpg',
      popularity: 30.675316,
      id: 57243,
      backdrop_path: '/cVWsigSx97cTw1QfYFFsCMcR4bp.jpg',
      vote_average: 6.83,
      overview:
        "The Doctor looks and seems human. He's handsome, witty, and could be mistaken for just another man in the street. But he is a Time Lord: a 900 year old alien with 2 hearts, part of a gifted civilization who mastered time travel. The Doctor saves planets for a living – more of a hobby actually, and he's very, very good at it. He's saved us from alien menaces and evil from before time began – but just who is he?",
      first_air_date: '2005-03-26',
      origin_country: ['GB'],
      genre_ids: [10759, 18, 10765],
      original_language: 'en',
      vote_count: 339,
      name: 'Doctor Who',
      original_name: 'Doctor Who',
    },
  ],
  showDetail: serie => console.log('show detail ' + JSON.stringify(serie)),
  nextPage: 1,
  hasMoreResult: true,
}
export default SerieListView

L'évenement onEndReached de la listView nous permettras par la suite de charger automatiquement les résultats supplémentaires nous reviendrons sur cette partie dans la troisième partie de cette série.

Voilà nous avons créer l'ensemble des composants nécessaires pour l'application.

Vous pouvez retrouver le code source ici.