85
63

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【React Native】インスタグラム的なアプリを1日でサクッと実装してみた

Last updated at Posted at 2019-03-03

はじめに

サーバー周りはFirebase、インターフェースはexpo(React Native)でアプリケーションを作ります。Firebase、React Nativeの基本的な知識は知っている前提で進めます。

capture

今回作るアプリのコアな機能

Facebook認証を利用したログイン機能
写真と文言をセットにした投稿機能
ユーザープロフィール編集機能

いいねとフォロー機能は今回実装しません。

Firebaseの設定

SNS認証について

別記事で解説してます。そちらを参照してください。
【React Native】ExpoでFirebase AuthenticationのSNS認証を利用する方法

データの保存先について

投稿内容とプロフィールのデータは基本的にFirestoreに保存する。例外としてプロフィールと投稿内容の画像ファイルはstorageに保存し、Firestoreに対応するURLを保存させる。

Firestoreの設定

UserFeedのコレクションを作る。
Userはユーザーごとにドキュメントが存在し、avatar(アバター画像のURL)name(ニックネーム)を保存させる。
Feedには投稿ごとにドキュメントが存在し、message(メッセージ)image(画像のURL)created_at(投稿日時)updated_at(更新日時)writer(投稿者)を保存させる。

本来であれば、Userにもcreated_atupdated_atをつけるべきだが今回は省略する。

それではFirestoreにルールを設定していきます。
閲覧権限に制限は授けていませんが、編集・投稿はユーザー固有のuidで制限をかけています。
これで、自分自身のプロフィールと自分自身が投稿したフィードのみ投稿・編集可能にできます

service cloud.firestore {
  match /databases/{database}/documents {
    match /User/{userID} {
    	allow read;
      allow create, update: if request.auth.uid == userID;
    }
    match /Feed/{feedID} {
    	allow read;
      allow create, update: if request.resource.data.writer == request.auth.uid;
    }
  }
}
Storageの設定

構造はUser/{userID}/Avatar/main.pngにプロフィール画像をUser/{userID}/Feed/{feedID}/main.pngに投稿内容の画像を保存する。

それではStorageにルールを設定していきます。
閲覧権限に制限は授けていませんが、画像保存はユーザー固有のuidで制限をかけています。
これで、自分自身のプロフィール画像と自分自身が投稿した内容の画像のみ保存可能にできます

service firebase.storage {
  match /b/{bucket}/o {
    match /User/{userID} {
      match /Avatar/main.png {
      	allow read;
      	allow write: if userID == request.auth.uid;
      }
      
      match /Feed/{novelID} {
      	match /main.png {
        	allow read;
          allow write: if userID == request.auth.uid;
        }
      }
    }
  }
}

ユーザー初回ログイン時の処理について

SNS認証でログインしたユーザーが初回ログインの場合、Firestoreにユーザーデータと自動で追加させるようにします
Functionsでこれは実装します。

関数の発火トリガーをcreateAccountDocにすることで、初回ログイン時のみ実行させる関数ができます。

const admin = require('firebase-admin');
const functions = require('firebase-functions');

admin.initializeApp(functions.config().firebase)

exports.createAccountDoc = functions.auth.user().onCreate( async (user) => {
  const db = admin.firestore();
  const batch = db.batch();

  const userCollection = db.collection('User');
  const userRef = userCollection.doc(user.uid);

  try{
    await batch.set(userRef, { name: '未設定' });

    await batch.commit().then(() => {
    console.log('add user success.');
    })
  }
  catch(e) {
    console.log(`error occurs: ${e}`);
  }
});

これでサーバー周りの準備は終わりです。自分で実装するとかなり時間かかるし、セキュリティー設定はめんどくさいので小規模だったりプロト開発だったらFirebase使っとけばいい気がしてる。

アプリケーションの作成

準備

expo cliでアプリケーションを作成します。

npm install -g expo-cli
expo init instaApp
cd instaApp
npm install

使用するライブラリをインストールします。

npm install redux react-redux redux-actions redux-saga redux-logger --save
npm install native-base react-navigation --save
npm isntall firebase axios moment-timezone lodash --save

画面構成について

画面構成は、ホーム画面、詳細画面、投稿画面、プロフィール画面、プロフィール編集画面で作ります

