React NativeのNative Moduleでカメラ起動させてみた(iOS/Swift)

62994 ワード

こんにちは

株式会社アルダグラムの渡辺です

今回は React Native に自作の Native Module を使ってカメラの起動とボタンの配置・イベント発火までをコードベースで解説できればと思います

背景

アルダグラムで提供しているサービス「KANNA」の App は React Native で開発を行なっております。

この KANNA にカメラを使った機能を追加することになりました

はじめは React Native だけでカメラ機能を開発したかったのですが、KANNA で実現したいことはNative を組み込まないとできないと判断したため Native Module 化をすることになりました

※ カメラ機能を利用するだけならreact-native-vision-cameraの利用もおすすめです

Native Moduleでカメラを起動させてみる

Nativeファイルの作成と設定

  1. React Native のプロジェクト内にある .xcworkspace ファイルを xcode で開きます

  2. 開いた xcode の PROJECT 直下に下記4ファイルを作成します
    a. {project name}-Bridging-Header.h
    b. CameraViewManager.m
    c. CameraViewManager.swift
    d. CameraView.swift

  3. PROJECTの build settings で swift compiler を {project name}-Bridging-Header.h に変更します
    a. これを変更しないと {project name}-Bridging-Header.h で import したライブラリを利用できません

  4. React Native の ios ディレクトリ内にある info.plist に下記を追加します

    <key>NSCameraUsageDescription</key>
    <string>カメラへのアクセスを許可してください。</string>
    

作成したファイルの説明

{project name}-Bridging-Header.h

このファイルでは objective-c で記述されたネイティブのライブラリを swift で利用できるようにするために import を記述します

#import "AppDelegate.h"
#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>

#import <Foundation/Foundation.h>
#import <React/RCTUIManager.h>
#import <React/RCTEventEmitter.h>

#import <React/RCTBridge.h>
#import <React/RCTRootView.h>

#ifndef {project name}-_Bridging_Header_h
#define {project name}-_Bridging_Header_h

#endif

※ swift で記述したコードを objective-c にブリッジするために必要なファイルです

CameraViewManager.m

このファイルでは swift で記述した内容を React Native で実行できるようにするための記述を記述します

#import <Foundation/Foundation.h>

#import <React/RCTViewManager.h>
#import <React/RCTUtils.h>

// React NativeでCameraViewManagerをモジュールとして利用できるようにする
@interface RCT_EXTERN_REMAP_MODULE(CameraView, CameraViewManager, RCTViewManager)

// React NativeからNativeに渡したいデータや関数
RCT_EXPORT_VIEW_PROPERTY(onBack, RCTDirectEventBlock);

// Naiveで記述した関数をReact Nativeで利用できるようにする
RCT_EXTERN_METHOD(requestCameraPermission:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject);                                       

@end

CameraViewManager.swift

このファイルでは React Native でコンポーネントを扱うための記述や、呼び出したい関数を記述します

import AVFoundation
import Foundation

@objc(CameraViewManager) // Objective-C側でこのクラスを読み込めるようにする
final class CameraViewManager: RCTViewManager { // UI Componentを利用したい場合はRCTViewManagerを継承する

  override var methodQueue: DispatchQueue! {
    return DispatchQueue.main
  }

  override static func requiresMainQueueSetup() -> Bool {
    return true
  }

  override final func view() -> UIView! {
    return CameraView() // React Nativeでコンポーネントとして扱いたいViewを返す
  }
  
  @objc // Objective-C側で読み込めるようにする
  final func requestCameraPermission(_ resolve: @escaping RCTPromiseResolveBlock, reject _: @escaping RCTPromiseRejectBlock) {
        // カメラの権限を要求する
    AVCaptureDevice.requestAccess(for: .video) { granted in
      let result: AVAuthorizationStatus = granted ? .authorized : .denied
      resolve(result.descriptor)
    }
  }
}

※ UI Componentを利用するために必要なファイルです

CameraView.swift

このファイルではコンポーネントの見た目の部分やボタン押下時の挙動を記述していきます

import AVFoundation
import Foundation
import UIKit
import React

public final class CameraView: UIView {
  @objc var onBack: RCTDirectEventBlock?

  internal let captureSession = AVCaptureSession()
  internal let videoPreviewLayer = AVCaptureVideoPreviewLayer()
  
  internal var myDevice: AVCaptureDevice!
  internal let devices = AVCaptureDevice.devices()
  internal let screenWidh: CGFloat = UIScreen.main.bounds.size.width
  internal let screenHeight: CGFloat = UIScreen.main.bounds.size.height
  internal var flashMode: AVCaptureDevice.FlashMode = AVCaptureDevice.FlashMode.off
    
