HelloKoding

Practical coding guides

Build a Hacker News App with React Native

This post walks you through the process of creating a Hacker News App for iOS and Android devices with React Native

If you’re new to React Native or CSS Flexbox, it’d be best to walk your way through:

What you’ll need

  • MacOS, Xcode
  • NodeJS
  • NPM
  • React Native Package Manager
  • React Native 0.26

Stack

  • ES6, ES7
  • React Native
  • CSS Flexbox

Project structure

├── android
├── ios
├── Dashboard.js
├── HackerNewsApi.js
├── PostWebView.js
├── PostsListView.js
├── index.android.js
├── index.ios.js
└── package.json

Project dependencies

package.json

{
  "name": "HackerNewsApp",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start"
  },
  "dependencies": {
    "moment": "^2.13.0",
    "react": "^15.0.2",
    "react-native": "0.26.0",
    "react-native-gifted-listview": "0.0.15",
    "react-native-tab-navigator": "^0.3.2",
    "react-native-timeago": "^0.3.0",
    "react-native-vector-icons": "^2.0.2",
    "react-timer-mixin": "^0.13.3"
  }
}

Init project

react-native init HackerNewsApp
cd HackerNewsApp

Hacker News APIs

We use some public APIs of Hacker News

HackerNewsApi.js

const HackerNewsApiV0 = 'https://hacker-news.firebaseio.com/v0/';
const HackerNewsApi = {
  topStories: HackerNewsApiV0 + 'topstories.json',
  newStories: HackerNewsApiV0 + 'newstories.json',
  showStories: HackerNewsApiV0 + 'showstories.json',
  askStories: HackerNewsApiV0 + 'askstories.json',
  jobStories: HackerNewsApiV0 + 'jobstories.json',
  post: HackerNewsApiV0 + 'item/${postId}.json'
};

module.exports =  HackerNewsApi;

Web View

PostWebView.js

import React, {Component} from 'react';
import {Platform, WebView} from 'react-native';

class PostWebView extends Component {
  render() {
    let marginTop = (Platform.OS === 'ios') ? 0 : 60;
    return (
      <WebView
        source={{uri: this.props.uri}}
        style={{flex: 1, marginTop: marginTop}}>
      </WebView>
    );
  }
};

module.exports = PostWebView;

List View

Install GiftedListView and react-native-timeago

npm install react-native-gifted-listview --save
npm install react-native-timeago --save

PostsListView.js

import React, {Component} from 'react';
import {StyleSheet, ListView, View, Text, TouchableHighlight} from 'react-native';
import GiftedListView from 'react-native-gifted-listview';
import TimeAgo from 'react-native-timeago';
import PostWebView from './PostWebView';
import HackerNewsApi from './HackerNewsApi';

const LISTVIEW_PAGESIZE = 12;

class PostsListView extends Component {
  constructor(props) {
    super(props);
  }

  async _onFetch(page=1, callback, options) {
    var postIds = [];
    if (this.props.postIds) {
      postIds = this.props.postIds;
    } else {
      var response = await fetch(this.props.postsApi);
      postIds = await response.json();
    }

    var posts = [];
    var startIndex = (page - 1) * LISTVIEW_PAGESIZE;
    var endIndex = startIndex + LISTVIEW_PAGESIZE - 1;
    var allLoaded = false;
    if (endIndex >= postIds.length) {
      endIndex = postIds.length - 1;
      allLoaded = true;
    }

    for(var i = startIndex; i <= endIndex; i++) {
      var postId = postIds[i];
      var response = await fetch(HackerNewsApi.post.replace('${postId}', postId));
      var post = await response.json();
      posts.push(post);
    }

    callback(posts, {
      allLoaded: allLoaded
    });
  }

  _onPressRowTitle(rowData) {
    if (rowData.type === 'comment') return;

    if (rowData.url) {
      this.props.navigator.push({
        component: PostWebView,
        title: rowData.title,
        passProps: {
          uri: rowData.url
        }
      });
    } else {
      this.props.navigator.push({
        component: PostsListView,
        title: rowData.title || this._fixCommentText(rowData.text),
        passProps: {
          postIds: rowData.kids
        }
      });
    }
  }