ホーム画面 ・・・ 投稿内容がカード形式で一覧表示
詳細画面 ・・・ 投稿内容の詳細が表示
投稿画面 ・・・ 画像と文言を入力し、投稿させる
プロフィール画面 ・・・ プロフィール画像とニックネームが表示
プロフィール編集画面 ・・・ プロフィール画像とニックネームを入力し、アップロードさせる

今回、画面遷移の管理はreact-navigationにさせるので、各画面のコンポーネントファイルとAppNavigatorというファイルを作り画面遷移の制御設定をしていきます。

スクリーンファイルはcomponentsフォルダに適当に作って入れておいてください。

components/HomeScreen.js
import React, { Component } from 'react'
import { StyleSheet, Text, View } from 'react-native'

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
})

class HomeScreen extends Component {
  render() {
    return (
      <View style={styles.container}>
        <Text>HomeScreen</Text>
      </View>
    )
  }
}

export default HomeScreen

AppNavigatorでは上手にスタックを組み合わせてナビゲーションの構造を作ります。
詳しくは「【React Native】react-navigation (version3.x)の手ほどき」の記事で解説している。

AppNavigator.js
import React from 'react'
import { Platform } from 'react-native'
import { createStackNavigator, createBottomTabNavigator } from 'react-navigation'
import { Icon } from 'expo'
import HomeScreen from './components/HomeScreen'
import DetailScreen from './components/DetailScreen'
import FeedScreen from './components/FeedScreen'
import ProfileScreen from './components/ProfileScreen'
import ProfileEditScreen from './components/ProfileEditScreen'

const HomeStack = createStackNavigator(
  {
    Home: {
      screen: HomeScreen
    },
    Detail: {
      screen: DetailScreen
    },
  },
  {
    initialRouteName: 'Home',
    navigationOptions: ({ navigation }) => ({
      tabBarIcon: ({ focused }) => (
        <Icon.Ionicons
          name={
            Platform.OS === 'ios'
              ? 'ios-home'
              : 'md-home'
          }
          size={26}
          style={{ marginBottom: -3 }}
          color={focused ? 'black' : 'gray'}
        />
      ),
    }),
  }
)

const FeedStack = createStackNavigator(
  {
    Feed: {
      screen: FeedScreen
    },
    Detail: {
      screen: DetailScreen
    },
  },
  {
    initialRouteName: 'Feed',
    navigationOptions: {
      tabBarIcon: ({ focused }) => (
        <Icon.Ionicons
          name={
            Platform.OS === 'ios'
              ? 'ios-add'
              : 'md-add'
          }
          size={26}
          style={{ marginBottom: -3 }}
          color={focused ? 'black' : 'gray'}
        />
      ),
    },
  }
)

const ProfileStack = createStackNavigator(
  {
    Profile: {
      screen: ProfileScreen
    },
    Edit: {
      screen: ProfileEditScreen
    },
  },
  {
    initialRouteName: 'Profile',
    navigationOptions: {
      tabBarIcon: ({ focused }) => (
        <Icon.Ionicons
          name={
            Platform.OS === 'ios'
              ? 'ios-person'
              : 'md-person'
          }
          size={26}
          style={{ marginBottom: -3 }}
          color={focused ? 'black' : 'gray'}
        />
      ),
    },
  }
)

const TabNavigator = createBottomTabNavigator(
  {
    Home: HomeStack,
    Feed: FeedStack,
    Profile: ProfileStack,
  },
  {
    tabBarOptions: {
      activeTintColor: 'black',
      inactiveTintColor: 'gray',
    }
  }
)

export default TabNavigator

App.jsで表示させる物を、AppNavigatorにします。

App.js
import React from 'react'
import { createAppContainer } from 'react-navigation'
import AppNavigator from './AppNavigator'

const AppContainer = createAppContainer(AppNavigator)

export default class App extends React.Component {
  render() {
    return <AppContainer />
  }
}

ストア管理をReduxにさせる

ストア管理をReduxにさせるので、まずはユーザーのログイン情報を入れるreducerとactionを作成します。

reducers/user.js
import { handleActions } from 'redux-actions'
import actions from '../actions/user'

const initialState = { 
  uid: null,
  properties: {},
}

const reducer = handleActions({
  [actions.setUserUid]: (state, action) => ({
    ...state,
    uid: action.payload,
  }),
  [actions.setUserProperties]: (state, action) => ({
    ...state,
    properties: action.payload,
  })
}, initialState)

export default reducer
actions/user.js
import { createActions } from 'redux-actions'

