🥺

ReactNativeでNative Module, Native Componentを作る(iOS, Swift)

2022/08/08に公開

はじめに

ReactNativeでアプリを作っていると既存のライブラリを使えば基本的に自分でネイティブ側のコードを書かなくても大丈夫です。しかし既存のライブラリで欲しい機能などが満たせない場合、自分で実装する必要があります。
今回は簡単なカメラ機能をSwiftで実装しながらiOSのネイティブ側の作り方を見ていきます。
カメラはシミュレータだと動かないので実機が必要です。

達成したいこと

  • ネイティブで書いたカメラのプレビューをRN側で表示。
  • 撮影用ボタンはRN側で作成して表示。
  • ネイティブで書いた写真の撮影用メソッドをRN側で実行。写真の撮影に成功させる。
  • 撮影した写真をデバイスのライブラリに保存。
  • 撮影時のフラッシュのオンオフをPropsから指定できるようにする。

プロジェクトのセットアップ

npx react-native init RNSwiftCamera
cd RNSwiftCamera
pod install
yarn ios

ネイティブ側の作成

今回ネイティブ側では

CameraView.swift
CameraManager.swift
CameraManager.m(objective-c)
<プロジェクト名>-Bridging-Header

の4つのファイルを新たに作成します。
Swiftで書きたいと思っても、objcの実行ファイルが必要です。

CameraView

実際のviewを書いていきます。

CameraManager

JS側で使用するメソッドの定義などをします。
viewメソッドをオーバーライドしてCameraViewを返します。

CameraManager.m

実際にRNでモジュールを使用できるように定義します。
RNは本来objcを動かすのでSwiftで書いたコードのみでは使用することができません。

<プロジェクト名>-Bridging-Header

ヘッダファイルです。RCTViewManagerなどのクラスをSwiftでも使用できるようにします。

CameraViewとCamraManagerは1つのファイルで作ることも可能ですが、分けられることが多いようです。
react-native-vision-cameraのファイルを見てみると、CameraViewとCameraViewManagerが存在します。
https://github.com/mrousavy/react-native-vision-camera
自アプリとライブラリという違いはありますが、今回は2つのファイルを使い作成します。

CameraView.swift

ファイルを作成します。ファイルは AppDelegate.m がある階層と同じ場所に作成してください。

この時に、Bridging Header File を作成するかどうか聞かれるので作成してください。

まずカメラのプレビュー画面を表示させることを目指します。Swiftでコードをざっと書いていきます。

import Foundation
import UIKit

class CameraView: UIView {
  override public init(frame: CGRect) {
    super.init(frame: frame)
  }
  
  required init?(coder _: NSCoder) {
    fatalError("init(coder:) is not implemented.")
  }
}

UIKitのインポートとinitのセットアップを行いました。これはUIKitを継承した場合必要になるので一旦このまま書いてしまいましょう。

次にカメラプレビューを表示するための実装をします。

import Foundation
import UIKit
import AVFoundation

class CameraView: UIView {
  // デバイスからの入力と出力を管理
  var captureSession = AVCaptureSession()
  
  // デバイス管理
  var currentDevice: AVCaptureDevice?
  var mainCamera: AVCaptureDevice?
  
  // Sessionの出力を指定するためのオブジェクト
  var photoOutput: AVCapturePhotoOutput?
  
  // カメラのプレビュー管理
  var cameraPreviewLayer: AVCaptureVideoPreviewLayer?
  
  override public init(frame: CGRect) {
    super.init(frame: frame)
    setupCaptureSession()
    setupDevice()
    setupInputOutput()
    setupPreviewLayer()
    captureSession.startRunning()
  }
  
  required init?(coder _: NSCoder) {
    fatalError("init(coder:) is not implemented.")
  }
  
  // カメラのプレビューを表示するレイヤの設定
  func setupPreviewLayer() {
    // frameをネイティブ側で指定しないとプレビュー画面表示されない(?)
    self.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height)
    
    cameraPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
    cameraPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
    cameraPreviewLayer?.connection?.videoOrientation = AVCaptureVideoOrientation.portrait
    cameraPreviewLayer?.frame = layer.bounds
    
