React Nativeでゴルフスコアカウンタアプリ作ってみた(後編)

# この記事でわかること
- ReactNativeを使ったアプリの作り方
- Reactとの基本的な違い
- WEB開発者が少し触ってみた感想

# 背景
最近会社の同僚・大和田さんとゴルフにハマってるんですがスコア・打数・パーなどのルールや用語を覚えられず、割と適当に遊んでいます。笑
そんな中大和田さんと何回か打ちっぱなしに行く中で、「ゴルフスコアカウンタアプリ作ってみません？」と提案をいただきました。
会社では普段Laravel・Vueなど、プライベートではReact(Next)などを扱っていてiOSアプリ開発を1mmも経験していないので、
ReactNativeならとっかかりやすそうだし、今年はアドベントカレンダーに投稿するものが思い当たらなかったのでいい機会だと思ったので、
今回「ゴルフスコアカウンターアプリ」をReact Nativeで作っていこうと思います。

# 作るもの
まずは、機能の洗い出しからいきましょう！
前編はこちら→ [大和田さんの記事](https://qiita.com/NaokiOwada/items/9bccd6fa08eaa7d1ecbe)

# 機能の洗い出し
## ゴルフスコア管理アプリの機能
今回は下記機能に絞ってアプリを作成します

- 現在のホールがわかる
- 現在のホールの規定打数(Par)を入力できる
- 現在の打数を入力できる
- ホール毎のスコアを確認できる
- 最終スコアを確認できる
- リスタートできる

## 画面別機能
- top画面
    - 現在のホールを表示
    - 現在のホールの規定打数(Par)を入力
    - 現在の打数を入力
    - ホールアウトボタン(決定)
    - スコア確認ボタン
- ホールアウト画面
    - スコア結果の表示
    - 次のホールボタン(結果確認画面へ)
- スコア画面
    - スコア一覧表示
    - 総合打数表示
    - 閉じるボタン
- スコア結果確認画面
    - スコア一覧表示
    - 総合打数表示
    - リスタートボタン

## 画面遷移図

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2311891/89ef6ad2-af66-dce7-7d5b-4c07c3427c02.png)

## 画面遷移図の解説

top画面では現在のホールの基本情報を表示させます。
ホールアウト画面では現在のスコアの結果を表示させ、次のホールボタンを押すことでtop画面に遷移させます。
スコア画面ではスコアの一覧を表示させ、Totalの打数を表示させます。
スコア結果確認画面ではスコアの一覧・Totalの打数を表示させ、ゲーム自体をリスタートすることができます。

ゴルフのスコアにはスコアに応じて呼び方(スコア名)があります。
今回は、ホールアウト画面でスコア結果の表示と同時にスコアの呼び方(スコア名)も表示させようと思います。
スコア名は、下記のスコア早見表に則りロジックを組んでいきたいと思います。
## スコア早見表
![https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2079147/22925c40-11d7-f133-4d5c-fac453916212.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2079147/22925c40-11d7-f133-4d5c-fac453916212.png)

## スコア早見表のみかた
![https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2079147/c4af300e-0de7-56fa-c5aa-0ca5a2cb622d.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2079147/c4af300e-0de7-56fa-c5aa-0ca5a2cb622d.png)

# 結果(画面表示)
## 最終ホール以外
![screenshot01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2311891/537faab7-b437-8389-5593-80c042c45080.gif)
## 最終ホール
![screenshot02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2311891/7309426e-f672-8028-6b59-93f14581eb94.gif)

# ソースコード解説
この記事では、カスタムフック部分とTOP表示部分のみを説明します。
全て説明したいのですが、ファイル数が多い為「全部コード見てみたい」という方は以下URLから見てみてください！