const actions = createActions(
  {
    SET_USER_UID: (args) => (args),
    SET_USER_PROPERTIES: (args) => (args),
  }
)

export default actions

configureStore.jsファイルを作成し、storeを作成する関数を定義します。

configureStore.js
import { combineReducers, createStore, applyMiddleware } from 'redux'
import user from './reducers/user'

const middlewares = []

if(process.env.NODE_ENV !== 'production') {
  const { logger } = require('redux-logger')
  middlewares.push(logger)
}

const reducers = combineReducers({
  user,
})

const configureStore = initialState => {
  const store = createStore(reducers, initialState, applyMiddleware(...middlewares))
  return store
}

export default configureStore

AppNavigator.jsで読み込んでいるコンポーネントをコンテナーに変更します。

AppNavigator.js
...
import HomeScreen from './containers/HomeScreen'
import DetailScreen from './containers/DetailScreen'
import FeedScreen from './containers/FeedScreen'
import ProfileScreen from './containers/ProfileScreen'
import ProfileEditScreen from './containers/ProfileEditScreen'
...

最後に、App.jsでReduxを利用するように設定します。

App.js
import React from 'react'
import { Provider } from 'react-redux'
import { createAppContainer } from 'react-navigation'
import AppNavigator from './AppNavigator'
import configureStore from './configureStore'

const store = configureStore()

const AppContainer = createAppContainer(AppNavigator)

export default class App extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <AppContainer />
      </Provider>
    )
  }
}

firebase apiをラップしたモジュールを作る

modules/firebaseフォルダを作成し、Facebook認証、画像アップロードを関数化したindex.jsを作成します。
config.jsに、firebaseのウェブ設定から取得できるconfigと、facebookのアプリIDを保存しておく。

modules/firebase/index.js
import * as firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'
import 'firebase/storage'
import { config, FACEBOOK_APPID } from './config'
import * as Expo from 'expo'

firebase.initializeApp(config)

// auth

export const auth = firebase.auth()

export const getUid = () => {
  const user = firebase.auth().currentUser

  if (user) {
    return { uid: user.uid }
  }
  else {
    return { uid: null }
  }
}

export const authFacebook = async () => {
  try {
    const { type, token } = await Expo.Facebook.logInWithReadPermissionsAsync(
      FACEBOOK_APPID,
      { permissions: ['public_profile'] }
    )

    if (type === 'success') {
      const credential = firebase.auth.FacebookAuthProvider.credential(token)
      return firebase.auth().signInAndRetrieveDataWithCredential(credential).catch((error) => console.log(error))
    }
    else {
      return { cancelled: true }
    }
  }
  catch (e) {
    return { error: true }
  }
}

export const logout = () => {
  return firebase.auth().signOut()
}

// firestore

export const db = firebase.firestore()
export const userCollection = db.collection('User')
export const feedCollection = db.collection('Feed')

export const getNowDate = () => {
  return firebase.firestore.FieldValue.serverTimestamp()
}

export const getNewFeedDoc = () => {
  return feedCollection.doc()
} 

// storage

const storageRef = firebase.storage().ref()
export const userRef = storageRef.child('User')

export const uploadAvatar = async(uri) => {
  const { uid } = getUid()
  const avatarRef = userRef.child(`${uid}/Avatar/main.png`)

  const blob = await new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.onload = () => {
      resolve(xhr.response)
    }

    xhr.onerror = e => {
      console.log(e)
      reject(new TypeError('Network request failed'))
    }

    xhr.responseType = 'blob'
    xhr.open('GET', uri, true)
    xhr.send(null)
  })

  const snapshot = await avatarRef.put(blob)
  blob.close()
  return await snapshot.ref.getDownloadURL()
}

export const uploadFeedImage = async(uri, uuid) => {
  const { uid } = getUid()
  const feedImageRef = userRef.child(`${uid}/Feed/${uuid}/main.png`)

  const blob = await new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.onload = () => {
      resolve(xhr.response)
    }

    xhr.onerror = e => {
      console.log(e)
      reject(new TypeError('Network request failed'))
    }

    xhr.responseType = 'blob'
    xhr.open('GET', uri, true)
    xhr.send(null)
  })

  const snapshot = await feedImageRef.put(blob)
  blob.close()
  return await snapshot.ref.getDownloadURL()
}

sagaを導入し、常にログイン状態をチェックさせる