  _onPressRowDetail(rowData) {
    if (!rowData.kids || !rowData.kids.length) return;

    this.props.navigator.push({
      component: PostsListView,
      title: rowData.title || this._fixCommentText(rowData.text),
      passProps: {
        postIds: rowData.kids
      }
    });
  }

  _fixCommentText(str){
  	return String(str).replace(/<p>/g, '\n\n')
  			   		  .replace(/&#x2F;/g, '/')
  			   		  .replace('<i>', '')
  			   		  .replace('</i>', '')
  			   		  .replace(/&#x27;/g, '\'')
  			   		  .replace(/&quot;/g, '\"')
  			   		  .replace(/<a\s+(?:[^>]*?\s+)?href="([^"]*)" rel="nofollow">(.*)?<\/a>/g, "$1");
  }

  _renderRowView(rowData) {
    var timeAgo = <TimeAgo time={rowData.time*1000} />;
    var score = '';
    var commentsCount = '';
    if (rowData.type === 'comment') {
      commentsCount = (!rowData.kids ? 0 : rowData.kids.length);
      commentsCount = (commentsCount == 1 ? '1 comment' : (commentsCount + ' comments'));
    } else {
      score = rowData.score + ' points';
      commentsCount = (rowData.descendants == 1 ? '1 comment' : (rowData.descendants + ' comments'));
    }

    return (
      <TouchableHighlight
        underlayColor='red'
        style={{flex: 1}}>
        <View style={styles.rowContainer}>
          <Text style={styles.rowTitleText}
            onPress={() => this._onPressRowTitle(rowData)}>
            {rowData.title || this._fixCommentText(rowData.text)}
          </Text>
          <View style={styles.rowDetailContainer}>
            <Text style={styles.rowDetailText}
              onPress={() => this._onPressRowDetail(rowData)}>
              {score} by {rowData.by} | {timeAgo} | {commentsCount}
            </Text>
          </View>
        </View>
      </TouchableHighlight>
    );
  }

  _renderSeparatorView() {
    return (
      <View style={styles.separator} />
    );
  }

  _renderPaginationWaitingView(paginateCallback) {
    return (
      <TouchableHighlight
        underlayColor='#c8c7cc'
        onPress={paginateCallback}
        style={styles.paginationView}
      >
        <Text style={styles.actionsLabel}>
          load more
        </Text>
      </TouchableHighlight>
    );
  }

  render() {
    return (
      <View style={styles.listViewContainer}>
        <GiftedListView
          rowView={(rowData) => this._renderRowView(rowData)}
          renderSeparator={this._renderSeparatorView}
          onFetch={(page=1, callback, options) => this._onFetch(page, callback, options)}
          firstLoader={true}
          pagination={true}
          paginationWaitingView={this._renderPaginationWaitingView}
          refreshable={true}
          withSections={false}
          refreshableTintColor='blue'
          style={styles.listView}
          enableEmptySections={true}
        />
      </View>
    )
  }
};

const styles = StyleSheet.create({
  listViewContainer: {
    flex: 1,
    marginTop: 60,
    paddingLeft: 10,
    paddingRight: 10
  },
  listView: {
    flex: 1
  },
  rowContainer: {
    flex: 1,
    justifyContent: 'center',
    paddingTop: 10,
    paddingBottom: 10,
  },
  rowTitleText: {
    fontSize: 18
  },
  rowDetailContainer: {
    flex: 1,
    marginTop: 2
  },
  rowDetailText: {
    fontSize: 12,
    color: '#828282'
  },
  separator: {
    flex: 1,
    height: 1,
    backgroundColor: '#dddddd'
  },
  paginationView: {
    flex: 1,
    height: 44,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#FFF',
  },
  actionsLabel: {
    fontSize: 13,
    color: '#007aff',
  }
});

module.exports = PostsListView;

Dashboard

Install react-native-tab-navigator and react-native-vector-icons

npm install react-native-tab-navigator --save
npm install react-native-vector-icons --save
npm install rnpm -g
rnpm link

Dashboard.js

import React, { Component } from 'react';
import { StyleSheet, Text, View, TabBarIOS } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import PostsListView from './PostsListView';
import HackerNewsApi from './HackerNewsApi';
import TabNavigator from 'react-native-tab-navigator';

class Dashboard extends Component {
  constructor(props) {
    super(props);

    this.state = {
      selectedTab: 'topTab'
    }
  }

