Sweet Brissie life

ブリスベンでのサトウキビ博士研究生活の甘くない備忘録

{React Native} 単語帳アプリ GoogleAPI + unstated + AsyncStorage

React Nativeに手を出してから既に2か月ほど。とりあえずいろんな機能のチュートリアルを試してみたけれど、ようやくそれらを参考に単語帳アプリを自分で作ってみた。

 

ざっくりこんな感じ。

f:id:tkd708:20190930015737g:plain

Language app demo

 まだまだコードの書き方が拙いし(しかも参考にした数々チュートリアルの影響が色濃く残っているので一貫してない)、機能やUIにも欠陥が無数にあるし(ところどころアプリ感は出せても、ださい、、、)本来とても表に出せたものじゃない。けど、自分で取り組んでいてRNの日本語の参考例はもっと出回ってほしいと感じたし、あわよくば熟練者の方からアドバイスをいただけたらな、といった理由で公開することに。

 

本当はチュートリアル形式で紹介したかったけど、途中経過を各段階で載せてくのはさすがにしんどかった(チュートリアル記事の偉大さにしみじみ)ので、完成コードと要点紹介という形式で。

 

主に練習したかった機能としては

  • Google翻訳+axiosでのAPI処理
  • Unstatedによる状態管理  
  • Asyncstorageでのローカルストレージを用いた単語帳とタグ機能

 

また、UIでは主に下記を活用。

  • react-navigation
  • react-native-action-sheet
  • react-native-swipe-list-view
  • expo-linear-gradient

 

ソースコードはこちら。

https://github.com/tkd708/language-app

 

 

 さて、それでは個別にどんな感じで実装したか紹介!

 

まず翻訳機能を使うのにGoogleAPI Keyが必要になります。取得方法は下記参照。https://cloud.google.com/translate/docs/quickstart-client-libraries

 

で、そのAPIが公開されちゃうと困るのでPublicにならないようにする処理はこちら。

(今回は実装し損ねたので、const API_KEY = を書き換えてやってくださいな)

https://dev.to/robertchen234/how-to-use-google-translate-api-27l9

 

さて、翻訳部分のfunctionはこんな感じで書きました。

(components/AddScreen.js)


  onTranslate(){
    const URL = `https://translation.googleapis.com/language/translate/v2?key=${API_KEY}&source=${this.state.langFrom}&target=${this.state.langTo}&q=${this.state.convertedText}&format=text`;    
    axios.get(URL)
    .then(res => {
      this.setState({ outputText: res.data.data.translations[0].translatedText });
      return
  }).catch(err => {
      console.log('err:'err);
      return
  })
  }

API Keyに加えて、source=の先に翻訳元言語、target=の先に翻訳先言語を設定してq=の先に翻訳元のテキストを入れる感じです。

これらはUIのPickerとかTextInputで入ってくる状態としました(このコンポーネント内のみで完結)。