Github URL: [https://github.com/Apro-yuto/golf-counter-app](https://github.com/Apro-yuto/golf-counter-app)

## TOP画面
```jsx
import React, {useState} from 'react';
import {
  TouchableOpacity,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import Counter from './hooks/counter';
import Header from './components/Header';
import HitContent from './components/HitContent';
import ParContent from './components/ParContent';
import ResultScoreModal from './components/ResultScoreModal';
import ScoreBoardModal from './components/ScoreBoardModal';

const App = () => {
  const {
    scoreBoard,
    currentScore,
    currentScoreString,
    currentHole,
    onHitChange,
    onParChange,
    onNextHole,
    resetScore,
  } = Counter();

  const [isScoreOpen, setIsScoreOpen] = useState(false);
  const [isHoleResultOpen, setIsHoleResultOpen] = useState(false);
  const [isResultOpen, setIsResultOpen] = useState(false);
  const resetModal = () => {
    setIsHoleResultOpen(false);
    setIsResultOpen(false);
  };

  return (
    <SafeAreaView style={styles.safeAreaContainer}>
      <Header
        hole={currentHole}
        isOpen={isScoreOpen}
        setIsOpen={setIsScoreOpen}
        resetScore={() => resetScore(resetModal)}
        isDisplayResult={isResultOpen}
      />

      <View style={styles.sectionContainer}>
        <View style={styles.sectionContents}>
          <HitContent currentScore={currentScore} onHitChange={onHitChange} />
          <ParContent currentScore={currentScore} onParChange={onParChange} />
        </View>

        <TouchableOpacity
          style={styles.holeOutButton}
          onPress={() => setIsHoleResultOpen(true)}>
          <Text style={styles.holeOutText}>ホールアウト</Text>
        </TouchableOpacity>

        <ResultScoreModal
          currentScoreString={currentScoreString}
          hole={currentHole}
          isOpen={isHoleResultOpen}
          setIsResultOpen={setIsResultOpen}
          onNextHole={() => onNextHole(setIsHoleResultOpen(false))}
        />
        <ScoreBoardModal
          scoreBoard={scoreBoard}
          isOpen={isScoreOpen || isResultOpen}
        />
      </View>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  safeAreaContainer: {
    flex: 1,
  },
  sectionContainer: {
    paddingTop: 100,
    position: 'relative',
    flex: 1,
  },
  sectionContents: {
    flexDirection: 'row',
    alignItems: 'flex-end',
    justifyContent: 'center',
  },

  holeOutButton: {
    width: 250,
    paddingTop: 15,
    paddingBottom: 15,
    backgroundColor: 'tranparent',
    borderWidth: 5,
    borderRadius: 100,
    marginTop: 100,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
  holeOutText: {
    color: '#000',
    fontSize: 30,
    fontWeight: '700',
    textAlign: 'center',
  },
});

export default App;
```

Reactと違うのは、「View」コンポーネントと「Text」コンポーネントを使用しているところです。
以下がそれぞれ公式ドキュメントに記載があったコンポーネントの説明です。

> * Viewコンポーネント → 「**UIを構築するための最も基本的なコンポーネントであるViewは、flexbox,スタイル,一部のタッチ処理,およびアクセシビリティコントロールを使用したレイアウトをサポートするコンテナーです。**」
> 
> * Textコンポーネント → 「**テキストを表示するためのReactコンポーネントです。Text は、ネスト、スタイル設定、タッチ処理をサポートしています。**」

つまりはレイアウトやデザインを構築するために「div」タグなどではなく、「View」コンポーネントを使用して、
テキストを表示するために「p」タグなどではなく「Text」コンポーネントを使用するという感じです。なんだか少しNext.jsに似ている気がします。
また、スタイルについてもコンポーネント毎にCreateしてます。
(コードレベルでの反省を言うと、useContextを使用すればもう少しPropsの量が減らせるかもです)

## カスタムフック(カウンター機能)
```jsx
import {useState, useEffect, useCallback, useMemo} from 'react';
import {canEditHitCount, canEditParCount} from '../partials/counter';
import {LIMIT_HOLE_COUNT, SCORE} from '../constants';
import {generateScoreString} from '../partials';

const Counter = () => {
  let [scoreBoard, setScore] = useState([]);
  let [currentHole, setHole] = useState(1);
  let [hitCount, _] = useState(1);

  // useMemo
  // 現在のスコア
  const currentScore = useMemo(() => {
    return scoreBoard.find(item => item.id === currentHole);
  }, [scoreBoard, currentHole]);

  // 現在のスコア(文字列)
  const currentScoreString = useMemo(() => {
    const currentScoreBoard = scoreBoard.find(item => item.id === currentHole);
    const resultScore = SCORE.find(
      item =>
        item.score === currentScoreBoard?.hitCount - currentScoreBoard?.par,
    );
    return generateScoreString(resultScore);
  }, [currentHole, scoreBoard]);

  // useCallback
  // 現在のスコア(文字列)
  const generateUpdateScore = useCallback(
    updateValue => {
      return scoreBoard.map(item => {
        if (item.id !== currentHole) return item;
        return {
          ...item,
          ...updateValue,
        };
      });
    },
    [scoreBoard, currentHole],
  );
  const onScoreCountChange = useCallback(
    updateValue => {
      setScore(generateUpdateScore(updateValue));
    },
    [setScore, generateUpdateScore],
  );
  const onHitChange = useCallback(
    (direction = 1) => {
      const expectCount = currentScore.hitCount + direction * 1;
      if (!canEditHitCount(expectCount)) return;
      onScoreCountChange({hitCount: expectCount}, direction);
    },
    [onScoreCountChange, currentScore],
  );
  const onParChange = useCallback(
    (direction = 1) => {
      const expectCount = currentScore.par + direction * 1;
      if (!canEditParCount(expectCount)) return;
      onScoreCountChange({par: expectCount}, direction);
    },
    [onScoreCountChange, currentScore],
  );
  const onNextHole = useCallback(
    afterProcess => {
      setHole(currentHole + 1);
      if (!afterProcess) {
        return;
      }
      afterProcess();
    },
    [currentHole, setHole],
  );

  const initScoreBoard = useCallback(() => {
    let scoreBoardArr = [];
    for (let i = 1; i <= LIMIT_HOLE_COUNT; i++) {
      scoreBoardArr.push({
        id: i,
        hitCount: 0,
        par: 3,
      });
    }
    setScore([...scoreBoardArr]);
  }, []);

  const resetScore = useCallback(
    afterProcess => {
      initScoreBoard();
      setHole(1);

      afterProcess();
    },
    [initScoreBoard],
  );

  // useEffect
  useEffect(() => {
    // スコアボードの初期化
    initScoreBoard();
  }, [initScoreBoard]);
  return {
    scoreBoard,
    currentHole,
    currentScore,
    currentScoreString,
    onHitChange,
    onParChange,
    onNextHole,
    resetScore,
  };
};

export default Counter;
```

カウンターアプリに必要な機能を、コンポーネントから切り離すため、カスタムフックを作成しました。正直あまりReactと変わらない気がするので説明は割愛します。
以下の機能をカスタムフックで実装しました。
- 全体のスコア
- 現在のホール
- 現在のスコア
- 現在のスコアテキスト
- 現在の打数をカウントする処理
- 現在のパーを変更する処理
- 次のホールに遷移する処理
- スコアを初期化する処理

規模も規模ですが普通のReactと変わらず開発が進められたので、とても扱いやすかったです。
逆に言えばReactと変わらなかったので、そんなに説明することがなかったので
WEBアプリをただiOSアプリにしただけみたいになりました、、、

## その他コンポーネント等

Github URL: [https://github.com/Apro-yuto/golf-counter-app](https://github.com/Apro-yuto/golf-counter-app)

# 感想

今回は初めてiOSアプリをReact Nativeで作成してみました。
所感としては、「ほんのちょっとめんどくさいReact」といった感じでした。
Style周りがめんどくさかったり、標準のコードフォーマッターがとても優秀なので少し戸惑ったり、、、
デバイスに対する操作が必要な場合はネイティブ言語で書かなきゃいけないみたいなので、ネイティブ言語の学習も必要だったりして、WEBエンジニアが急に触っても実装しにくい機能も多いかもしれません。
ただ、やっぱりJavaScriptで書けるのはすごく扱いやすく、FacebookやSoundCloudなど、多くのアプリで採用されているので、
個人的には今後もiOSアプリを作る際はReact Nativeを一旦採用しようかな、、、といった感じでした！

通知機能やGPS機能など、今後も大和田さんとゴルフをするときにあったら便利だよね！
みたいな機能を、時間を掛けて追加して、このアプリを公開してみようと思います。

とりあえず、このアプリを使用して大和田さんとまたゴルフをするのがすごく楽しみです。
みなさん読んでいただきありがとうございました。いいゴルフライフを！