React NativeのNative Moduleでカメラ起動させてみた(iOS/Swift)
こんにちは
株式会社アルダグラムの渡辺です
今回は React Native に自作の Native Module を使ってカメラの起動とボタンの配置・イベント発火までをコードベースで解説できればと思います
背景
アルダグラムで提供しているサービス「KANNA」の App は React Native で開発を行なっております。
この KANNA にカメラを使った機能を追加することになりました
はじめは React Native だけでカメラ機能を開発したかったのですが、KANNA で実現したいことはNative を組み込まないとできないと判断したため Native Module 化をすることになりました
※ カメラ機能を利用するだけならreact-native-vision-cameraの利用もおすすめです
Native Moduleでカメラを起動させてみる
Nativeファイルの作成と設定
-
React Native のプロジェクト内にある .xcworkspace ファイルを xcode で開きます
-
開いた xcode の PROJECT 直下に下記4ファイルを作成します
a. {project name}-Bridging-Header.h
b. CameraViewManager.m
c. CameraViewManager.swift
d. CameraView.swift -
PROJECTの build settings で swift compiler を {project name}-Bridging-Header.h に変更します
a. これを変更しないと {project name}-Bridging-Header.h で import したライブラリを利用できません
-
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 の記事も公開予定ですので是非そちらもよろしくお願いします
株式会社アルダグラムのTech Blogです。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら: herp.careers/v1/aldagram0508/
Discussion