ちなみに、format=textを足しとかないと特殊記号('とか各種アクセント記号とか、フランス語だと頻出)が翻訳先で出てきたときに文字コードで返ってきちゃうので注意。

 

API使うのに定番のaxios、下記がシンプルで分かりやすかった。

https://weblion303.net/1485/

 

Google Translate実装の部分で参考にしたのはこちら。

http://blog.zenof.ai/create-a-language-translation-mobile-app-using-react-native-and-google-apis/

 

Node.jsでの実行例なんかも参考にしてました。アウトプットの構造を勘違いしててsetStateでうまく保存されないみたいな凡ミスもしてました...

https://www.atmarkit.co.jp/ait/articles/1705/16/news019.html

https://www.nodejsera.com/how-to-use-google-translator-with-nodejs.html

 

 

  • Unstatedによる状態管理  

Google Translate APIを用いて単語を翻訳してタグもつけて(components/AddScreen.js)、検索した単語を一覧表示する(components/ListScreen.js)のに、これらのコンポーネント間で単語の情報をやり取りするのに状態管理をする必要が出てきます(よね?)。

 

そこで状態管理といえば王道はRedux。。。なのは間違いないと思われますが、下記の記事にある通りこの程度の試作アプリにはコード量も増えてファイル構造も複雑化してかえって苦労するので、より手軽なunstatedを採用。実際、Reduxより遥かに手軽だと感じました。

次の状態管理はReduxをやめてunstatedにする理由 - Qiita

 

1. Storeの役割も兼ねるContainerを用意する(importしたContainerをextends)

(containers/WordContainer.js)

export default class WordContainer extends Container {
  constructor(props) { 
        super(props);
        this.state = {
          loadingItems: false,
          allItems: ,
          allTags: ,
          taggedItems: ,
          isCompleted: false,    
        };
  }

Component間をまたいで参照するようなFunctionたちはこのContainerの中に格納しました。 

 

2. 各ComponentからContainerをSubscribeする。

(下記の例ではAddScreenをWordContainerにSubscribe) (components/AddScreen.js)

 const AddWrapper = () => (
  <Subscribe to={[WordContainer]}>
    {word => <AddScreen word={word} />}
  </Subscribe> 
)
export default AddWrapper

 

3. 全体を<Provider>でWrapする。

(App.js)

export default class App extends React.Component {
  render() {
    return (
      <Provider>
        <ActionSheetProvider>
          <LinearGradient colors={['#1E90FF''white''#FF4F50']} style={flex: 1}}>
            <Navigator /> 
          </LinearGradient>
        </ActionSheetProvider>
      </Provider>
    );
  }
}

 

こんなもんです。Containerに格納された状態や関数にアクセスするには、

this.props.AAA.state.BBB (AAAはSubscribe時に指定した、BBBは状態名)

this.props.AAA.CCC (AAAはSubscribe時に指定した定数、CCCは関数名)

といった書き方になる(はず、、、?上記記事ではrender内では始めにconst AAA = this.propsと

して、AAA.state.BBBやAAA.CCCでアクセスしている)

(components/AddScreen.js & ListScreen.js)

  componentDidMount() {
    this.props.word.loadingItems();
  }

 

それなりに大規模アプリになって、コンポーネントも多数、開発人数も多数、となってこない限りはunstatedでいいんじゃないかなぁ、と思った次第。

 

 

  • Asyncstorageでのローカルストレージを用いた単語帳とタグ機能

こちらの機能の実装はほとんどが下記のTodoアプリのチュートリアルから。

https://pusher.com/tutorials/build-to-do-app-react-native-expo

 

変更点のほとんどがJS関連のものでした。改めてRN書くにもJSが基本なんだなと実感。。。

(containers/WordContaienr.js)

 onDoneAddItem = (inputoutputtags=> {
    if (output !== '') {
      this.setState(prevState => {
        const id = uuid(); // create a new ID using uuid  
        const newItemObject = { // create an object called newItemObject which uses the ID as a variable for the name.
          [id]: {
            id,
            isCompleted: false,
            wordIn: input,
            wordOut: output,
            tags: tags,
            createdAt: Date.now()
          }
        };
        // create a new object called newState which uses the prevState object, clears the TextInput for newInputValue 
        // and finally adds our newItemObject at the end of the other to do items list.
        const newState = {
          ...prevState,
          allItems: {
            ...prevState.allItems,
            ...newItemObject
          },
          taggedItems: {
            ...prevState.taggedItems,
            ...newItemObject
          }
        };
        this.saveItems(newState.allItems);
        return { ...newState };
      });
    }
  };

  saveItems = newItems => {
    const saveItem = AsyncStorage.setItem('Words'JSON.stringify(newItems));

    let mergedTags = ;
    for (let i = 0i < Object.values(newItems).lengthi++) {
    mergedTags.push(...Object.values(newItems)[i].tags);
    }
    let filteredTags = mergedTags.filter(function(xiself) {
    return self.indexOf(x=== i;
    });
    this.setState({
      allTags: filteredTags
    });
  };

  loadingItems = async () => {
    try {
      const loadedItems = await AsyncStorage.getItem('Words');
      const allItems = JSON.parse(loadedItems)
      let mergedTags = [];
      for (let i = 0i < Object.values(allItems).lengthi++) {
        mergedTags.push(...Object.values(allItems)[i].tags);
      }
      let filteredTags = mergedTags.filter(function(xiself) {
      return self.indexOf(x=== i;
      });
      this.setState({
        loadingItems: true,
        allItems: allItems || {},
        taggedItems: allItems || {},
        allTags: filteredTags
      });
    } catch (err) {
      console.log(err);
    }
  };

  onTagPress = (selectedTag=> {
    let wordsWithTag = {};
    for (let i = 0i < Object.values(this.state.allItems).lengthi++) {
      if (Object(Object.values(this.state.allItems)[i]).tags.indexOf(selectedTag>= 0 ) {
        //wordsWithTag.push(Object.values(this.state.allItems)[i]);
        let id = (Object.values(this.state.allItems)[i]).id;
        let content = Object.values(this.state.allItems)[i];
        var word = { [id]: content };
        let wordsWithTag = {...wordsWithTag...word};
        this.setState({
          taggedItems: wordsWithTag,
        });
      }
    }
  }  

 

AsyncStorageだとJSON形式のみなので、逐一Stringify(上記内ではsaveItems)とParse(上記内ではloadingItems)しなくちゃいけないからあまりお勧めされないらしい。次はSQLiteとか使いたいところ、、、

https://qiita.com/kaba/items/569aafd80889bb5d9328

 

このJSON縛りのせいだからか(?)、onDoneAddItemのところで、ID(uuidから生成)をkeyとし、翻訳前後の単語やタグなどの情報をvalueとする、入れ子のobject構造をprevStateに書き足していくという形式をとっている。そして更新されたStateをsaveItemに送ってAsyncStorageに書き込む。 

 

これらはTodoアプリ記事に倣っていて、変更したのはallItemsだけでなくtaggedItemsのObjectを増やした点。加えて、saveItemsとloadingItemsの中にあるmergedTags(全単語から付加されたTagをすべて一つの配列にPushしたもの)、filteredTags(filterで重複をはじいたもの)、そして最後にallTags(filteredTagsをsetState)することによってタグ一覧を生成してる。onTagPressはタグを選んだときのFunctionで、一致するタグを持つ単語を配列(ここではwordsWithTag)に入れるという作業をforで回して全探索して、その配列を別の状態で保存する(ここではtaggedItems)というやりかた。

 

これらのFunctionのs下の方にDeleteItem, CompleteItem, IncompleteItemと続きますが参考にしたTodoアプリ記事と丸被りなのでここでは省略。keyにしたIDを用いて削除や完了の操作をしている。

 

 

 

機能面で主要な点はこんなところです。引き続いてUIに活用したLibraryの紹介!

 

  • react-navigation

これは、このアプリ全体、そして自分のReactNative学習全般を通してとても参考にしている下記のシリーズから。SplashScreen(Splashって本来はアプリ起動前に現れる画面のこと?)とか、ほぼそのままです。非常に丁寧な解説、感謝しております。

https://note.mu/cube0529/n/n4a130029dfe1

 

また、下記のシリーズも分かりやすく、また今回は採用しなかったけどReduxの実装例もついているのでおすすめです。

React-Navigatorを利用してみる(基礎編) - Qiita

 

 

  • react-native-swipe-list-view

単語帳の表示の仕方として、翻訳前の単語を一覧にして、確認したい単語の訳をパッと引き出せる形式がよい、と思っていたとろで巡り合ったLibrary。Swipeして削除とかのボタンを表示させるアレ。

 

下記の開発元の具体例が充実してるのでそのまま。今回はその中のStandAloneRowを採用。CSS(styles)での調整がメインかな。

https://github.com/jemise111/react-native-swipe-list-view

 

  • react-native-action-sheet

タグ一覧を表示するのに、当初modalを使っていたけどこちらに変更。そのままnpmに記載の例から。

https://www.npmjs.com/package/@expo/react-native-action-sheet

 

Library内のshowActionSheetWithOptionsというメソッドに、options(画面に表示される文字列での選択肢)と、そのindexそれぞれに対する操作(buttonIndex)を指定する。今回はoptionsにタグ一覧に加えて、Show all wordsという全単語表示の選択肢を用意。それによってIndexが一つずれるのでonTagPressに送るタグのindexはbuttonIndex - 1。

(components/ListScreen.js)

  onOpenActionSheet = () => {
    const options = ["Show all words"]
    const optionsFull = options.push(...this.props.word.state.allTags);

    this.props.showActionSheetWithOptions(
      { 
        options,
      },
      buttonIndex => {
        if (buttonIndex === 0){
          this.props.word.selectAllWords();
        }else{
          this.props.word.onTagPress(this.props.word.state.allTags[buttonIndex-1]);
        }
      },
    );
  };

また、アプリの上位層で<ActionSheetProvider>によるWrapが必要。

(App.js)

export default class App extends React.Component {
  render() {
    return (
      <Provider>
        <ActionSheetProvider>
          <LinearGradient colors={['#1E90FF''white''#FF4F50']} style={flex: 1}}>
            <Navigator /> 
          </LinearGradient>
        </ActionSheetProvider>
      </Provider>
    );
  }
}

 

...後から知ったのが、類似のreact-native-actionsheetというlibraryもあるようで、しかもこっちの方がgithubのスターも多いようで、、、

https://github.com/beefe/react-native-actionsheet

 

 

  • expo-linear-gradient

背景色をグラデーションにするというもの、複数色指定可能。これも先述のTodoアプリの記事を参考にしつつ、またアプリ全体にこの背景を適用するには上層でWrapすればよいとの記載をみつけたので今回はその形で実装。

 

 

 

さてさて、長くなってしまったけれどこんな感じでした!

 

改めてJSとCSSが基礎であることを痛感するとともに、何よりただチュートリアルをなぞるだけじゃなくそれらを組み合わせて自作する(多くは転用ですが)ことは格段に工夫を要するし一段階上の練習になるということを実感。

 

なるべく自作しながら必要だったり興味のある機能を調べながら実装していく、というのが現段階では大切なのだろうと思います。

 

 

最後に、参考にさせていただいた数々の貴重な記事とその作成者への感謝と、この記事とコードも稚拙ながら誰かの一助となるよう願いを込めて。