sagasフォルダを作成し、user.jsで常にログイン状態をチェックさせるチャネルを作成する。
詳しくは「【React】Firebaseの認証状態チェックコードはどこに書くべきなのか」の記事で解説している。

sagas/auth.js
import { take, put, call } from 'redux-saga/effects'
import { eventChannel } from 'redux-saga'
import { auth } from '../modules/firebase'

const data = (type ,payload) => {
  const _data = {
    type: type,
    payload: payload,
  }

  return _data
}

const authChannel = () => {
  const channel = eventChannel(emit => {
    const unsubscribe = auth.onAuthStateChanged(
      user => emit({ user }),
      error => emit({ error })
    )
    return unsubscribe
  })
  return channel
}

function* checkUserStateSaga() {
  const channel = yield call(authChannel)
  while (true) {
    const { user, error } = yield take(channel)

    if ( user && !error ) {
      yield put(data('SET_USER_UID', user.uid))
    }
    else {
      yield put(data('SET_USER_UID', null))
    }
  }
}

const sagas = [
  checkUserStateSaga(),
]

export default sagas

index.jsでsagaをまとめる。

今回はauth.jsのみだが、増えたとき簡単に束ねなれるように。

sagas/index.js
import { all } from 'redux-saga/effects'
import auth from './auth'

export default function* rootSaga() {
  yield all([
    ...auth,
  ])
}

configureStore.jsでsagaがちゃんと動くように設定する。

