はじめに
サーバー周りはFirebase、インターフェースはexpo(React Native)でアプリケーションを作ります。Firebase、React Nativeの基本的な知識は知っている前提で進めます。
今回作るアプリのコアな機能
Facebook認証を利用したログイン機能
写真と文言をセットにした投稿機能
ユーザープロフィール編集機能
※いいねとフォロー機能は今回実装しません。
Firebaseの設定
SNS認証について
別記事で解説してます。そちらを参照してください。
【React Native】ExpoでFirebase AuthenticationのSNS認証を利用する方法
データの保存先について
投稿内容とプロフィールのデータは基本的にFirestoreに保存する。例外としてプロフィールと投稿内容の画像ファイルはstorageに保存し、Firestoreに対応するURLを保存させる。
Firestoreの設定
UserとFeedのコレクションを作る。
Userはユーザーごとにドキュメントが存在し、avatar(アバター画像のURL)
、name(ニックネーム)
を保存させる。
Feedには投稿ごとにドキュメントが存在し、message(メッセージ)
、image(画像のURL)
、created_at(投稿日時)
、updated_at(更新日時)
、writer(投稿者)
を保存させる。
※本来であれば、Userにもcreated_at
とupdated_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フォルダに適当に作って入れておいてください。
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)の手ほどき」の記事で解説している。
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にします。
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を作成します。
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
import { createActions } from 'redux-actions'
const actions = createActions(
{
SET_USER_UID: (args) => (args),
SET_USER_PROPERTIES: (args) => (args),
}
)
export default actions
configureStore.js
ファイルを作成し、storeを作成する関数を定義します。
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
で読み込んでいるコンポーネントをコンテナーに変更します。
...
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を利用するように設定します。
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を保存しておく。
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の認証状態チェックコードはどこに書くべきなのか」の記事で解説している。
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
のみだが、増えたとき簡単に束ねなれるように。
import { all } from 'redux-saga/effects'
import auth from './auth'
export default function* rootSaga() {
yield all([
...auth,
])
}
configureStore.js
でsagaがちゃんと動くように設定する。
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から取ってきて、ローカルストアに保持させています。
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
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側で送信用データも管理させた方が綺麗かもしれません、、、
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で一元管理した方が綺麗になると思います。
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_at
とupdated_at
はfirebaseのapiでサーバーの時間を保存させるように命令することができるので、それで解決している。
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
を取得し、uuid
でFeed
の情報を取得して表示させています。また、Feed
のwriter
からUser
の情報を取得して表示させています。
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
完成品はこのようになります。
最後に
ソースコードあげておきました。
https://github.com/kousaku-maron/insta-sample