    self.layer.insertSublayer(cameraPreviewLayer!, at: 0)
  }
  
  // 入出力データの設定
  func setupInputOutput() {
    do {
      // CaptureDeviceからCaptureSessionに向けてデータを提供するInputを初期化
      let caputureDeviceInput = try AVCaptureDeviceInput(device: currentDevice!)
      
      // 入力をセッションに追加
      captureSession.addInput(caputureDeviceInput)
      
      // 出力データを受け取るオブジェクト
      photoOutput = AVCapturePhotoOutput()
      
      let outPutPhotoSettings = [AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])]
      
      // 出力の設定。ここではフォーマットを指定。
      photoOutput!.setPreparedPhotoSettingsArray(outPutPhotoSettings, completionHandler: nil)
      
      captureSession.addOutput(photoOutput!)
    } catch {
      print(error)
    }
  }
  
  // デバイスの設定
  func setupDevice() {
    // カメラデバイスのプロパティ設定。どの種類のデバイスをどのメディアのために使うかなど設定。
    let deviceDiscoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [AVCaptureDevice.DeviceType.builtInWideAngleCamera], mediaType: AVMediaType.video, position: AVCaptureDevice.Position.unspecified)
    
    // discoverySessionで指定した基準に満たしたデバイスを取得
    let devices = deviceDiscoverySession.devices
    
    for device in devices {
      if device.position == AVCaptureDevice.Position.back {
        mainCamera = device
      }
    }
    
    currentDevice = mainCamera
  }
  
  func setupCaptureSession() {
    // 解像度設定
    captureSession.sessionPreset = AVCaptureSession.Preset.photo
  }
}

ここで謝らなければならないことが2点あります。
1点目はプレビューを表示するだけにしては少々余分に設定をおこなってるかもしれません。
2点目は setupPreviewLayer の
self.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height)
の部分です。
ReactNativeはframeを指定することを推奨していません。

Do not attempt to set the frame or backgroundColor properties on the UIView instance that you expose through the -view method. React Native will overwrite the values set by your custom class in order to match your JavaScript component's layout props.

しかし、自分の実装だとframeを指定しないとJS側でstyleを指定してもプレビュー画面が表示されませんでした。なので今回はネイティブ側でframeを指定してしまいます。ここでは縦横いっぱいに広がるよう設定しています。

権限の設定

カメラ、アルバムの使用には権限が必要なので設定します。
infoタブから
Privacy - Photo Library Usage Description
Privacy - Camera Usage Description
をそれぞれ追加してください。

CameraManager.swift

実際にNative Componentとして登録するクラスを書いていきます。
まずはファイルを作成し、次のコードを書いてください。

import Foundation
import UIKit

// CameraManagerをobjcで使用できるようにする
@objc(CameraManager)
class CameraManager: RCTViewManager {
  
}

@objc(CameraManager)でCameraManagerをobjcで使えるようにしています。

この時点でRCTViewManagerの部分に以下のようなエラーが出てると思います。
Cannot find type 'RCTViewManager' in scope
これはヘッダファイルで定義をインポートすることで解決します。
RCTViewManagerをSwiftで使用できるようになります。

// 先ほど作成した<プロジェクト名>-Bridging-Header
#import <React/RCTViewManager.h>

CameraManager.swiftに戻り、view()をオーバーライドします。
このviewメソッドで返されるUIViewが実際に表示されます。
ここでは先ほど作成したCameraViewを返します。

import Foundation
import UIKit

@objc(CameraManager)
class CameraManager: RCTViewManager {
  override func view() -> UIView! {
    return CameraView()
  }
  
  override static func requiresMainQueueSetup() -> Bool {
    return true
  }
}

requiresMainQueueSetupもオーバーライドしています。
これはこのモジュールの初期化をメインスレッドで行うのか、バックグラウンドスレッドで行うのかを決めるものです。
ネイティブではViewにまつわる部分はメインスレッドで行うのと同様に、UIKitが含まれるモジュールではtrueを返してメインスレッドで処理した方がいいでしょう。
なお、メインスレッドで処理させない場合でもfalseを返さないと警告が出されます。

