【React Native】Expo-Camera でカメラアプリを作成してみた

## はじめに
　カメラアプリが作成したくてReact NativeのExpo-Cameraで作成してみました。
　しかし、クロスプラットフォーム（というかExpo-Camera自体）の機能に限界を感じたため、途中で断念……。

　それでも、自分の端末で動作はしたので、誰かの役に立てばいいな、と思い共有させていただきます。

## 機能
　- メディアライブラリへのアクセス権限の確認
　- カメラ機能の使用権限の確認
　- 写真の撮影
　- 撮影した写真をカメラロールに保存
　- カメラビューのズームイン・ズームアウト
　- カメラの切り替え（インカメラ or アウトカメラ）
　- フラッシュモードの切り替え（ON or OFF）
　- 撮影した写真を確認できるモーダルの表示（カメラロールへのアクセスは不可）
　- 複数の写真を撮影した場合、スワイプにて写真送りで確認ができる

## 実装予定だったけど断念した機能
　- フォトモーダル内の写真の拡大、縮小、及びスワイプによる移動
　- iOS標準のカメラアプリと同様に、フォトモーダル内で一度タップするとヘッダーが消える。もう一度タップするとヘッダーが表示される（背景も黒くなれば良い？）
　- 下スワイプでモーダルを閉じる（カメラビューが再表示される）
　- カメラの解像度を上げたい（Expo-Cameraの機能的にできるのか分からない……）

　※ 今後もし実装できたら情報を更新するかもしれません。

## 環境
```
    "expo": "~52.0.23",
    "expo-camera": "~16.0.10",
    "expo-image-picker": "~16.0.4",
    "expo-media-library": "~17.0.4",
    "expo-status-bar": "~2.0.0",
    "react": "18.3.1",
    "react-native": "0.76.5",
    "react-native-color-matrix-image-filters": "^7.0.2",
    "react-native-gesture-handler": "^2.24.0"
```
　- iPhone15Proでのみ動作確認済み（Androidでは未確認）
　- 開発環境はMacなのでWindowsでの動作は保証しません ^^;

## 動作画面
![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4032668/39ded0c0-7431-4b98-99c0-3ab43052a3fe.gif)

