LoginSignup
3
2

More than 1 year has passed since last update.

【React Native】expo-cameraを利用した端末に画像を保存するカメラ画面の作り方

Last updated at Posted at 2023-01-03

主に使用するライブラリ

expo-camera

react-native-gesture-handler

撮影画面のズーム

expo-image-manipulator

撮影後の画像を回転

expo-file-system

端末ディレクトリ内に画像を保存

完成画面

内カメ・外カメの切り替え、フラッシュやズームなどの最低限の機能は実装しています。
カメラのアクセス許可がされていない場合は、カメラのアクセス許可が必要ですを表示します。

撮影前の画面

S__85655563.jpg

撮影後の画面

S__85655566.jpg

CameraScreen

画面を上と下で7:3の比率で分けています。
撮影後にimageを更新し、撮影前と撮影後の画面を出し分けています。
完了を押すと、ファイル名を次の画面に渡すようにしています。

CameraScreen.tsx
import { useEffect, useState } from "react"
import {
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
  Image,
  Platform,
  Button,
} from "react-native"
import Layout from "../constants/Layout"
import { Entypo, Feather, MaterialIcons, Ionicons } from "@expo/vector-icons"

import { HomeStackParamProps } from "../types"

import { Camera, CameraType, FlashMode } from "expo-camera"
import { manipulateAsync, SaveFormat } from "expo-image-manipulator"
import {
  GestureEvent,
  GestureHandlerRootView,
  PinchGestureHandler,
  PinchGestureHandlerEventPayload,
} from "react-native-gesture-handler"

import { saveToDirAndReturnPath } from "../utils/lib"

