In this series of articles, we will create a simple app for discovering TV series, based on The Movie DB API. This series is made up of three articles:
- Part 1: Creating the components
- Part 2: Navigation
- Part 3: Data
A preview of the result is available on YouTube.
In this part, you will not need an API key; the project will contain default data. Let's start by creating a new project:
While creating the components, we will need to use icons, so let's install the react-native-vector-icons library.
yarn add react-native-vector-icons
react-native linkNow let's move on to creating the components. Start by creating an src folder at the root of the app and an index.js file, which will be the app's entry point.
mkdir src
cd src && touch index.jsPaste the following code into the index.js file.
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,
},
})Replace the code in the index.ios.js and index.android.js files with the following:
import { AppRegistry } from 'react-native'
import App from './src/index.js'
AppRegistry.registerComponent('AwesomeSerie', () => App)Create a components folder where we will add all the components
mkdir componentsThe first component we will create is the ImageWithOverlay component, which will be used to present the series on the main screen.
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',
},
})Next, in the app preview, you can see that on a series detail screen there is an image with a diagonal edge, so we will create the DiagonalImage component.
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,
},
})Let's briefly revisit how this component works. To create this diagonal effect, we need to create a view shaped like a triangle, with the screen background color, positioned at the bottom right of the image.
Here is the style that creates a triangle from a view:
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,
}The code above will create a 90*90 triangle. In our case, we want the width to be equal to the screen width and the height to be a ratio of the image height, so we will define the two borderTopWidth and borderRightWidth properties dynamically.
- borderTopWidth: image height / 3.5
- borderRightWidth: screen width
If you want to learn more about creating different shapes with simple views and styles, you can read this article.
Before the detailed series screen, we will create a component that displays additional information about the series.
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',
},
})Now let's create the detailed screen for a series. Here are all the properties this component will receive.
- serieItem: object containing information about the series
- goBack: navigation function used to return to the previous screen
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',
},
})To finish, we will create the listView that displays the list of series retrieved through the API, as well as the listViewItem component. Let's start by creating the listViewItem component. Here are its properties:
onItemPress: Displays the image detail screen
- image: URL of the poster image
- height: Height
- width: Width
- title: Series title
- description: Series description 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,
},
})Finally, let's move on to the listView. Here are its properties:
- data: list of data returned by the API
- showDetail: navigation function used to display the detail screen for a series,
- nextPage: next page of results,
- hasMoreResult: defines whether there are pages left to load,
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 SerieListViewThe listView's onEndReached event will later let us automatically load additional results. We will come back to this part in the third article of this series.
That's it: we have created all the components needed for the app.