  internal let wrapperView: UIView = UIView()
  internal let cameraScreenView: UIView = UIView()
  internal let closeButton: UIButton = UIButton()
  internal let takeButton: UIButton = UIButton()
  internal let nextButton: UIButton = UIButton()

  override public init(frame: CGRect) {
    super.init(frame: frame)
    // Viewのレイアウトをセット
    setLayout()
    // videoPreviewLayerにカメラの映像を反映する
    configureCaptureSession()
  }

  @available(*, unavailable)
  required init?(coder _: NSCoder) {
    fatalError("init(coder:) is not implemented.")
  }
  
  // 背面カメラから写っている情報を取得してcaptureSessionに追加する
  private func configureCaptureSession() {
    #if targetEnvironment(simulator)
      invokeOnError(.device(.notAvailableOnSimulator))
      return
    #endif

    captureSession.beginConfiguration()
    defer {
      captureSession.commitConfiguration()
    }

    do {
      for device in devices {
        if(device.position == "back"){
          myDevice = device
          break
        }
      }
      
      guard myDevice != nil else {
        // error処理
        return
      }
      
      deviceInput = try AVCaptureDeviceInput(device: myDevice)
      guard captureSession.canAddInput(deviceInput!) else {
        // error処理
        return
      }
      captureSession.addInput(deviceInput!)
    } catch {
      invokeOnError(.device(.invalid))
      return
    }

    // 写真撮影に利用する(本記事では対象外)
    photoOutput = AVCapturePhotoOutput()

    guard captureSession.canAddOutput(photoOutput!) else {
      // error処理
      return
    }
    captureSession.addOutput(photoOutput!)
        captureSession.startRunning()
  }
 
  // 本ClassのViewに対して子Viewを追加してレイアウトを整える
  private func setLayout() {
    wrapperView.frame = CGRect(x: 0, y: 50, width: screenWidh, height: screenHeight - 50)
    cameraScreenView.frame = CGRect(x: 0, y: 100, width: screenWidh, height: screenWidh * 1.33333333)
    
    // カメラから取得した情報を表示するようにする
    videoPreviewLayer.session = captureSession
    videoPreviewLayer.frame = layer.bounds
    videoPreviewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
    
    cameraScreenView.layer.addSublayer(videoPreviewLayer)
    
    setButtons()
    wrapperView.addSubview(cameraScreenView)
    
    addSubview(wrapperView)

  }
  