## コード
#### 本体
```App.tsx
import { CameraView, CameraType, useCameraPermissions, FlashMode } from 'expo-camera';
import { useState, useEffect } from 'react';
import { Button, StyleSheet, Text, TouchableOpacity, View, Image, Platform } from 'react-native';
import {
  GestureHandlerRootView,
  GestureDetector,
  Gesture,
} from "react-native-gesture-handler"
import * as MediaLibrary from 'expo-media-library'; // メディアライブラリをインポート
import PhotosModal from './PhotosModal';

export default function App() {
  const [facing, setFacing] = useState<CameraType>('back'); // インカメラ・アウトカメラ切替え用のstate
  const [permission, requestPermission] = useCameraPermissions(); // カメラ権限を許可するためのstate
  const [camera, setCamera] = useState<CameraView | null>(null); // CameraViewのstate(本体)
  const [mediaLibraryPermission, setMediaLibraryPermission] = useState(false); // メディアライブラリを許可するためのstate
  const [flash, setFlash] = useState<FlashMode>('off') // フラッシュモードのOn Off切替え用のstate
  const [photos, setPhotos] = useState<boolean>(false); // PhotoModalの表示切り替え用のstate
  const [picture, setPicture] = useState<string | null>(null); // 撮影した写真のuriを保存するstate
  const [pictureList, setPictureList] = useState<{ key: number; uri: string }[]>([]); // 撮影した写真を保存する配列
  const [zoom, setZoom] = useState(0) // ズーム用のstate

  // カメラパーミッションのロード
  useEffect(() => {
    // メディアライブラリ権限の確認
    async function checkPermissions() {
      const { status } = await MediaLibrary.requestPermissionsAsync();
      setMediaLibraryPermission(status === 'granted');
    }
    checkPermissions();
  }, []);

  // 写真を撮影するたびにpictureの値(uri形式)を配列に保存する
  // keyは自動付番とする
  useEffect(() => {
    if (picture) {
      setPictureList((prevList) => [
        ...prevList,
        { key: prevList.length + 1, uri: picture }
      ]);
    }
  }, [picture]);

  // カメラ権限がない場合
  if (!permission) {
    return <View />;
  }

  // カメラ権限が許可されていない場合
  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text style={styles.message}>アプリを使用するためにカメラの使用を許可してください</Text>
        <Button onPress={requestPermission} title="アクセスを許可する" />
      </View>
    );
  }

  // メディアライブラリ権限がない場合
  if (!mediaLibraryPermission) {
    return (
      <View style={styles.container}>
        <Text style={styles.message}>写真を保存するためにメディアライブラリの使用を許可してください</Text>
        <Button onPress={async () => {
          const { status } = await MediaLibrary.requestPermissionsAsync();
          setMediaLibraryPermission(status === 'granted');
        }} title="メディアライブラリのアクセスを許可する" />
      </View>
    );
  }

  // ピンチによるズーム
  const pinchGesture = Gesture.Pinch()
    .onUpdate((e) => {
      let newZoom = zoom + (e.scale - 1) * 0.025; // 0.05 はズームの感度調整
      newZoom = Math.max(0, Math.min(newZoom, 0.5)); // 0 ~ 1 の範囲に制限
      setZoom(newZoom);
    });

  // 写真の撮影
  async function takePicture() {
    if (camera) {
      const photo = await camera.takePictureAsync();
      console.log(photo);

      // 写真を保存
      if (photo !== undefined) {
        const asset = await MediaLibrary.createAssetAsync(photo.uri);
        setPicture(photo.uri); // 撮影した写真をstateにセットする
        await MediaLibrary.createAlbumAsync('MyAppPhotos', asset, false); // 'MyAppPhotos' アルバムに保存
      }
    } else {
      console.log("camera is Null");
    }
  }
  
  // PhotosModalの表示
  function photosOpen() {
    setPhotos(true);
  }

  // カメラの切り替え（イン・アウト）
  function toggleCameraFacing() {
    setFacing(current => (current === 'back' ? 'front' : 'back'));
  }

  // フラッシュの切り替え
  function flashMode() {
    setFlash(current => (current === 'off' ? 'on' : 'off')); //ON or OFFの切り替え
  }


  // UI
  return (
    <View style={styles.container}>
      {/* ヘッダー部 */}
      <View style={styles.cameraHeader}>
        {/* フラッシュ */}
        <TouchableOpacity style={styles.flashButton} onPress={flashMode}>
          {flash === 'off' ? (
            <Text style={styles.text2}>×</Text>
          ) : (
            <Text style={styles.text2}>⚪︎</Text>
          )}
        </TouchableOpacity>
      </View>
      {/* カメラビュー */}
      <GestureHandlerRootView>
        <GestureDetector gesture={pinchGesture}>
          <CameraView style={styles.camera} facing={facing} flash={flash} ref={(ref) => setCamera(ref)} zoom={zoom} />
        </GestureDetector>
      </GestureHandlerRootView>
      {/* ボタンコンテナ */}
      <View style={styles.buttonContainer}>
        {/* PhotoModalを開く */}
        {picture ? (
            <TouchableOpacity style={styles.cameraRollButton} onPress={photosOpen} activeOpacity={0.7}>
              <Image source={{ uri: picture }} style={styles.pictureMini} resizeMode="contain" />
            </TouchableOpacity>
          ) : (
            <View style={styles.cameraRollButton}>
              <Text style={styles.pictureMini}>{' '}</Text>
            </View>
          )
        }
        {/* 撮影ボタン */}
        <TouchableOpacity style={styles.snapButton} onPress={takePicture}>
          <Text style={styles.text}>◉</Text>
        </TouchableOpacity>
        {/* カメラ切り替えボタン */}
        <TouchableOpacity style={styles.toggleCameraButton} onPress={toggleCameraFacing}>
        <Text style={styles.text2}>C</Text>
        </TouchableOpacity>
      </View>
      {/* PhotoModalコンポーネント */}
      <PhotosModal 
        setPhotos={setPhotos}
        photos={photos}
        picture={picture}
        pictureList={pictureList}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    display: 'flex',
    flexDirection: 'column',
    justifyContent: 'center',
    height: '100%',
  },
  message: {
    textAlign: 'center',
    paddingBottom: 10,
  },
  cameraHeader: {
    height: 120,
    flexDirection: 'row',
    backgroundColor: 'black',
    justifyContent: 'flex-start',
    alignItems: 'center',
    paddingTop: 50,
  },
  flashButton: {
    justifyContent: 'center',
    alignItems: 'flex-start',
    alignSelf: 'center',
    marginLeft: 10,
    marginBottom: 10,
  },
  camera: {
    flexGrow: 1,
  },
  buttonContainer: {
    height: 180,
    flexDirection: 'row',
    backgroundColor: 'black',
    justifyContent: 'center',
    alignItems: 'center',
    paddingBottom: 20,
  },
  cameraRollButton: {
    justifyContent: 'center',
    alignItems: 'flex-start',
    alignSelf: 'center',
  },
  snapButton: {
    justifyContent: 'center',
    alignItems: 'center',
    alignSelf: 'center',
    marginLeft: 60,
    marginRight: 60,
  },
  toggleCameraButton: {
    justifyContent: 'center',
    alignItems: 'flex-end',
    alignSelf: 'center',
    width: 70,
  },
  text: {
    fontSize: 100,
    fontWeight: 'bold',
    color: 'white',
  },
  text2: {
    fontSize: 50,
    fontWeight: 'bold',
    color: 'white',
  },
  icon: {
    width: 40,
    height: 40,
  },
  pictureMini: {
    height: 100,
    width: 70,
  }
});
```