requiresMainQueueSetup to let React Native know if your module needs to be initialized on the main thread, before any JavaScript code executes. Otherwise you will see a warning that in the future your module may be initialized on a background thread unless you explicitly opt out with + requiresMainQueueSetup:. If your module does not require access to UIKit, then you should respond to + requiresMainQueueSetup with NO.

先ほどframeについて書きましたが、frameを指定する場合は他のUIViewでラップした方がいいとも書かれています。しかし今回は特に行わず進めます。
https://reactnative.dev/docs/native-components-ios.html#ios-mapview-example

CameraManager.m

objcの実行ファイルを作成します。ここでの定義によって初めてRNがネイティブコンポーネントとして登録してくれます。
まずはObjective-cファイルを作成してください。

フォルダはこんな感じ↑になっていると思います。

コードを書いていきます。

#import <Foundation/Foundation.h>
#import <React/RCTViewManager.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(CameraManager, RCTViewManager)
@end

RCT_EXTERN_MODULEを使いJS側からCameraManagerを使えるようにしています。
第二引数には型を渡しています。

JS側の作成

一旦ここでコンポーネントの作成などJS側の作成に移ります。
今回はTSは使いません。

// App.js

import React from 'react';
import {requireNativeComponent, StyleSheet, View} from 'react-native';

const NativeCamera = requireNativeComponent('Camera');

const App = () => {
  return (
    <View style={styles.container}>
      <NativeCamera style={styles.camera} />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  camera: {
    flex: 1,
  },
});

export default App;

requireNativeComponentを使用することで登録したNative Componentを取得することができます。
先ほどRCT_EXTERN_MODULEにCameraManagerをモジュール登録しました。"Manager"はRN側で自動で外されるので、'Camera'でモジュールにアクセスできます。

ここまででカメラのプレビュー画面が表示されていると思います。次は撮影用のメソッドを作成し、それをネイティブモジュールとしてJS側で使用できるようにし、撮影を成功させます。

撮影用メソッドの作成

まずCameraView.swiftに戻ってください。
captureという関数を定義します。

  func capture() {
    let settings = AVCapturePhotoSettings()
    settings.flashMode = .off // 後で可変にする
    self.photoOutput?.capturePhoto(with: settings, delegate: self as  AVCapturePhotoCaptureDelegate)
  }
  
  // カメラのプレビューを表示するレイヤの設定
  func setupPreviewLayer() {
  ...

settings.flashModeの部分は後でコンポーネントに渡すプロップスから変更できるようにします。

写真を撮影した後の処理を書くためDelegateを追加しましょう。CameraViewの外側に記述してください。

class CameraView {
...
}

extension CameraView: AVCapturePhotoCaptureDelegate {
  func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
    if let imageData = photo.fileDataRepresentation() {
      let uiImage = UIImage(data: imageData)
      // ライブラリに画像を保存
      UIImageWriteToSavedPhotosAlbum(uiImage!, nil, nil, nil)
    }
  }
}

このままではcaptureを実行することはできません。JS側で実行できるようにしていきます。
CamraManager.swiftに登録しましょう。

// CameraManager.swift

import Foundation
import UIKit

@objc(CameraManager)
class CameraManager: RCTViewManager {
  let cameraView = CameraView() // 初期化
  
  override func view() -> UIView! {
    return cameraView // 初期化したインスタンス返す
  }
  
  override static func requiresMainQueueSetup() -> Bool {
    return true
  }
  
  @objc
  func capture() {
    cameraView.capture() // 撮影
  }
}

@objcキーワードをつけたcaptureを定義しました。
@objcをつけることでobjcで使用できるようにしています。
プロップスやネイティブモジュールから使用したいメソッドなどJS側で使いたいデータに対しては基本的に全て@objcをつける必要があります。

JS側からはこのcaptureを実行することになります。

このままだとまだ使用できません。CameraManager.mでマクロを使いRNにメソッドを登録します。