  _renderContent(color, api) {
    return (
      <View style={{flex: 1, backgroundColor: color}}>
        <PostsListView
          navigator={this.props.navigator}
          postIds={false}
          postsApi={api}>
        </PostsListView>
      </View>
    );
  }

  render() {
    let iconSize = 28;
    let iconColor = '#ff6600';

    return (
      <TabNavigator tabBarStyle={{backgroundColor: 'white'}}>
        <TabNavigator.Item
          selected={this.state.selectedTab === 'topTab'}
          title="Top"
          renderIcon={() => <Icon name="ios-heart-outline" color={iconColor}  size={iconSize}/>}
          renderSelectedIcon={() => <Icon name="ios-heart" color={iconColor} size={iconSize}/>}
          onPress={() => this.setState({ selectedTab: 'topTab' })}>
          {this._renderContent('white', HackerNewsApi.topStories)}
        </TabNavigator.Item>
        <TabNavigator.Item
          selected={this.state.selectedTab === 'newTab'}
          title="New"
          renderIcon={() => <Icon name="ios-bulb-outline" color={iconColor} size={iconSize}/>}
          renderSelectedIcon={() => <Icon name="ios-bulb" color={iconColor} size={iconSize}/>}
          onPress={() => this.setState({ selectedTab: 'newTab' })}>
          {this._renderContent('white', HackerNewsApi.newStories)}
        </TabNavigator.Item>
        <TabNavigator.Item
          selected={this.state.selectedTab === 'showTab'}
          title="Show"
          renderIcon={() => <Icon name="ios-sunny-outline" color={iconColor} size={iconSize}/>}
          renderSelectedIcon={() => <Icon name="ios-sunny" color={iconColor} size={iconSize}/>}
          onPress={() => this.setState({ selectedTab: 'showTab' })}>
          {this._renderContent('white', HackerNewsApi.showStories)}
        </TabNavigator.Item>
        <TabNavigator.Item
          selected={this.state.selectedTab === 'askTab'}
          title="Ask"
          renderIcon={() => <Icon name="ios-chatboxes-outline" color={iconColor} size={iconSize}/>}
          renderSelectedIcon={() => <Icon name="ios-chatboxes" color={iconColor} size={iconSize}/>}
          onPress={() => this.setState({ selectedTab: 'askTab' })}>
          {this._renderContent('white', HackerNewsApi.askStories)}
        </TabNavigator.Item>
        <TabNavigator.Item
          selected={this.state.selectedTab === 'jobTab'}
          title="Job"
          renderIcon={() => <Icon name="ios-code-working-outline" color={iconColor} size={iconSize}/>}
          renderSelectedIcon={() => <Icon name="ios-code-working" color={iconColor} size={iconSize}/>}
          onPress={() => this.setState({ selectedTab: 'jobTab' })}>
          {this._renderContent('white', HackerNewsApi.jobStories)}
        </TabNavigator.Item>
      </TabNavigator>
    );
  }
}

module.exports = Dashboard;

Final piece

index.ios.js

import React, { Component } from 'react';
import { AppRegistry, StyleSheet, Text, View, NavigatorIOS } from 'react-native';
import Dashboard from './Dashboard';

class HackerNewsApp extends Component {
  render() {
    return (
      <NavigatorIOS
        style={styles.container}
        tintColor='#FF6600'
        initialRoute={{
          component: Dashboard,
          title: 'Hacker News'
        }}
      />
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F6F6EF'
  }
});

AppRegistry.registerComponent('HackerNewsApp', () => HackerNewsApp);

Run

react-native run-ios

Source code

git@github.com:hellokoding/hackernewsapp-reactnative.git https://github.com/hellokoding/hackernewsapp-reactnative

Follow HelloKoding