全ての記事

welogger app 開発メモ 1 - react-nativeを試してみた

2017-11-11 @sunderls

welogger react-native

welogger.comはずっとウェブアプリでやっていこうと思ったが、作って見てやっぱりウェブアップはだめだと判断した。なぜなら:

  1. 今のiOS standaloneモードでは毎回リロードになって、めんどくさい、前回アクセスしたurlにならない
  2. ロードするのはやっぱり遅い
  3. iOSはserice workerのサポートを始めたが、まだまだ
  4. 写真追加トリガーしたら、反応が遅い。写真選択したらできることが少ない。binaryで操作はできるが、めんどくさい。
  5. webviewのスクロール領域によく問題が出てくる
  6. 時間・文字の入力などのキーボードはコントロール不能。
  7. ロケーションなど毎回確認ダイアログがでる
  8. ブラウザの使用には育成するコストがある

なのでやっぱり、ネイティブのアプリを作ることにした。きっかけとしてreact-nativeを触ってみる。第一日目はこんな物をできた。

はじめに

まずreact-nativeのガイドを見る。すごく簡単:

npm install -g create-react-native-app
create-react-native-app welogger
cd welogger
npm start

実機か、シミュレーターで開くかを決め、App.jsを変更したらhot reloadで更新される。

css 調整

普通のcssとはちょっと違う、慣れる必要がある、でも大した違いがない。大事なのはflexbox,他のアトリビュートは必要な時にドキュメントを見ればいい。 以下はトップページのスタイル。

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'center',
    flexDirection: 'column',
    justifyContent: 'space-between',
  },
  main: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'center',
    flexDirection: 'column',
    justifyContent: 'center'
  },
  title: {
    textAlign: 'center',
    color: '#2196f3',
    fontSize: 30,
    lineHeight: 60,
    fontFamily: 'Avenir Next',
    fontWeight: 'bold'
  },
  intro: {
    textAlign: 'center',
    lineHeight: 25,
    fontSize: 16,
    color: '#555'
  },
  bottom: {
    backgroundColor: '#fff',
    justifyContent: 'center',
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingLeft: 40,
    paddingRight: 40,
    paddingBottom: 50
  }
});

react-navigation nesting

公式に勧められたのはreact-navigationなので、試して見た。 weloggerのページは簡単にする:

  1. Home -> ログイン状態を見て出し分けをする。必要な場合はログインさせる。
  2. Login -> modal transitionでログイン画面を出して、ユーザーネームとパスワードを入力させる
  3. Profile ->ここは一般なpush inのtransitionで、プロフィールページを右から表示させる

transitionが違うので、navigator nestingでやって見る。

一番外はmodal StackNavigator、中にデフォルトのStackNavigatorを入れる。

App.js

const App = StackNavigator({
  Home: { screen: Home },
  Profile: { screen: Profile }
});

export default StackNavigator({
  App: { screen: App },
  Login: { screen: Login },
}, {
  mode: 'modal',
  headerMode: 'none'
});

headerの処理にご注意を。何もしなければ、homeとprofileに二つのheaderが出ちゃう。Homeのheaderは閉じられるが、profileには必要なので、ここで簡単にやるため一番外のnavigatorのheaderをオフにした。headerMode:'none'。これでlogin画面で自作のcancel が必要になる。まあもっといい方法あるけど、一旦これで。

JWTでsessionを管理

weloggerのウェブ版はすでにjwtを使ってるので、appも同じ仕組みで、基本的に何もいじらなくてサクッと入れた。

  1. jwtをAsyncStorageに保存。localStorageと同じ
  2. API requestではAsyncStorageのtokenをつける
  3. API responseのheaderの中にtokenがあれば、AsyncStorageのtokenを更新する。
  4. ログインの時tokenを発行し、AsyncStorageを更新する。

Api.js

APIの処理を個別にして、fetch apiを使う。

api.js

import { t } from './Locale';
import { AsyncStorage } from 'react-native';

const API_BASE = 'xxxxx';

function handle(response) {
    const header = response.headers.get('Authorization');
    if (header) {
        const token = header.split(/\s+/)[1];
        AsyncStorage.setItem('token', token);
    }

    return response.json().then(result => {
        if (!response.ok) {
            if (typeof result.error.message === 'string') {
                alert(t(result.error.message));
            } else if (typeof result.error.message === 'object') {
                alert(Object.keys(result.error.message).map(key => {
                    let msg = result.error.message[key];
                     return Array.isArray(msg) ? msg.map(t).join('\n') : t(msg);
                }).join('\n'));
            }
            throw new Error(result.error.message || result.error); 
        }
        return result;
    });
}

const Api = {
    async get(path, params){
        const token = await AsyncStorage.getItem('token');
        return fetch(API_BASE + path, {
            mode: 'cors', 
            method: 'GET', 
            headers: {
                'Authorization': 'Bearer ' + token,
            }
        }).then(handle)
    },

    async post(path, params){
        const token = await AsyncStorage.getItem('token');
        return fetch(API_BASE + path, {
            mode: 'cors', 
            method: 'POST', 
            body: JSON.stringify(params),
            headers: {
            'Authorization': 'Bearer ' + token,
            'Content-Type': 'application/json',
            Accept: 'application/js'
            }
        }).then(handle)
    },

    delete(path, params){
        return fetch(API_BASE + path, {
            mode: 'cors', 
            method: 'DELETE', 
            body: JSON.stringify(params),
            headers: {
            'Authorization': 'Bearer ' + localStorage.getItem('token'),
            'Content-Type': 'application/json',
            Accept: 'application/js'
            }
        }).then(handle)
    }
}