// CameraManager.m

#import <Foundation/Foundation.h>
#import <React/RCTViewManager.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(CameraManager, RCTViewManager)
RCT_EXTERN_METHOD(capture) // 追加
@end

RCT_EXTERN_METHODに@objcがついたメソッドを渡してあげることで、ネイティブモジュールの中でこのメソッドが使えるようになります。
まだcaptureに渡す引数はないので、captureのみをRCT_EXTERN_METHODに渡してあげればいいです。
これでJS側からcaptureを実行できます。

撮影用ボタンの作成

撮影するためのボタンコンポーネントを作成しましょう。
App.jsを開いてください。

// App.js

import React from 'react';
import {
  requireNativeComponent,
  StyleSheet,
  TouchableOpacity,
  View,
} from 'react-native';

const NativeCamera = requireNativeComponent('Camera');

const App = () => {
  return (
    <View style={styles.container}>
      <NativeCamera style={styles.camera} />
      {/* 撮影用ボタン */}
      <TouchableOpacity style={styles.button} />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  camera: {
    flex: 1,
  },
  button: {
    position: 'absolute',
    bottom: 60,
    width: 70,
    height: 70,
    borderRadius: 70,
    backgroundColor: 'white',
    alignSelf: 'center',
  },
});

export default App;

ネイティブモジュールを使う

ネイティブモジュールを使用し、先ほど定義したcaptureをJS側で使用しましょう。
NativeModulesを使用することでネイティブモジュールを使うことができます。

// App.js

import {
  NativeModules,
  ...
} from 'react-native';

const CameraModule = NativeModules.CameraManager;

...

ネイティブコンポーネントを初期化する時はManagerが取り除かれたので'Camera'としてましたが、今回はNativeModules.Camaraとしないように気をつけましょう。

onPressのタイミングで実行させます。

import React from 'react';
import {
  NativeModules,
  requireNativeComponent,
  StyleSheet,
  TouchableOpacity,
  View,
} from 'react-native';

const NativeCamera = requireNativeComponent('Camera');
const CameraModule = NativeModules.CameraManager;

const App = () => {
  // コールバック作成
  const onPress = async () => {
    await CameraModule.capture();
  };

  return (
    <View style={styles.container}>
      <NativeCamera style={styles.camera} />
      <TouchableOpacity style={styles.button} onPress={onPress} /> // onPress登録
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  camera: {
    flex: 1,
  },
  button: {
    position: 'absolute',
    bottom: 60,
    width: 70,
    height: 70,
    borderRadius: 70,
    backgroundColor: 'white',
    alignSelf: 'center',
  },
});

export default App;

TurboModuleに対応してないネイティブモジュールは全て非同期の実行になります。

ここまでで、ボタンを押したら撮影ができ、その写真がアルバムに保存されているはずです。

複数のネイティブコンポーネントに対応する

https://reactnative.dev/docs/native-components-ios#handling-multiple-native-views

この公式のDocsを読んでみてください。今のやり方だと複数のネイティブコンポーネントをレンダリングする時に困ることがわかります。

雑にまとめると、複数コンポーネントがレンダリングされている場合、UIManagerはどのインスタンスのメソッドを実行すればいいかわからないので、明示的に特定のインスタンスのメソッドを実行させる必要がある、ということです。

viewにはreact tagという一意のidのようなものが割り当てられているのでそれを使用します。

react tagはref.current._nativeTagで取得することができます。

まずネイティブ側から修正します。CameraManager.swiftを開いてください。
CameraManagerの中に以下のメソッドを定義します。

// CameraManager.swift

class CameraManager: RCTViewManager {
  ...
  func getCameraView(tag: NSNumber) -> CameraView {
    return bridge.uiManager.view(forReactTag: tag) as! CameraView
  }
}

bridge.uiManager.viewにタグを渡すことで該当のUIViewを返します。
CameraManagerからはCameraViewがview()で返されており、そのコンポーネントからタグを取得予定なのでここではCameraViewでキャストします。

エラーが出るので、ヘッダファイルに以下のように追加しましょう。

