【React Native＋Expo】モバイルアプリ開発初心者がサクッと簡単に3つの機能を作ってみた

# はじめに
[NRI OpenStandia Advent Calendar 2024 シリーズ2](https://qiita.com/advent-calendar/2024/nri-openstandia)の 23日目担当のあやかです。

普段、私は業務でReactを使いWebアプリを開発していますが、モバイルアプリ開発にも興味がありました。ただ、なかなか最初の一歩を踏み出せずにいました。

そこで今回は、できるだけ手軽にモバイルアプリ開発を始める方法に挑戦してみようと思います！
React開発者にとっては学習コストが低い**React Native**を使い、さらに簡単に環境構築や動作確認ができる**Expo**というフレームワークを活用します。

「モバイルアプリ開発に興味はあるけど最初のハードルが高そう…」と思っている方も、この記事を読めば簡単に始められるはずです！
興味のある方は、ぜひ読んでみてください。

# 環境作成
[React Native 公式サイト](https://reactnative.dev/docs/environment-setup)を参考にして、開発環境を作成していきます。

React Nativeの開発には、フレームワークを使う方法と使わない方法がありますが、公式サイトではフレームワークを使う方法が推奨されています。また、その方が手軽に始められるため、今回はExpoというフレームワークを使って環境を構築しました。

## 手順
①Node.jsインストール
Node.jsがまだインストールされていない場合は、[公式サイト](https://nodejs.org/en/download/package-manager)からインストールしてください。
今回はv22.12.0を使用しました。（2024/12/20時点の推奨バージョン）

②プロジェクト作成
以下のコマンドを実行して、Expoアプリのプロジェクトを作成します。

```bash
npx create-expo-app@latest
```

③Expoのユーザーアカウントを作成
[Expoの公式サイト](https://expo.dev/)からユーザーアカウントを作成してください。

④Expo Goアプリをスマホにインストール
開発アプリをスマホで動作確認するため、Expo Goアプリをインストールします。
•	iPhoneの場合: App Storeからダウンロード
•	Androidの場合: Google Play Storeからダウンロード


今回はiPhoneを使用しました。詳細な手順は[こちら](https://docs.expo.dev/get-started/set-up-your-environment/?mode=expo-go)を参考にしてください。


以上で準備は完了です！

# 動作確認
作成したプロジェクトのディレクトリに移動し、以下のコマンドを実行してアプリを起動します。

```bash
npm run start
```
コマンドを実行すると、以下のようにターミナルにQRコードが表示されます。

![CBD06264-0165-4D57-8A63-D6A795699D10.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/697416/b6a594d2-4ecc-d82a-1b9d-d70e6db4e946.jpeg)

このQRコードをiPhoneのカメラで読み取り、Expo Goアプリで開きます。
これで、スマホ上で開発中のアプリの動作を確認することができます。

![Init.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/697416/7fbc5951-0aa4-9047-5a0c-72e54c3c871c.gif)

:::note warn
PCとデバッグ用のスマホが同じネットワークに接続されている必要があります。
:::

コマンドプロンプトで```r```キーを押すと、iPhoneのExpo Goアプリがリロードされて表示が更新されます。

# Expoのライブラリを使用して機能を作成する

環境作成手順2で作成したプロジェクトを基に、機能の追加や変更を行います。
Expoのドキュメントには、提供されている機能やその使い方が詳しく記載されているので、それを参考にしながら進めていきます。

https://docs.expo.dev/versions/latest/sdk

## カメラで写真撮影し保存する
カメラで写真撮影し、その写真を保存する機能を作成してみます。

まず、使うライブラリをインストールします。
```bash
npm install expo-camera expo-media-library
```
続いて、app>(tabs)配下に配置されている`_layout.tsx`にカメラタブを作成します。
タブに使用するiconは、端末がApple製品の場合
https://developer.apple.com/sf-symbols/ 
から探し、`IconSymbol`の`name`に指定します。

```tsx
<Tabs.Screen
  name="photoPage"
  options={{
    title: 'Photo',
    tabBarIcon: ({ color }) => <IconSymbol size={28} name="camera" color={color} />,
  }}
/>
```

次に、app>(tabs)配下に`photoPage.tsx`ファイルを作成します。
Expoのドキュメントに従って、カメラの権限確認は`useCameraPermissions`というhooks、カメラのcomponentは`CameraView`を使います。

```tsx
import { useCameraPermissions } from 'expo-camera';

const [status, requestPermission] = useCameraPermissions();
```

```tsx
import { CameraView } from 'expo-camera';

<CameraView style={styles.camera}>
  {/* Viewやボタン */}
</CameraView>
```
また、画像の保存に使うExpo MediaLibraryには、メディアライブラリの権限確認を行う`requestPermissionsAsync`や、メディアライブラリにメディアアセットを追加する`createAssetAsync`、指定された名前でフォルダ作成する`createAlbumAsync`が用意されています。

```tsx
import { requestPermissionsAsync } from 'expo-media-library';

const { status } = await MediaLibrary.requestPermissionsAsync();
```

```tsx
import { createAssetAsync } from 'expo-media-library';

const asset = await MediaLibrary.createAssetAsync(uri);
```

```tsx
import { createAlbumAsync } from 'expo-media-library';

const asset = await MediaLibrary.createAssetAsync(uri);
```

これらを使用して作成した機能がこちらです↓

![Photo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/697416/e97ce868-724a-a54d-ef20-b51931b6ea44.gif)

ソースコードの全量はこちらです。

```photoPage.tsx
import React, { useState, useRef } from 'react';
import { View, Button, Text, StyleSheet, Image, ScrollView, Alert } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import {requestPermissionsAsync, createAssetAsync, createAlbumAsync} from 'expo-media-library';

const PhotoPage = () => {
  const [permission, requestPermission] = useCameraPermissions();
  const [photo, setPhoto] = useState<string | null>(null);
  const cameraRef = useRef<CameraView | null>(null);
  const [mediaLibraryPermission, setMediaLibraryPermission] = useState<boolean | null>(null);

  // カメラの権限付与
  if (!permission) {
    return <View style={styles.container} />
  }
  if (!permission.granted) {
    return (
      <View style={styles.notGrantedContainer}>
        <Button onPress={requestPermission} title="カメラの起動を許可" />
      </View>
    );
  }

  // メディアライブラリの権限確認
  if (mediaLibraryPermission === null) {
    (async () => {
      const { status } = await requestPermissionsAsync();
      setMediaLibraryPermission(status === 'granted');
    })();
  }

  // 写真の撮影と保存
  const takePicture = async () => {
    if (cameraRef.current) {
      const photo = await cameraRef.current.takePictureAsync();
      if (photo) {
        setPhoto(photo.uri);

        // メディアライブラリへ保存
        if (mediaLibraryPermission) {
          try {
            const asset = await createAssetAsync(photo.uri);
            await createAlbumAsync('サンプルアプリフォルダ', asset, false); // 新しいアルバムに保存
            Alert.alert('保存完了', '写真がギャラリーに保存されました！');
          } catch (error) {
            console.error('写真の保存に失敗しました: ', error);
            Alert.alert('エラー', '写真の保存に失敗しました');
          }
        } else {
          Alert.alert('権限エラー', 'メディアライブラリのアクセス権限がありません');
        }
      }
    }
  }

  return (
    <ScrollView style={styles.container}>
      {/* カメラ部分 */}
      <View style={styles.cameraContainer}>
        <CameraView style={styles.camera} ref={cameraRef}>
          <View style={styles.buttonContainer}>
            <Button title="写真を撮影" onPress={takePicture} />
          </View>
        </CameraView>
      </View>

      {/* 撮影写真表示部分 */}
      <View style={styles.imageContainer}>
        {photo && (
          <View style={styles.photoContainer}>
            <Text>撮影した写真:</Text>
            <Image source={{ uri: photo }} style={styles.image} />
          </View>
        )}
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  notGrantedContainer: {
    flex: 1,
    justifyContent: 'center', 
    alignItems: 'center',   
  },
  container: {
    flex: 1,
  },
  cameraContainer: {
    height: 600,
    justifyContent: 'flex-end',
  },
  camera: {
    width: '100%',
    height: '100%',
    justifyContent: 'flex-end',
  },
  buttonContainer: {
    backgroundColor: 'transparent',
    flexDirection: 'row',
    justifyContent: 'center',
    marginBottom: 20, // ボタンをカメラビューの下に配置
  },
  imageContainer: {
    justifyContent: 'center',
    alignItems: 'center',
    marginTop: 20,
    paddingBottom: 300,
  },
  photoContainer: {
    alignItems: 'center',
  },
  image: {
    width: 300,
    height: 300,
    borderRadius: 10,
    marginTop: 10,
  },
});

export default PhotoPage;
```

## 画面の向きを固定する
次はスマホの画面の向きを指定した向きで固定する機能を作成していきます。

カメラ機能と同様に使うライブラリをインストールし、
```bash
npm install expo-screen-orientation
```
`_layout.tsx`に新しいタブを作成します。

```tsx
<Tabs.Screen
  name="screenOrientationPage"
  options={{
    title: 'Orientation',
    tabBarIcon: ({ color }) => <IconSymbol size={28} name="iphone.gen3.circle" color={color} />,
  }}
/>
```

そして、app>(tabs)配下に`screenOrientationPage.tsx`ファイルを作成します。
Expoのドキュメントに従い、指定された向きに画面を固定する`lockAsynck`、画面の方向を指定する`OrientationLock`、画面の向きを端末で指定されているデフォルトの向きに戻す`unlockAsync`を使います。

```tsx
import { lockAsync, OrientationLock, unlockAsync } from 'expo-screen-orientation';

lockAsync(OrientationLock.PORTRAIT_UP);
unlockAsync();
```

これらを使用して作成したものがこちらです↓

![ScreenOrientation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/697416/ab295f75-234d-974e-e22e-0571cde94585.gif)

ソースコードの全量はこちら↓
```screenOrientationPage.tsx
import React, { useEffect } from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import { lockAsync, OrientationLock, unlockAsync, } from 'expo-screen-orientation';

const ScreenOrientationPage = () => {
  useEffect(() => {
    // マウント時の画面は縦向き固定
    lockAsync(OrientationLock.PORTRAIT_UP);

    // アンマウント時はデバイスで設定している向きに戻す
    return () => {
      unlockAsync();
    };
  }, []);

  const lockLandscape = async () => {
    // 横向きにロック
    await lockAsync(OrientationLock.LANDSCAPE);
  };

  const lockPortrait = async () => {
    // 縦向きにロック
    await lockAsync(OrientationLock.PORTRAIT_UP);
  };

  const unlockOrientation = async () => {
    // デバイスで設定している向きにする
    await unlockAsync();
  };

  return (
    <View style={styles.container}>
      <Text style={styles.text}>画面の向きの制御サンプル</Text>
      <Button title="横向きにロック" onPress={lockLandscape} />
      <Button title="縦向きにロック" onPress={lockPortrait} />
      <Button title="画面の向きを解除" onPress={unlockOrientation} />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  text: {
    fontSize: 18,
    marginBottom: 20,
  },
});

export default ScreenOrientationPage;
```

## 位置情報を表示する
最後に、位置情報を表示する機能を作っていきます。

こちらも同様に必要なライブラリをインストールし、
```bash
npm install expo-location
```
`_layout.tsx`に新しいタブを作成します。

```tsx
<Tabs.Screen
  name="locationPage"
  options={{
    title: 'Location',
    tabBarIcon: ({ color }) => <IconSymbol size={28} name="location" color={color} />,
  }}
/>
```

そして、app>(tabs)配下に`locationPage.tsx`ファイルを作成します。
Expoのドキュメントに従い、位置情報の権限をリクエストする`requestForegroundPermissionsAsync`、位置情報の緯度経度を取得する`getCurrentPositionAsync`、緯度経度から住所に変換する`reverseGeocodeAsync`を使います。

```tsx
import { requestForegroundPermissionsAsync } from 'expo-location';

let { status } = await requestForegroundPermissionsAsync();
```
```tsx
import { getCurrentPositionAsync, reverseGeocodeAsync } from 'expo-location';

const userLocation = await getCurrentPositionAsync({});
setLocation({
  latitude: userLocation.coords.latitude,
  longitude: userLocation.coords.longitude,
});

const geocode = await reverseGeocodeAsync({
  latitude: userLocation.coords.latitude,
  longitude: userLocation.coords.longitude,
});

```

これらを使用して作成したものがこちらです↓

![loc1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/697416/29ed58c3-ee6f-e0d5-d814-2fad8f28a7a0.gif)


位置情報許可画面
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/697416/2ee1d239-d3ab-ef63-424a-6caf720ec547.png" width=50%>

位置情報が許可されてない場合
![loc2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/697416/06f2f10c-8af9-7e85-6dcc-4742115ed396.gif)

ソースコードの全量はこちら↓

```LocationPage.tsx
import React, { useState, useEffect } from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import * as Location from 'expo-location';

const LocationPage = () => {
  const [location, setLocation] = useState<{ latitude: number; longitude: number } | null>(null);
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
  const [address, setAddress] = useState<string | null>(null);

  const getLocation = async () => {
    // 位置情報の権限をリクエスト
    let { status } = await Location.requestForegroundPermissionsAsync();
    if (status !== 'granted') {
      setErrorMsg('位置情報のアクセス権限が必要です');
      return;
    }

    try {
      // 位置情報取得
      const userLocation = await Location.getCurrentPositionAsync({});
      setLocation({
        latitude: userLocation.coords.latitude,
        longitude: userLocation.coords.longitude,
      });

      // 住所取得
      const geocode = await Location.reverseGeocodeAsync({
        latitude: userLocation.coords.latitude,
        longitude: userLocation.coords.longitude,
      });

      // 住所情報の取得と整形
      if (geocode.length > 0) {
        const { country, region, city, street } = geocode[0];
        const fullAddress = `${country} ${region} ${city} ${street}`;
        setAddress(fullAddress);
      }
    } catch (error) {
      setErrorMsg('位置情報の取得に失敗しました');
    }
  };

  useEffect(() => {
    getLocation();
  }, []);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>現在位置情報</Text>

      {errorMsg ? (
        <Text style={styles.errorText}>{errorMsg}</Text>
      ) : location ? (
        <View>
          <Text style={styles.text}>緯度: {location.latitude}</Text>
          <Text style={styles.text}>経度: {location.longitude}</Text>
          {address && <Text style={styles.text}>住所: {address}</Text>}
        </View>
      ) : (
        <Text style={styles.text}>位置情報を取得しています</Text>
      )}

      {/* 位置情報を再取得するボタン */}
      <Button title="位置情報を再取得" onPress={getLocation} />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 20,
  },
  text: {
    fontSize: 18,
    marginBottom: 10,
  },
  errorText: {
    fontSize: 18,
    color: 'red',
    marginBottom: 10,
  },
});

export default LocationPage;
```

# まとめ
React NativeとExpoを使うことで、モバイルアプリの開発を迅速に行うことができました。
普段からReactを扱っている開発者であれば、React Nativeは特別な言語の習得は必要なく、スムーズに開発を進められます。
また、Expoを活用することで、開発環境の作成が簡単になり、さらに多くの便利な機能が提供されているため、開発が一層効率的になります。