export default function CameraScreen({
  navigation,
  route,
}: HomeStackParamProps<"Camera">) {

  const [permission, requestPermission] = Camera.useCameraPermissions()
  const [camera, setCamera] = useState<Camera | null>(null)
  const [image, setImage] = useState<string | null>(null)
  const [type, setType] = useState<CameraType.back | CameraType.front>(
    CameraType.back
  )
  const [zoom, setZoom] = useState(0)
  const [flash, setFlash] = useState(false)

  useEffect(() => {
    requestPermission()
  }, [])

  // ピンチによるズーム
  const onPinchGestureEvent = (
    e: GestureEvent<PinchGestureHandlerEventPayload>
  ) => {
    var scale = e.nativeEvent.scale
    var velocity = e.nativeEvent.velocity / 20

    let newZoom =
      velocity > 0
        ? zoom + scale * velocity * (Platform.OS === "ios" ? 0.01 : 25)
        : zoom -
          scale * Math.abs(velocity) * (Platform.OS === "ios" ? 0.02 : 50)

    if (newZoom < 0) newZoom = 0
    else if (newZoom > 0.5) newZoom = 0.5

    setZoom(newZoom)
  }

  if (!permission) {
    return <View />
  }
  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text style={styles.noPermissionText}>
          カメラのアクセス許可が必要です
        </Text>
        <Button onPress={() => navigation.goBack()} title={"キャンセル"} />
      </View>
    )
  }

  // 内カメ・外カメの切り替え
  const toggleCameraType = () => {
    setType((current) => {
      return current === CameraType.back ? CameraType.front : CameraType.back
    })
  }

  // 写真の撮影
  const takePicture = async () => {
    if (camera) {
      const image = await camera.takePictureAsync({})
      setImage(image.uri)
    }
  }

  return (
    <View style={styles.container}>
      <View style={{ flex: 7, justifyContent: "center" }}>
        {image !== null ? (
          <View>
            <Image
              source={{ uri: image ? image : undefined }}
              style={styles.image}
            ></Image>
            <View style={styles.rowEndContainer}>
              <TouchableOpacity
                onPress={async () => {
                  const manipResult = await manipulateAsync(
                    image!,
                    [{ rotate: 90 }],
                    { compress: 1, format: SaveFormat.PNG }
                  )
                  setImage(manipResult.uri)
                }}
                style={styles.rotateButton}
              >
                <MaterialIcons
                  name="rotate-right"
                  size={Layout.window.height * 0.0375}
                  color="gray"
                />
              </TouchableOpacity>

              <TouchableOpacity
                onPress={async () => {
                  const manipResult = await manipulateAsync(
                    image!,
                    [{ rotate: -90 }],
                    { compress: 1, format: SaveFormat.PNG }
                  )
                  setImage(manipResult.uri)
                }}
                style={styles.rotateButton}
              >
                <MaterialIcons
                  name="rotate-left"
                  size={Layout.window.height * 0.0375}
                  color="gray"
                />
              </TouchableOpacity>
            </View>
          </View>
        ) : (
          <View>
            <GestureHandlerRootView>
              <PinchGestureHandler onGestureEvent={onPinchGestureEvent}>
                <Camera
                  style={{
                    width: Layout.window.width,
                    height: Layout.window.width,
                  }}
                  type={type}
                  flashMode={flash ? FlashMode.on : FlashMode.off}
                  zoom={zoom}
                  ref={(ref) => setCamera(ref)}
                ></Camera>
              </PinchGestureHandler>
            </GestureHandlerRootView>
            <View style={styles.rowEndContainer}>
              <TouchableOpacity
                onPress={() => setFlash(!flash)}
                style={styles.flashButton}
              >
                <Ionicons
                  name={!flash ? "flash-outline" : "flash-off-outline"}
                  size={Layout.window.height * 0.025}
                  color="white"
                />
              </TouchableOpacity>
            </View>
          </View>
        )}
      </View>
      <View style={{ flex: 3, justifyContent: "center" }}>
        {image !== null ? (
          <View>
            <View style={{ alignItems: "center" }}>
              <TouchableOpacity onPress={() => setImage(null)}>
                <Text style={styles.againText}>もう一度撮影する</Text>
              </TouchableOpacity>
              <TouchableOpacity
                style={{
                  marginTop: Layout.window.height * 0.02,
                }}
                onPress={async () => {
                  const newUri = (await saveToDirAndReturnPath(
                    image!
                  )) as string
                  navigation.replace("EditableImage", {
                    image: newUri.slice(newUri.lastIndexOf("/") + 1),
                  })
                }}
              >
                <Text style={styles.completeText}>完了</Text>
              </TouchableOpacity>
            </View>
          </View>
        ) : (
          <View style={styles.mainButtonContainer}>
            <TouchableOpacity
              style={{ marginRight: Layout.window.width * 0.1 }}
              onPress={() => navigation.goBack()}
            >
              <MaterialIcons
                name="cancel"
                size={Layout.window.height * 0.04}
                color="white"
              />
            </TouchableOpacity>
            <TouchableOpacity onPress={takePicture}>
              <Entypo
                name="circle"
                size={Layout.window.height * 0.075}
                color="white"
              />
            </TouchableOpacity>

            <TouchableOpacity
              style={{ marginLeft: Layout.window.width * 0.1 }}
              onPress={toggleCameraType}
            >
              <Feather
                name="refresh-cw"
                size={Layout.window.height * 0.035}
                color="white"
              />
            </TouchableOpacity>
          </View>
        )}
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center",
    backgroundColor: "#333",
  },

  noPermissionText: {
    textAlign: "center",
    fontWeight: "bold",
    fontSize: Layout.window.width * 0.04,
  },

  image: {
    width: Layout.window.width,
    height: Layout.window.width,
    borderColor: "white",
    borderWidth: 1,
  },

  rowEndContainer: {
    flexDirection: "row",
    justifyContent: "flex-end",
  },

  rotateButton: { marginRight: 16, marginTop: 8 },

  flashButton: {
    marginRight: 16,
    marginTop: 8,
    borderWidth: 0.75,
    borderRadius: Layout.window.height * 0.03,
    padding: 4,
    borderColor: "white",
  },

  againText: {
    color: "white",
    fontSize: Layout.window.width * 0.04,
  },

  completeText: {
    color: "white",
    marginTop: Layout.window.height * 0.03,
    fontSize: Layout.window.width * 0.045,
    fontWeight: "bold",
  },

  mainButtonContainer: {
    flexDirection: "row",
    justifyContent: "space-evenly",
    alignItems: "center",
  },
})

アプリをアップデートすると、FileSystemディレクトリ名は自動で変更されます。
端末やサーバーなどにパスを保存する際は、フルパスではなくファイル名のみを保存するようにしてください。

lib.ts
import * as FileSystem from "expo-file-system"

// ディレクトリに画像を保存してフルパスを返す関数
export const saveToDirAndReturnPath = async (uri: string) => {
  const tempDir = FileSystem.documentDirectory as string
  const imgName = uri.slice(uri.lastIndexOf("/") + 1)
  FileSystem.getInfoAsync(tempDir).then((dirInfo) => {
    if (!dirInfo.exists) {
      FileSystem.makeDirectoryAsync(FileSystem.documentDirectory as string, {
        intermediates: true,
      })
    }
  })
  const to = tempDir + imgName
  await FileSystem.copyAsync({ from: uri, to })
  return to
}

おまけ

紹介したカメラ画面は以下のアプリ内で実装しています。
散歩や旅行が好きな方は、ぜひ使ってみてください。

地図に写真を記録しよう - KOKOII(ここいい)-

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2