  // ボタンのレイアウト
  private func setButtons() {    
    nextButton.frame = CGRect(x: screenWidh - 130, y: screenHeight - 200, width: 100, height: 50)
    nextButton.setTitle("次へ", for: .normal)
    nextButton.backgroundColor = .red
    nextButton.setTitleColor(.white, for: .normal)
    nextButton.layer.cornerRadius = 10
    nextButton.addTarget(self, action: #selector(self.goNext(_:)), for: UIControl.Event.touchUpInside)
    
    closeButton.frame = CGRect(x: 0, y: 10, width: 50, height: 50)
    closeButton.setTitle("閉じる", for: .normal)
    closeButton.setTitleColor(.white, for: .normal)
    closeButton.addTarget(self, action: #selector(self.goBack(_:)), for: UIControl.Event.touchUpInside

    takeButton.frame = CGRect(x: 0, y: screenHeight - 200, width: 100, height: 100)
        takeButton.setTitle("撮影", for: .normal)
    takeButton.setTitleColor(.white, for: .normal)
    takeButton.center.x = wrapperView.center.x
    
    wrapperView.backgroundColor = UIColor.black
    wrapperView.addSubview(takeButton)
    wrapperView.addSubview(nextButton)
    wrapperView.addSubview(closeButton)
    
    addSubview(wrapperView)
  }
  
  // 閉じるボタンタップ時のイベント
  @objc
  func goBack(_ sender: UIButton){
    guard let onBack = onBack else {
      return
    }
    onBack(nil)
  }

  // 次へボタンタップ時のイベント
  // 次節で説明します  
  @objc
  func goNext(_ sender: UIButton){
    CameraEvent.shared?.onCameraEvent()
  }
}

※ ここでは詳しいカメラに関する説明はしませんので詳しい情報はアップルの公式ドキュメントを閲覧くださいhttps://developer.apple.com/documentation/avfoundation/cameras_and_media_capture

NativeからReact Nativeにイベントを送れるようにする

ここでは UI Component 内のイベントにより別のクラスのイベントを発火させる方法を説明します

CameraView.swift

  // 次へボタンタップ時のイベント
  @objc
  func goNext(_ sender: UIButton){
    CameraEvent.shared?.onCameraEvent()
  }

「次へ」ボタンをタップして goNext 関数の CameraEvent の onCameraEvent が実行されるようにするために2ファイルを追加で作成します

CameraEvent.m

このファイルでは CameraEvent で送られたイベントを React Native で受け取れるようにします

#import <React/RCTEventEmitter.h>

// React NativeでCameraEventをモジュールとして利用できるようにする
@interface RCT_EXTERN_MODULE(CameraEvent, NSObject)
@end

CameraEvent.swift

このファイルで実際に発生させたいイベントを記述します

import Foundation

@objc(CameraEvent)// Objective-C側でこのクラスを読み込めるようにする
class CameraEvent: RCTEventEmitter { // イベントを追加したい場合はRCTEventEmitterを継承する
  // 他のクラスからでもイベントを発火できるようにする
  public static var shared: CameraEvent?
  
  override init() {
    super.init()
    CameraEvent.shared = self
  }
  
  @objc
  override func supportedEvents() -> [String]! {
    // 追加したいイベントを配列で列挙する
    return ["onNext"]
  }
  
  override static func requiresMainQueueSetup() -> Bool {
    return true
  }
  
  // イベントを発火させるための関数
  func onCameraEvent() {
    sendEvent(withName: "onNext", body: "イベントを受け取ったときに受け取りたい値を渡す")
  }
}

※ イベントは複数作成が可能

React Nativeのソースコード

NativeCamera.tsx

requireNativeComponent を使って Native で作成したコンポーネントを呼び出すことができます

import React, { FC } from 'react'
import { requireNativeComponent } from 'react-native'
import { ParamListBase, useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'

type CameraViewType = {
  style: {}
  onBack: () => void
}

// requireNativeComponentでNativeでNativeのコンポーネントを呼び出します
// CameraViewManagerですがManagerを除いた文字列で指定します
const CameraView = requireNativeComponent<CameraViewType>('CameraView')

type CameraProps = {}

export const NativeCamera: FC<CameraProps> = () => {
  const navigation = useNavigation<StackNavigationProp<ParamListBase>>()

  return (
    <CameraView
      onBack={() => navigation.popToTop()} // onBackにはnavigationのtopに遷移する関数を指定
      style={{ width: '100%', height: '100%' }} // widthとheightを指定しないとうまく表示されませんでした
    />
  )
}

※ デバッグ時に同一コンポーネント名のエラーが発生するため、呼び出したコンポーネントのみで構成されたファイルに分割することをお勧めします

CameraScreen.tsx

NativeModules を使って Native で作成した関数やイベントを受け取ることができます

import React, { FC, useState } from 'react'
import {
  View,
  Text,
  NativeModules,
  NativeEventEmitter
} from 'react-native'

type CameraScreenProps = {}
type CameraPermissionRequestResult = 'authorized' | 'denied'

// NativeModulesからCameraViewManagerのModuleを呼び出せるようにする
// CameraViewManagerからManagerは除く
const CameraModule = NativeModules.CameraView

export const CameraScreen: FC<PhotoCameraProps> = () => {
  const [hasPermission, setHasPermission] = useState<boolean>()

  useEffect(() => {
    const requestPermission = async () => {
      try {
        return await CameraModule.requestCameraPermission()
      } catch (e) {
        console.log(e)
      }
    }
    if (Platform.OS === 'ios') {
      requestPermission().then((status: CameraPermissionRequestResult) => {
        setHasPermission(status === 'authorized')
      })
    }
  }, [])

  if (Platform.OS === 'ios') {
    // NativeEventEmitterを使ってNativeModulesのCameraEventのイベントを受け取れるようにする
    const CameraEvent = new NativeEventEmitter(NativeModules.CameraEvent)
    // Native側でonNextイベントが発火されたら実行される
    // 撮影した写真のPathとかを送ったりできる
    CameraEvent.addListener(
      'onNext',
      (evt: any) => console.log(evt)
  }

  if (!hasPermission) {
    return (<View><Text>カメラの権限が与えらていません</Text></View>)
  }

  return (
    <View>
      <NativeCamera />
    </View>
  )
}

最後に

React Native ではたくさんの node_modules がありますがその多くが Native で記述されています

しかし各々のプロジェクトに適したライブラリが必ずしもあるわけではありません

今回 KANNA もカメラ機能を拡張して複数のことを行えるようにしなければなりませんでした

しかし node_modules のライブラリではその要件を満たせそうになく Native 化するという方針至りました

本記事は iOS の Native Module を解説しましたが、Android の Native Module の記事も公開予定ですので是非そちらもよろしくお願いします

採用情報

アルダグラムでは、建設業界を一緒に変えていきたいエンジニアを積極採用中です!