React Native + TypeScript で iOS アプリのひな形を作る

こんにちは。[Japan Digital Design](https://japan-d2.com/)でインフラチームに所属している[猪熊](https://twitter.com/sinocloudon)です。JDDでは、有志で AdventCalendar をやっています。本記事は [JDD Advent Calendar](https://adventar.org/calendars/5160) 22日目です。よろしくお願いいたします。

とあるきっかけでスマホアプリ開発を手伝うことになりました。これまでサーバサイド、インフラメインで業務してきたのでフロントエンドやネイティブアプリ開発経験はほとんどありません(5年前くらいに Socket.io と Node.js で簡単なチャットアプリ作ってみた程度)。
今回はプロジェクトの雛形を作るまでにやったことを共有します。
私のようなフロントエンド・ネイティブアプリ開発経験のないエンジニアの方の参考になれば幸いです。

# React Nativeにした理由
ゆくゆくはiOSだけでなくAndroidもリリースしたいものの、2つ開発する余力がなく、クロスプラットフォームを選んでいます。上に書いたように JavaScript の経験は多少あったのでReact Nativeでやってみようということになりました。

**※社内で投稿を確認してもらっていて知ったのですが、特にモバイル開発者未経験者には、[Expo CLIを使った開発](https://reactnative.dev/docs/environment-setup)を推奨しているそうです。今回の記事には含まれませんが、そちらも検討してみても良いかも知れません。**

# React Native + TypeScript で iOS アプリのひな形を作る

さて、ここからが本編です。

## 開発環境構築編
まずは開発環境を構築します。前提条件は以下です。

* 使用端末： MacBook Pro / macOS Catalina 10.15.7
* 使用エディタ： Visual Studio Code

環境構築に関する情報は検索すると沢山出てきます。
今回は詳細は割愛して、インストールしたものの列挙に留めます。

### VSCode Extensions

* ESLint
* Prettier
* EditorConfig for VSCode
* Jest

### 実行環境・開発ツール系

* Node.js(私は anyenv 経由でインストールしました)
* yarn
* watchman
* Xcode
* CocoaPods

## 雛形プロジェクト作成編

まずは、React Native のプロジェクトを作成します。

```bash
npx react-native init RNSampleApp --template react-native-template-typescript
```

作成が終わったらアプリが起動することを確認します。

```bash
cd RNSampleApp
npx react-native run-ios
```

### package.json を編集する

次に `package.json` を編集します。

#### `name` を変更する

`RNSampleApp` のままだと、警告が表示されるので適当に変更してきます。
ここでは、 `rn-sample-app` としておきます。

#### パッケージバージョンの`^` (ハット)を削除する

例えば、以下のようにバージョン番号の前に `^` がついているものがあります。

```json
  "devDependencies": {
    "@babel/core": "^7.8.4",
    "@babel/runtime": "^7.8.4",
    "@react-native-community/eslint-config": "^1.1.0",
    "@types/jest": "^25.2.3",
    "@types/react-native": "^0.63.2",
    "@types/react-test-renderer": "^16.9.2",
    "@typescript-eslint/eslint-plugin": "^2.27.0",
    "@typescript-eslint/parser": "^2.27.0",
    "babel-jest": "^25.1.0",
    "eslint": "^6.5.1",
    "jest": "^25.1.0",
    "metro-react-native-babel-preset": "^0.59.0",
    "react-test-renderer": "16.13.1",
    "typescript": "^3.8.3"
  }
```

先頭に `^` がついているパッケージは、メジャーバージョンしか固定されません。
`yarn` コマンド実行時に常に最新のマイナーバージョン、パッチバージョンのものがインストールされるため、記載されているバージョンとは異なるパッケージがインストールされる可能性があります。
(特にチーム開発をしている場合は)環境差異によるトラブルを避けるためにも削除しておいた方が良いと思います。

#### Prettierの設定を行う

Prettierを追加することで、常にコードフォーマットされるようになります。

```bash
yarn add --dev --exact prettier
```

`.prettierrc.yaml` をプロジェクトルートディレクトリ直下に作成します。

```yaml
endOfLine: lf
tabWidth: 2
semi: true
singleQuote: true
trailingComma: all

overrides:
  - files: '*.json'
    options:
      parser: json
  - files:
    - '*.md'
    - '*.markdown'
    options:
      parser: markdown
  - files:
    - '*.ts'
    - '*.tsx'
    options:
      parser: typescript
  - files:
    - '*.yml'
    - '*.yaml'
    options:
      parser: yaml
```

#### ESLintの設定を行う

ESLintは静的解析ツールです。コード品質を一定水準に保つのに役立つので追加しましょう。

```bash
yarn add --dev --exact eslint
yarn add --dev --exact @typescript-eslint/parser
yarn add --dev --exact @typescript-eslint/eslint-plugin
yarn add --dev --exact eslint-config-prettier
yarn add --dev --exact eslint-plugin-prettier
yarn add --dev --exact eslint-plugin-react
yarn add --dev --exact eslint-plugin-jest
yarn add --dev --exact @react-native-community/eslint-config
```

`.eslintrc.yaml` をプロジェクトルートディレクトリ直下に作成します。

```yaml
---
root: true
extends:
  - plugin:@typescript-eslint/recommended
  - prettier
  - prettier/@typescript-eslint
  - plugin:react/recommended
  - '@reactnativecommunity'
  - plugin:react-native/all
  - plugin:jest/recommended
parser: '@typescript-eslint/parser'
parserOptions:
  project: ./tsconfig.json
  ecmaFeatures:
    jsx: true
plugins:
  - '@typescript-eslint'
  - react
  - react-native
  - jest
settings:
  react:
    version: detect
rules:
  no-console: warn
```

#### EditorConfigの設定を行う

EditorConfigの設定を置いておくことで、インデントなどコード形式を統一することができます。
`.editorconfig` をプロジェクトルートディレクトリ直下に作成します。

```config
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
```

#### 実際に雛形アプリを作ってみる

それでは、実際に雛形を作っていきましょう。
ソースコードは全て `src`ディレクトリ内に含めることとします。
プロジェクトルートディレクトリ直下に `App.jsx` が配置されていますが、こちらをsrcディレクトリ直下に移動してください。
最終的なディレクトリ構成は以下のようになります。

```
RNSampleApp/src
├── App.tsx
├── components
│   └── pages
│       ├── Detail
│       │   └── index.tsx
│       ├── Home
│       │   └── index.tsx
│       ├── MyPage
│       │   └── index.tsx
│       ├── Search
│       │   └── index.tsx
│       └── index.ts
├── constants
│   ├── screen.ts
│   └── theme.ts
└── routes
    ├── Main
    │   ├── Home.tsx
    │   └── index.tsx
    └── index.tsx
```

##### アプリ必要なパッケージをインストールする

アプリに必要なパッケージを追加でインストールします。
インストール完了後、 `package.json` を開き、先ほどと同様に `^` のついたパッケージバージョンについては、 `^` を削除しておきましょう。

```bash
yarn add @react-navigation/native
yarn add @react-navigation/stack react-native-safe-area-context react-native-gesture-handler react-native-screens @react-native-community/masked-view
yarn add @react-navigation/material-bottom-tabs react-native-paper
yarn add react-native-vector-icons
yarn add @types/react-native-vector-icons
```

アイコン用のフォントをビルド成果物に含める必要があるので、 `ios/RNSampleApp/info.plist` に以下の定義を追加してください。

```xml
    <key>UIAppFonts</key>
    <array>
      <string>AntDesign.ttf</string>
      <string>Entypo.ttf</string>
      <string>EvilIcons.ttf</string>
      <string>Feather.ttf</string>
      <string>FontAwesome.ttf</string>
      <string>FontAwesome5_Brands.ttf</string>
      <string>FontAwesome5_Regular.ttf</string>
      <string>FontAwesome5_Solid.ttf</string>
      <string>Foundation.ttf</string>
      <string>Ionicons.ttf</string>
      <string>MaterialIcons.ttf</string>
      <string>MaterialCommunityIcons.ttf</string>
      <string>SimpleLineIcons.ttf</string>
      <string>Octicons.ttf</string>
      <string>Zocial.ttf</string>
    </array>
```

CocoaPodsのリポジトリを更新します。

```bash
rm -rf ios/build
cd ios
pod install --repo-update
cd ..
```

##### index.jsを修正する

```javascript
/**
 * @format
 */

import { AppRegistry } from 'react-native';
import App from './src/App'; // './App' から './src/App' に変更する
import { name as appName } from './app.json';

AppRegistry.registerComponent(appName, () => App);

```

##### src/constants/screen.tsを作成する

画面名を示す定数を作成します。
今回は、ホーム画面、詳細画面(ホーム画面から遷移)、検索画面、マイページの4画面とします。

```ts
export const HOME = 'HOME';
export const MY_PAGE = 'MY_PAGE';
export const DETAIL = 'DETAIL';
export const SEARCH = 'SEARCH';
```

##### src/constants/theme.tsを作成する

コンポーネントで使用する色を示す定数を作成します。
今回は、画面下部のタブに使用する2つの色を定義しています。

```ts
export const COLOR = {
  BOTTOM_TAB: '#87cefa',
  TAB_ACTIVE: '#000000',
};
```

##### 各画面を作成する

ここからは4つの画面をそれぞれ作成します。

###### src/components/pages/Home/index.tsx

ホーム画面を作成します。

```tsx
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { DETAIL } from '../../../constants/screen';

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

export default function Home() {
  const { navigate } = useNavigation();
  return (
    <View style={styles.container}>
      <Text>ホーム</Text>
      <TouchableOpacity onPress={() => navigate(DETAIL)}>
        <Text>詳細画面へ遷移する</Text>
      </TouchableOpacity>
    </View>
  );
}
```

###### src/components/pages/Detail/index.tsx

詳細画面を作成します。

```tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';

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

export default function Detail() {
  return (
    <View style={styles.container}>
      <Text>詳細画面</Text>
    </View>
  );
}
```

###### src/components/pages/Search/index.tsx

検索画面を作成します。

```tsx
import React from 'react';
import { View, StyleSheet, Text } from 'react-native';

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

export default function Search() {
  return (
    <View style={styles.container}>
      <Text>検索画面</Text>
    </View>
  );
}
```

###### src/components/pages/Mypage/index.tsx

マイページ画面を作成します。

```tsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';

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

export default function MyPage() {
  return (
    <View style={styles.container}>
      <Text>マイページ</Text>
    </View>
  );
}
```

###### src/components/pages/index.ts

`pages` ディレクトリから一括でインポートするためのtsファイルを作成します。

```ts
export { default as Detail } from './Detail';
export { default as Home } from './Home';
export { default as MyPage } from './MyPage';
export { default as Search } from './Search';
```

##### 画面遷移を定義する

各画面を作成した後は、画面遷移を定義します。

###### src/routes/Main/Home.tsx

ホーム画面と詳細画面の画面遷移を定義します。

```tsx
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { DETAIL, HOME } from '../../constants/screen';
import { Detail, Home } from '../../components/pages';

const Stack = createStackNavigator();

function HomeNavigator() {
  return (
    <Stack.Navigator initialRouteName={HOME}>
      <Stack.Screen
        name={HOME}
        component={Home}
        options={{
          title: 'ホーム',
        }}
      />
      <Stack.Screen
        name={DETAIL}
        component={Detail}
        options={{
          title: '詳細画面',
        }}
      />
    </Stack.Navigator>
  );
}

export default HomeNavigator;
```

###### src/routes/Main/index.tsx

メイン画面の画面遷移を定義します。

```tsx
import React from 'react';
import { createMaterialBottomTabNavigator } from '@react-navigation/material-bottom-tabs';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import { HOME, MY_PAGE, SEARCH } from '../../constants/screen';
import { Search, MyPage } from '../../components/pages';
import { COLOR } from '../../constants/theme';
import Home from './Home';

const Tab = createMaterialBottomTabNavigator();

export default function MainNavigator() {
  return (
    <Tab.Navigator
      initialRouteName={HOME}
      activeColor={COLOR.TAB_ACTIVE}
      shifting={true}
    >
      <Tab.Screen
        name={HOME}
        component={Home}
        options={{
          tabBarColor: COLOR.BOTTOM_TAB,
          tabBarLabel: 'ホーム',
          tabBarIcon: ({ color }) => (
            <MaterialCommunityIcons name="home" color={color} size={25} />
          ),
        }}
      />
      <Tab.Screen
        name={SEARCH}
        component={Search}
        options={{
          tabBarColor: COLOR.BOTTOM_TAB,
          tabBarLabel: '検索',
          tabBarIcon: ({ color }) => (
            <MaterialCommunityIcons
              name="map-search-outline"
              color={color}
              size={25}
            />
          ),
        }}
      />
      <Tab.Screen
        name={MY_PAGE}
        component={MyPage}
        options={{
          tabBarColor: COLOR.BOTTOM_TAB,
          tabBarLabel: 'マイページ',
          tabBarIcon: ({ color }) => (
            <MaterialCommunityIcons name="account" color={color} size={25} />
          ),
        }}
      />
    </Tab.Navigator>
  );
}
```

###### src/routes/index.tsx

メイン画面の画面遷移定義を `NavigationContainer` でラップします。

```tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import MainNavigator from './Main';

export default function MainRouter() {
  return (
    <NavigationContainer>
      <MainNavigator />
    </NavigationContainer>
  );
}
```

##### アプリの起動ポイントとつなぐ

最後に、 `src/App.jsx` を修正して、作成したアプリが表示されるようにしましょう。

```jsx
import React from 'react';
import MainRouter from './routes';

export default function App() {
  return <MainRouter />;
}
```

このようなアプリが表示されれば完成です。

![rn-sample-app.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310595/f905570f-b793-949f-473a-9d407d6ba43e.gif)

# おわりに

かなり簡素なひな形ですが、一応 [Github](https://github.com/sinokuma/rn-sample-app) に公開しておきました。

## やってみた感想

今回は記載を省略しましたが、開発環境構築するだけでも大変だと感じました。
様々なツールの様々な設定をおさえるだけでも結構な時間がかかりそうです。
各コンポーネントやAPIの設定はさらに時間がかかりそうです...。

現在、業務で [Teleport](https://goteleport.com/)というOSSを扱っています。TeleportのUIは、ReactとTypeScriptで構築されています。今までは、UIの実装を見ても何となくしか内容を理解できなかったのですが、今後はもう少しちゃんと理解することができそうです。せっかくの経験なので業務にも活かしていきたいと思います。

## 今後に向けて

基本的なパッケージしか使用していないので、お手伝いを通してもっと多くの React 、React Native のパッケージに触れたいと思います。推奨されている Expo CLI を使ったひな形も公開したいですね。
また、サーバサイドは AWS Amplify または Firebase を利用する予定です。開発が進んで、さらにノウハウが溜まったらまた何らかの形で整理して共有したいと思います。