configureStore.js
import { combineReducers, createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import rootSaga from './sagas'
import user from './reducers/user'

const sagaMiddleware = createSagaMiddleware()
const middlewares = [sagaMiddleware]

if(process.env.NODE_ENV !== 'production') {
  const { logger } = require('redux-logger')
  middlewares.push(logger)
}

const reducers = combineReducers({
  user,
})

const configureStore = initialState => {
  const store = createStore(reducers, initialState, applyMiddleware(...middlewares))
  sagaMiddleware.run(rootSaga)
  return store
}

export default configureStore

次から、やっと画面作成に入れます。

ProfileScreenの作成

アバターの画像と、ニックネームを表示させます。
ログインしていなかったら、ログインボタンを表示させます。
componentWillMount()では、Userの情報をfirestoreから取ってきて、ローカルストアに保持させています。

components/ProfileScreen.js
import React, { Component } from 'react'
import { StyleSheet, View, Text, Dimensions } from 'react-native'
import { Container, Content, Thumbnail, Button } from 'native-base'
import { authFacebook, logout, userCollection } from '../modules/firebase'

class ProfileScreen extends Component {
  static navigationOptions = ({ navigation }) => ({
    title: 'Instagram',
  })

  componentWillMount() {
    this.unsubscribe = userCollection.doc(this.props.user.uid || '_').onSnapshot(doc => {
      const properties = doc.data()
      if(properties) {
        this.props.handleSetUserProperties(Object.assign({
          avatar: null,
          name: null,
        }, properties))
      }
      else {
        this.props.handleSetUserProperties({
          avatar: null,
          name: null,
        })
      }
     console.log(properties)
    })
  }

  componentWillUnmount() {
    this.unsubscribe()
  }

  render () {
    if(this.props.user.uid) {

      const tempAvatar = 'https://firebasestorage.googleapis.com/v0/b/novels-a5884.appspot.com/o/temp%2Ftemp.png?alt=media&token=a4d36af6-f5e8-49ad-b9c0-8b5d4d899c0d'

      return (
        <Container style={styles.container}>
          <Content>
            <View style={styles.content}>
              <View style={styles.profileSection}>
                <View style={styles.profileMain}>
                  <Thumbnail
                    large
                    source={{uri: this.props.user.properties.avatar? this.props.user.properties.avatar : tempAvatar}}
                    style={styles.avatar}
                  />
                  <Text style={styles.name}>{this.props.user.properties.name? this.props.user.properties.name : '未設定'}</Text>
                </View>
                <Button
                  style={styles.editButton}
                  transparent
                  dark
                  onPress={() => this.props.navigation.navigate('Edit')}
                >
                  <Text style={styles.buttonText}>プロフィール編集</Text>
                </Button>
                <Button
                  style={styles.logoutButton}
                  dark
                  rounded
                  onPress={logout}
                >
                  <Text style={styles.buttonText}>ログアウト</Text>
                </Button>
              </View>
            </View>
          </Content>
        </Container>
      )
    }
    else {
      return (
        <View style={styles.notLoginContainer}>
          <Button
            style={styles.loginButton}
            dark
            rounded
            onPress={authFacebook}
            >
              <Text style={styles.buttonText}>Login with Facebook</Text>
            </Button>
        </View>
      )
    }
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  notLoginContainer: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  container: {
    flex: 1,
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  profileSection: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    position: 'relative',
    width: width,
    height: height/3,
    padding: 10,
    backgroundColor: 'black',
  },
  avatar: {
    width: height/5,
    height: height/5,
    borderRadius: height/10,
    marginBottom: 15,
  },
  name: {
    fontSize: 12,
    fontWeight: 'bold',
    color: 'white',
    textAlign: 'center',
  },
  editButton: {
    position: 'absolute',
    padding: 10,
    bottom: 10,
    right: 10,
  },
  logoutButton: {
    position: 'absolute',
    padding: 10,
    bottom: 10,
    left: 10,
  },
  loginButton: {
    padding: 10,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
  buttonText: {
    fontSize: 10.5,
    color: 'white',
  },
})

export default ProfileScreen
containers/ProfileScreen.js
import { connect } from 'react-redux'
import userActions from '../actions/user'
import ProfileScreen from '../components/ProfileScreen'

const mapStateToProps = state => {
  return state
}

const mapDispatchToProps = dispatch => {
  return {
    handleSetUserProperties: (properties) => dispatch(userActions.setUserProperties(properties)),
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(ProfileScreen)

ProfileEditScreenの作成

この画面では、アバターの画像を登録させるため、カメラロールへのアクセスを許可しなければいけません。こちらはexpo側でAPIを用意してくれているのでそれを利用します。カメラロールから写真を取ってくる機能もexpo側で用意してくれているのでそれを利用します。また、データとして送信用のストアが必要ですがこの画面でしか使わないデータなのでsteteで管理させています。

本当はRedux側で送信用データも管理させた方が綺麗かもしれません、、、

components/ProfileEditScreen.js
import React, { Component } from 'react'
import { Platform, StyleSheet, View, Text, Dimensions } from 'react-native'
import { Container, Content, Header, Left, Thumbnail, Button, Item, Input, Badge } from 'native-base'
import { Icon, Permissions ,ImagePicker } from 'expo'
import { userCollection, uploadAvatar, db } from '../modules/firebase'

class ProfileEditScreen extends Component {
  constructor(props) {
    super(props)
    this.state={
      name: null,
      avatar: null,
      uploading: false,
    }
  }

  static navigationOptions = ({ navigation }) => ({
    header: null,
  })

  pickImage = async () => {
    let isAccepted = true

    const permission = await Permissions.getAsync(Permissions.CAMERA_ROLL)
    
    if(permission.status !== 'granted') {
      const newPermission = await Permissions.askAsync(Permissions.CAMERA_ROLL)
      if (newPermission.status !== 'granted') {
        isAccepted = false
      }
    }

    if(isAccepted) {
      let result = await ImagePicker.launchImageLibraryAsync({
        allowsEditing: true,
        aspect: [9, 9]
      })

      if (!result.cancelled) {
        this.setState({ avatar: result.uri })
        console.log(result.uri)
      }
    }
  }

  updateProfile = async (properties) => {
    try{
      this.setState({ uploading: true })

      let downloadUrl = null
      if (this.state.avatar) {
        downloadUrl = await uploadAvatar(this.state.avatar)
      }

      const batch = db.batch()
      const userRef = userCollection.doc(this.props.user.uid)

      await batch.set(userRef, { name: properties.name, avatar: downloadUrl })
      await batch.commit().then(() => {
        console.log('edit user success.')
      })

      this.setState({
        name: null,
        avatar: null,
      })

      this.props.navigation.goBack()
    }
    catch(e) {
      console.log(e)
      alert('Upload avatar image failed, sorry :(')
    }
    finally {
      this.setState({ uploading: false })
    }
  }

  render () {
    if(this.props.user.uid) {

      const tempAvatar = 'https://firebasestorage.googleapis.com/v0/b/novels-a5884.appspot.com/o/temp%2Ftemp.png?alt=media&token=a4d36af6-f5e8-49ad-b9c0-8b5d4d899c0d'

      return (
        <Container style={styles.container}>
          <Header transparent>
            <Left>
              <Button
                transparent
                onPress={() => this.props.navigation.goBack()}
              >
                <Icon.Ionicons
                  name={
                    Platform.OS === 'ios'
                    ? 'ios-arrow-back'
                    : 'md-arrow-back'
                  }
                  size={24}
                  style={styles.backButton}
                  color='black'
                />
              </Button>
            </Left>
          </Header>
          
          <Content>            
            <View style={styles.content}>
              {this.state.avatar? (
                <Thumbnail
                  large
                  source={{ uri: this.state.avatar? this.state.avatar : tempAvatar }}
                  style={styles.avatar}
                />
              ) : (
                <Thumbnail
                  large
                  source={{ uri: this.props.user.properties.avatar? this.props.user.properties.avatar : tempAvatar }}
                  style={styles.avatar}
                />
              )}

              <Badge style={styles.iconButton}>
                <Icon.AntDesign
                  name='plus'
                  size={50}
                  color='white'
                  onPress={this.pickImage}
                />
              </Badge>

              <Item style={styles.name} rounded>
                <Input
                  placeholder={this.props.user.properties.name}
                  onChangeText={name => this.setState({ name })}
                />
              </Item>

              <Button
                style={styles.button}
                dark
                rounded
                onPress={() => this.updateProfile(this.state)}
                disabled={this.state.uploading}
              >
                <Text style={styles.buttonText}>プロフィールを保存</Text>
              </Button>
            </View>
          </Content>
        </Container>
      )
    }
    else {
      return (
        <View style={styles.notLoginContainer}>
          <Text>Error</Text>
        </View>
      )
    }
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  notLoginContainer: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  avatar: {
    position: 'relative',
    width: width*2/3,
    height: width*2/3,
    borderRadius: width/3,
    margin: 20,
  },
  iconButton: {
    position: 'absolute',
    top: width*6/11,
    right: width/7,
    width: 64,
    height: 64,
    borderRadius: 32,
  },
  name: {
    width: width*2/3,
    marginBottom: 20,
  },
  button: {
    padding: 10,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
  buttonText: {
    fontSize: 12,
    color: 'white',
  }
})

export default ProfileEditScreen

あとは投稿機能とそれをみられるようにすれば、完成です。長いですがラストスパートがんばりましょう。

HomeScreenの作成

投稿内容を表示するHomeScreenを先に作ります。
Feedを全て取得し、created_atでソートしてstateでデータを管理させています。

こちらもReduxで一元管理した方が綺麗になると思います。

components/HomeScreen.js
import React, { Component } from 'react'
import { StyleSheet, Image, Dimensions } from 'react-native'
import { Container, Content, Card, CardItem, Body, Text } from 'native-base'
import moment from 'moment-timezone'
import { feedCollection } from '../modules/firebase'

class HomeScreen extends Component {
  constructor(props) {
    super(props)
    this.state={
      feeds: null,
    }
  }

  static navigationOptions = ({ navigation }) => ({
    title: 'Instagram',
  })

  componentWillMount () {
    this.unsubscribe = feedCollection.orderBy('created_at').onSnapshot(querySnapshot => {
      const feeds = []
      querySnapshot.forEach(doc => {
        feeds.push({ uuid: doc.id, ...doc.data() })
      })
      feeds.reverse()
      this.setState({ feeds })
    })
  }

  componentWillUnmount() {
    this.unsubscribe()
  }

  render () {
    return (
      <Container style={styles.container}>
        <Content>
          {this.state.feeds && this.state.feeds.map(element => {
            let date
            try {
              date = moment.unix(element.created_at.seconds).format('YYYY/MM/DD HH:mm:ss')
            }
            catch (e) {
              console.log(e)
              date = '投稿日不明'
            }

            return (
              <Card style={styles.card} key={element.uuid}>
                <CardItem cardBody button onPress={() => this.props.navigation.navigate('Detail', { uuid: element.uuid })}>
                  <Image
                    style={styles.image}
                    source={{uri: element.image}}
                  />
                </CardItem>
                <CardItem style={styles.inner} button onPress={() => this.props.navigation.navigate('Detail', { uuid: element.uuid })}>
                  <Body>
                    <Text>{element.message}</Text>
                    <Text style={styles.date}>{date}</Text>
                  </Body>
                </CardItem>
              </Card>
            )
          })}
        </Content>
      </Container>
    )
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  card: {
    width: width,
    height: 300,
  },
  image: {
    width: width,
    height: 200,
    overflow: 'hidden',
  },
  date: {
    position: 'absolute',
    top: 50,
    left: 0,
    color: 'gray',
    fontSize: 10.5,
  },
})

export default HomeScreen

FeedScreenの作成

次、肝となる投稿機能を持つ画面です。
ProfileEditScreenと仕組みは同じだが、画像保存の際、feedIDが必要になるので先に空のドキュメントを作成しfeedIDを先に取得しているcreated_atupdated_atはfirebaseのapiでサーバーの時間を保存させるように命令することができるので、それで解決している

components/FeedScreen.js
import React, { Component } from 'react'
import { StyleSheet, View, Text, Dimensions } from 'react-native'
import { Container, Content, Button, Thumbnail, Badge, Textarea } from 'native-base'
import { Icon, Permissions, ImagePicker } from 'expo'
import { getNewFeedDoc, uploadFeedImage, getUid, getNowDate, authFacebook, db } from '../modules/firebase'

class FeedScreen extends Component {
  constructor(props) {
    super(props)
    this.state={
      message: null,
      image: null,
      uploading: false,
    }
  }

  static navigationOptions = ({ navigation }) => ({
    title: 'Instagram',
  })

  pickImage = async () => {
    const isAccepted = true

    const permission = await Permissions.getAsync(Permissions.CAMERA_ROLL)
    
    if(permission.status !== 'granted') {
      const newPermission = await Permissions.askAsync(Permissions.CAMERA_ROLL)
      if (newPermission.status !== 'granted') {
        isAccepted = false
      }
    }

    if(isAccepted) {
      let result = await ImagePicker.launchImageLibraryAsync({
        allowsEditing: true,
        aspect: [9, 9]
      })

      if (!result.cancelled) {
        this.setState({ image: result.uri })
        console.log(result.uri)
      }
    }
  }

  postFeed = async (properties) => {
    try{
      this.setState({ uploading: true })

      const feedRef = getNewFeedDoc()
      const uuid = feedRef.id

      let downloadUrl = null
      if (this.state.image) {
        downloadUrl = await uploadFeedImage(this.state.image, uuid)
      }

      const { uid } = getUid()

      const batch = db.batch()

      await batch.set(feedRef, {
        message: properties.message,
        image: downloadUrl,
        writer: uid,
        created_at: getNowDate(),
        updated_at: getNowDate(),
      })
      await batch.commit().then(() => {
        console.log('post feed success.')
      })

      this.setState({
        message: null,
        image: null,
      })
      this.props.navigation.navigate('Detail', { uuid })
    }
    catch(e) {
      console.log(e)
    }
    finally {
      this.setState({ uploading: false })
    }
  }

  render () {
    if(this.props.user.uid) {
      return (
        <Container style={styles.container}>
          <Content>
            <View style={styles.content}>
              <View style={styles.imageSection}>
                {this.state.image? (
                  <Thumbnail
                    large
                    square
                    source={{ uri: this.state.image }}
                    style={styles.image}
                  />
                ) : null}

                <Badge style={styles.iconButton}>
                  <Icon.AntDesign
                    name='plus'
                    size={50}
                    color='white'
                    onPress={this.pickImage}
                  />
                </Badge>
              </View>
              
              <View style={styles.textSection}>
                <Textarea
                  style={styles.description}
                  rowSpan={10}
                  bordered
                  placeholder='メッセージ'
                  onChangeText={message => this.setState({ message })}
                />
              </View>

              <Button
                style={styles.button}
                dark
                rounded
                onPress={() => this.postFeed(this.state)}
                disabled={this.state.uploading}
              >
                <Text style={styles.buttonText}>投稿</Text>  
              </Button>
            </View>
          </Content>
        </Container>
      )
    }
    else {
      return (
        <View style={styles.notLoginContainer}>
          <Button
            style={styles.loginButton}
            dark
            rounded
            onPress={authFacebook}
            >
              <Text style={styles.buttonText}>Login with Facebook</Text>
            </Button>
        </View>
      )
    }
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  notLoginContainer: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  imageSection: {
    position: 'relative',
    width: width,
    height: width,
    backgroundColor: 'black',
    marginBottom: 20,
  },
  image: {
    width: width,
    height: width,
  },
  iconButton: {
    position: 'absolute',
    bottom: -32,
    right: width/20,
    width: 64,
    height: 64,
    borderRadius: 32,
  },
  textSection: {
    padding: 10,
  },
  title: {
    width: width*9/10,
    marginBottom: 20,
  },
  description: {
    width: width*9/10,
    marginBottom: 20,
  },
  button: {
    padding: 10,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
  buttonText: {
    fontSize: 12,
    color: 'white',
  },
  loginButton: {
    padding: 10,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
})

export default FeedScreen

DetailScreenの作成

最後の画面です。
前のスクリーンからuuidを取得し、uuidFeedの情報を取得して表示させています。また、FeedwriterからUserの情報を取得して表示させています。

components/DetailScreen.js
import React, { Component } from 'react'
import { Platform, StyleSheet, Dimensions, View, Text, Image } from 'react-native'
import { Container, Content, Header, Left, Button, Thumbnail } from 'native-base'
import moment from 'moment-timezone'
import { Icon } from 'expo'
import { feedCollection, userCollection } from '../modules/firebase'

class DetailScreen extends Component {
  constructor(props) {
    super(props)
    this.state={
      feed: null,
      writer: null,
    }
  }

  static navigationOptions = ({ navigation }) => ({
    header: null,
  })

  componentWillMount() {
    const uuid = this.props.navigation.getParam('uuid', null)

    if(uuid) {
      this.unsubscribe = feedCollection.doc(uuid).onSnapshot(doc => {
        const feed = doc.data()

        let date
        try {
          date = moment.unix(feed.updated_at.seconds).format('YYYY/MM/DD HH:mm:ss')
        }
        catch (e) {
          console.log(e)
          date = '投稿日不明'
        }

        this.setState({
          feed : {
            image: feed.image,
            message: feed.message,
            writer: feed.writer,
            updated_at: date,
          }
        })

        userCollection.doc(feed.writer).get()
        .then(_doc => {
          if(_doc.exists) {
            const user = _doc.data()
            this.setState({
              user: {
                name: user.name,
                avatar: user.avatar,
              }
            })
          }
          else {
            this.setState({
              user: {
                name: null,
                avatar: null,
              }
            })
          }
        })
        .catch(error => {
          this.setState({
            user: {
              name: null,
              avatar: null,
            }
          })
          console.log(error)
        })
      })
    }
  }

  render () {
    if (!this.state.feed) {
      return (
        <View style={styles.notLoginContainer}>
          <Text>Error</Text>
        </View>
      )
    }
    
    return (
      <Container style={styles.container}>
        <Header transparent>
          <Left>
            <Button
              transparent
              onPress={() => this.props.navigation.goBack()}
            >
              <Icon.Ionicons
                name={
                  Platform.OS === 'ios'
                  ? 'ios-arrow-back'
                  : 'md-arrow-back'
                }
                size={24}
                style={styles.backBtn}
                color='black'
              />
            </Button>
          </Left>
        </Header>

        {this.state.feed &&
          <Content style={styles.content}>
            <Image
              source={{uri: this.state.feed.image}}
              style={styles.image}
            />
            <View style={styles.words}>
              {this.state.user &&
                <View style={styles.writer}>
                  <Thumbnail small source={{uri: this.state.user.avatar}} style={styles.avatar} />
                  <View>
                    <Text style={styles.writerName}>{this.state.user.name}</Text>
                    <Text style={styles.date}>{this.state.feed.updated_at} にこの記事は更新されています</Text>
                  </View>
                </View>
              }
                
              <View style={styles.divider} />
              
              <Text style={styles.description}>{this.state.feed.message}</Text>
            </View>
          </Content>
        }
      </Container>
    )
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  notLoginContainer: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  content: {
    position: 'absolute',
    top: 0,
    zIndex: -1,
  },
  image: {
    // square !!
    width: width,
    height: width,
  },
  words: {
    flex: 1,
    padding: 10,
  },
  title: {
    fontWeight: 'bold',
    fontSize: 25,
  },
  avatar: {
    marginRight: 5,
  },
  writer: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  writerName: {
    fontSize: 10.5,
  },
  date: {
    fontSize: 10.5,
    color: 'gray',
  },
  description: {
    fontSize: 20,
  },
  divider: {
    height: 10,
  },
  dividerHalf: {
    height: 5,
  }
})

export default DetailScreen

完成品はこのようになります。

capture capture

最後に

ソースコードあげておきました。
https://github.com/kousaku-maron/insta-sample

85
63
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
85
63

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?