#import <React/RCTViewManager.h>
#import <React/RCTUIManager.h> // 追加

次にcaptureをタグにより取得したインスタンスで実行するようにします。
先ほど定義したcaptureをこのように修正します。

// CameraManager.swift

@objc
  func capture(_ node: NSNumber) {
    let component = getCameraView(tag: node)
    component.capture()
  }

getCameraViewが返す型はCameraViewでキャストされているので、component.capture()を実行することができます。

captureに渡す引数が変わりました。RCT_EXTERN_METHOD(capture)もそれに合わせて修正する必要があります。

#import <Foundation/Foundation.h>
#import <React/RCTViewManager.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(CameraManager, RCTViewManager)
RCT_EXTERN_METHOD(capture:(nonnull NSNumber *)node) // 変更
@end

App.jsに書いているawait CameraModule.capture();に対してtagを渡してあげる必要があります。
先ほど、「react tagはref.current._nativeTagで取得することができます。」と書きましたがreact-nativeからexportされているfindNodeHandleを使いタグを取得しましょう。

https://github.com/facebook/react-native/blob/main/Libraries/Renderer/implementations/ReactFabric-prod.js#L8219

App.jsを以下のように修正してください。

import React, {useRef} from 'react';
import {
  findNodeHandle,
  NativeModules,
  requireNativeComponent,
  StyleSheet,
  TouchableOpacity,
  View,
} from 'react-native';

const NativeCamera = requireNativeComponent('Camera');
const CameraModule = NativeModules.CameraManager;

const App = () => {
  const cameraRef = useRef(); // 追加

  const onPress = async () => {
    const node = findNodeHandle(cameraRef.current); // タグ取得
    await CameraModule.capture(node); // 引数渡す
  };

  return (
    <View style={styles.container}>
      {/* ref渡す */}
      <NativeCamera style={styles.camera} ref={cameraRef} />
      <TouchableOpacity style={styles.button} onPress={onPress} />
    </View>
  );
};

// 以下省略

再度Xcodeで実機にビルドして確認してみましょう。写真が撮影されるはずです。

ネイティブコンポーネントにpropsを渡す

作成したコンポーネントにflashというpropsを渡し、そのtrueの場合フラッシュをたき、そうでない場合フラッシュをたかないようにします。

まずCameraManager.mを開いてください。以下のようにコードを追加します。

#import <Foundation/Foundation.h>
#import <React/RCTViewManager.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(CameraManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(flash, BOOL) // 追加

RCT_EXTERN_METHOD(capture:(nonnull NSNumber *)node)
@end

RCT_EXPORT_VIEW_PROPERTYに受け取りたいpropsの名前と型を定義してあげることでJS側からpropsとして渡せるようになります。

CameraView.swiftで受け取ったflashによって設定を変えるようにしましょう。

class CameraView: UIView {
...

@objc var flash = false // 追加

...

func capture() {
    let settings = AVCapturePhotoSettings()
    settings.flashMode = flash ? .on : .off // 変更
    self.photoOutput?.capturePhoto(with: settings, delegate: self as AVCapturePhotoCaptureDelegate)
  }
}

これでネイティブ側の処理は終了です。

JS側からpropsを渡してみましょう。

// App.js

// flash追加
<NativeCamera style={styles.camera} ref={cameraRef} flash />

再度ビルドして撮影してみてください。フラッシュがたかれると思います。

実装は以上です。

ソースコードは以下です。

https://github.com/riku99/RNSwiftCamera

終わりに

Swiftでネイティブコンポーネント、ネイティブモジュールを書きました。
今回イベントの扱いなどはしませんでしたが、触ってみたところ難しいものではなかったので是非公式など読みながらやってみてください。
ReactNativeの世界ではiOSのライブラリなどはobjective-cで書かれているものが多く、記事もそこまで多いわけではないです。その中でもreact-native-vision-cameraはSwiftで書かれており、非常にためになりましたので是非読んでみることをお勧めします。
動かない箇所、間違っている箇所などありましたらコメントいただけると嬉しいです。

Discussion