#### 撮影した写真を表示するためのモーダル
```PhotosModal.tsx
import { View, StyleSheet, Text, Image, FlatList, Dimensions } from "react-native";

const { width, height } = Dimensions.get("window");

export default function PhotosModal(props: any) {
    // PhotosModalを閉じる
    const photosClose = () => {
        props.setPhotos(false);
    }

    // key降順（最新が先頭にくる）
    const sortedPictures = [...props.pictureList].sort((a, b) => b.key - a.key);

    return(
        <>
            {props.photos ? (
            <View style={styles.fullscreen}>
                <View style={styles.PhotosModalHeader}>
                    <Text onPress={photosClose} style={styles.headerText}>{'＜'}</Text>
                </View>
            <FlatList
                data={sortedPictures}
                keyExtractor={(item) => item.key.toString()}
                horizontal
                pagingEnabled
                showsHorizontalScrollIndicator={false}
                inverted
                renderItem={({ item }) => (
                <Image source={{ uri: item.uri }} style={styles.image} />
                )}
            />
            </View>
            ) : (
                <></>
            )}
        </>
    );
}

const styles = StyleSheet.create({
    fullscreen: {
        ...StyleSheet.absoluteFillObject, // 画面全体を覆う
        backgroundColor: 'white',
        justifyContent: 'center',
        alignItems: 'center',
    },
    PhotosModalHeader: {
        width: '100%',
        height: 120,
        backgroundColor: 'rgb(240, 240, 240)',
        position: 'absolute',
        top: 0,
        zIndex: 2,
    },
    headerText: {
        position: 'absolute',
        bottom: 0,
        width: 70,
        marginLeft: 15,
        marginBottom: 10, 
        fontSize: 30,
        fontWeight: 'bold',
        color: 'rgba(0, 130, 255, 1)',
      },
    image: {
        width: width, // 画面幅いっぱいに表示
        height: height, // 画面高さいっぱいに表示
        resizeMode: "contain",
      },
});
```

## 解説
　余裕ができた時にやります……多分。

## 終わりに
　Expo-Cameraの情報は多いようで少ないので、誰かのお役に立てていれば幸いです。
　機能的にはreact-native-vision-cameraの方がいいという噂も……。
　興味が出たら触ってみたいと思います。

　また、ReactやReact Native初学者の方で観てくださっている方がいましたら、環境構築や基礎知識などは、ベテランプログラマーの方々がたくさん紹介しているので、そちらをご参照ください。

　ここ違うよ！とか、こうしたらもっと良くなるよ！みたいな意見がありましたら、いただけると後学のためになります。

　以上、Expo-Cameraの実装記事でした。