export default Api;

Login の処理

Login画面は簡単だ、input二つ用意して、tokenの発行APIを叩けば良い。

Login.js

import API from '../lib/Api';
import { AsyncStorage } from 'react-native';

export default class Login extends React.Component {
    static navigationOptions = {
        title: 'login',
    }

    constructor(props) {
        super(props);

        this.state = {
            email: ''
        };
    }

    cancel = () => {
        this.props.navigation.goBack();
    }

    login = async () => {
        API.post('/auth', {
            email: this.state.email,
            password: this.state.password
        }).then(async res => {
            await AsyncStorage.setItem('token', res.token);
            this.props.navigation.goBack();
        });
    }

    render() {
        return (
        <View style={styles.container}>
            <View style={styles.main}>
            <TextInput
                style={styles.input}
                onChangeText={(text) => this.setState({email: text})}
                placeholder="email"
                value={this.state.email}
            />
            <TextInput
                style={styles.input}
                onChangeText={(text) => this.setState({password: text})}
                placeholder="password"
                secureTextEntry={true}
                value={this.state.password}
            />
            <Button title="LOGIN" onPress={this.login} />
            <Button title="cancel" onPress={this.cancel} />
            </View>
        </View>
        );
    }
}

Home画面

HomeではAPI loadingの状態とログインずみかの状態を見て、文字をだす。 Screenはあとで説明する。

Home.js

export default class Home extends Screen {
  constructor(props) {
    super(props);

    this.state = {
      isLoading: true
    }
  }

  // homeにheaderがない
  static navigationOptions = {
      header: null
  }

  //ログイン画面へ
  gotoLogin = () => {
    this.props.navigation.navigate('Login');
  }

  // プロフィール画面へ、nameを渡す
  gotoProfile = () => {
    this.props.navigation.navigate('Profile', { name: this.state.user.name})
  }

  // logoutの時、tokenを消すだけ。
  logout = async () => {
    await AsyncStorage.removeItem('token');
    this.setState({
      user: null
    });
  }

  componentDidMount() {
    super.componentDidMount();

    this.getData().then(this.update)
  }

  getData() {
    return API.get('/init').catch(e => {
      this.setState({
        user: null
      });
    });
  }

  update = (data) => {
    this.setState(Object.assign({}, {
      isLoading: false
    }, data));
  }

  // ログイン画面から戻ったら
  // ここのhookは自作であり、後ほど説明する。
  componentDidFocus() {
    this.getData().then(this.update)
  }

  render() {
    const state = this.state;
    return (
      <View style={styles.container}>
        <View style={styles.main}>{
          state.isLoading ? <Text style={styles.intro}>loading</Text>
            : state.user ? <Button title={state.user.name} onPress={this.gotoProfile} />
            : <Text style={styles.intro}>welcome</Text>
        }
        </View>

        <View style={styles.bottom}>
          <Button title="SIGNUP" onPress={this.signup}/>
          <Button title="LOGOUT" onPress={this.logout}/>
          <Button title="LOGIN" onPress={this.gotoLogin}/>
        </View>
      </View>
    );
  }
}

profile画面

ここのかんた、this.props.navigation.state.paramsからパラメーターが取れる。

Profile.js

export default class Profile extends React.Component {
    static navigationOptions = {
        title: 'Profile',
    }

    constructor(props) {
        super(props);

        this.state = {
            name: ''
        };
    }

    render() {
        return (
        <View style={styles.container}>
            <View style={styles.main}>
            <Text> This is a profile page for {this.props.navigation.state.params.name}</Text>
            </View>
        </View>
        );
    }
}

componentDidFocus

ログイン成功したら前の画面へ戻る、が、どうやって知らせるのか。

これまだ方法見つからなく、react-navigationもまだ議論してるらしい。個人的には「最前面になる」「最前面から降りる」ってhookが必要。LINEマンガ:Page Stackを使ってサクサクなページ遷移を実現できましたの中にはcomponentDidTop()componentDidHide()があり、ここはどうしましょう?

react-navigationonTransitioStartonTransitionEndを提供してる、ここで次の画面の情報を見つけて、emitterを通じて対応できそう。

Screen.jsでscreen.focusをみる。idは自身と一致すれば、再度focusされると判断し、componentDidFocusをトリガーする。

Screen.js

export default class Screen extends React.Component {
    constructor(props) {
        super(props);
    }

    componentDidMount() {
        emitter.addListener('screen.focus', (data) => {
            console.log('screen.focus', data.key, `scene_${this.props.navigation.state.key}`);
            if (data.key === `scene_${this.props.navigation.state.key}`) {
                this.isFocusing = true;
                if (this.componentDidFocus) {
                    this.componentDidFocus();
                }
            }
        })
    }
}

navigatorの中にscreen.focusをemitする。

App.js

export default StackNavigator({
  App: { screen: App },
  Login: { screen: Login },
}, {
  mode: 'modal',
  headerMode: 'none',
  onTransitionStart: (target, current) => {
    if (target.index < current.index) {

      emitter.emit('screen.focus', {
        key: target.scene.key.replace(/(.*-)(\d+)/, '$1' + target.index)
      });
    }
  }
});

これは結構ダサいけど、いける。まあ一旦これで、今後いい方法あれば、その時に。

まとめ

もっともシンプルなweloggerアプリができた。これからは:

  1. i18n
  2. 各ページの機能を作る
  3. geolocation
  4. camera
  5. package