Neoself의 기술 블로그

React Native 네이티브 모듈 연결하기 - 파노라마 뷰(iOS) 본문

개발지식 정리/React Native

React Native 네이티브 모듈 연결하기 - 파노라마 뷰(iOS)

Neoself 2024. 6. 20. 17:51

본 게시글은 iOS에서의 Native 모듈 연결과정을 담고 있으며, 사용할 예시로 이전에 구현하였던 파노라마뷰 모듈을 사용할 것이다.

 

네이티브 UI뷰 파일이 React Native에 렌더되기까지 일반적으로 필요한 파일 구성은 다음과 같이 도식화 해볼 수 있다.

(좌) Objective-C로 구현된 네이티브 모듈 연결 도식, (우) Swift로 구현된 네이티브 모듈 연결 도식

 

우선 네이티브 뷰 자체를 구현하는 UI View 파일을 ViewManager 파일로 감싸 React Native에서 읽을 수 있는 인스턴스 형태로 변환시켜준다. 일반적으로 2개 성격의 파일이 조합되어 네이티브 모듈이 구성되지만, View Manager 파일에서 구현되는 인스턴스 생성 및 반환 로직을 UI View 파일로 이동해 하나의 파일로 구성하여도 무방하다.

 

마지막으로, Objective C 클래스 파일에서 RCT_EXTERN_MODULE이라는 함수를 호출하여 앞서 생성한 뷰 인스턴스를 React Native에서 사용할 수 있도록 노출한다. 만약 모듈이 React Native로부터 인자를 전달받아야 할 경우, 해당 클래스에서 RCT_EXPORT_VIEW_PROPERTY 함수로 전달받아야하는 인자를 명시해주면 된다.

 

위 환경 구축이 완료된다면, JavaScript 단에서 requireNativeComponent 함수를 통해 네이티브 모듈을 불러올 수 있게 된다. 좌측의 도식을 보면, 기본적으로 Objectice-C 언어로 네이티브 모듈이 구현되어야하는 것을 확인할 수 있는데, 만약 Swift 언어로 네이티브 모듈을 구현하고 싶을 경우, 우측의 도식처럼 Bridging-Header 파일이 추가되어야 한다. 자세한 설명은 밑에서 다루겠다.

 

앞서 언급한 파노라마 모듈 구현 절차를 위 도식과 같이 파일명과 함께 설명하자면 다음과 같이 이해해볼 수 있다

첫 도식과 다른 점이 있다면, 네이티브 뷰 파일을 구현하기 위해 CTPanoramaView라는 iOS SDK 파일을 로컬로 추가하여 이를 PanoramaView.swift 파일에서 사용하였다는 점이다.

https://github.com/scihant/CTPanoramaView

 

GitHub - scihant/CTPanoramaView: A library that displays spherical or cylindrical panoramas with touch or motion based controls.

A library that displays spherical or cylindrical panoramas with touch or motion based controls. - scihant/CTPanoramaView

github.com

 

 

1. 파노라마뷰 구현을 보조하는 iOS SDK 활용하기

CTPanoramaView SDK는 터치와 모바일 기기의 기울기 값을 통해 파노라마 이미지를 볼 수 있는 뷰 클래스가 구현되어있다. 1000개 이상의 깃허브 스타를 보유하고 있는 만큼, 인지도가 굉장히 높은 SDK였으며, 열려있는 이슈들이 많지 않았기에 큰 고민없이 해당 SDK를 선택하였다.

깃허브 레포 기준 Source 폴더 내부에 있는 CTPanoramaView.swift 파일을 그대로 {프로젝트 루트}/ios 폴더 내부로 이동시킨 후, XCode에 들어가 Add Files to 프로젝트명 을 실행하여 Xcode 내부에서도 해당 파일을 참조할 수 있도록 연결해준다. 이때, 최상단 프로젝트 디렉토리가(파란색)가 아닌, 그룹 프로젝트 디렉토리(회색)에서 우클릭하여 파일을 추가하도록 하자. 이는 앞으로 파일을 추가하거나 생성할때에도 동일하다.

매번 할때마다 느끼지만 Xcode에 파일을 연결해주는 절차는 굉장히 귀찮다. 마치 위 스크린샷에서 프로젝트명을 가리는 것처럼...

 

 

2. 파노라마뷰 구현을 위한 SwiftUI 파일과 Bridging 헤더 파일 생성하기

파일명에 별다른 제약은 없지만, 추후 React Native 단에서 requireNativeComponent 함수로 호출할때 사용할 명칭으로 설정하는 것이 좋다. 필자의 경우 'PanoramaView'라는 String 값으로 네이티브 모듈을 연결할 계획이라 PanoramaView로 파일명을 정하였다.

 

프로젝트 그룹 폴더에서 "New File..." 을 클릭, SwiftUI 파일을 클릭하여 파일을 생성해준다. 앞서 설명했듯 PanoramaView로 명칭을 설정하고, 그룹과 Target이 각각 프로젝트 그룹으로 선택되어있는 지 확인한 후, Create를 눌러 파일을 생성해준다. 이때, 기존 프로젝트에서 Swift파일을 생성하지 않았을 경우, Bridging-header 파일을 같이 생성할지 묻는 팝업창이 뜰 것이다. 이때 Create Bridging Header를 클릭하자.

 

해당 파일은 React Native가 Swift 파일을 읽을 수 있게 하는 역할도 있지만, React에서 제공하는 Objective-C 모듈을 Swift 파일에서 사용할 수 있도록 전달하는, 말 그대로 Swift와 Objective-C 언어 간의 다리 역할을 해준다. 해당 파일 내부에 Swift파일에서 필요로 하는 모듈들을 나열해 import 해주면 된다.

//  Objective-C, Swift 간의 쌍방향 교류를 담당하는 React 모듈
#import "React/RCTBridgeModule.h"
//  RN에서 네이티브 UI 컴포넌트를 생성하고 관리를 담당하는 React 모듈
#import "React/RCTViewManager.h"
//  image 링크를 이미지 파일로 변환해주는 React 모듈
#import "React/RCTImageLoader.h"

 

물론 팝업창을 통해 파일을 생성하지 않아도, 나중에 Header File 유형 파일을 새로 생성하여 Bridging-header 파일을 생성하여도 된다. 하지만 이의 경우 Xcode에서 매번 파일을 추가해 참조를 해주는 것처럼, Xcode의 Build Setting 탭 내부 Objective-C Bridging Header 설정에 추가한 파일명을 그대로 입력해 등록해주는 절차가 필요하다. 설령 프로젝트 이름이 exampleApp이면

exampleApp-Bridging-Header.h

처럼 확장자를 포함해 옵션값을 지정하여 연결시켜주면된다.

오 도형으로 가리니까 훨씬 덜 귀찮은듯

 

Bridging-Header파일 생성 및 사용 모듈 import문 추가가 완료되면, SwiftUI뷰 파일 구현을 이어하도록 하자.

파노라마 이미지 각도 전환과 같은 핵심 매커니즘은 CTPanoramaView에서 구현해주었기에 다음 로직들을 파일 내에 구현해주었다.

  1. 첫 화면 방향, 이미지 유형(원통형 vs 구형), 조작방식(터치 vs 기기 기울기값)과 같은 초기설정값을 설정하고, init 주기에 CTPanoramaView 클래스에 전달
  2. React Native로부터 전달받는 이미지 링크를 미디어파일로 전환
  3. 이미지 전환 실패시 처리 동작

SwiftUI뷰 파일 소스코드

더보기
// PanoramaView.swift
import Foundation
import UIKit

@objc public class PanoramaView: UIView {
  
  @objc public var bridge : RCTBridge? = nil
  @objc public var onImageLoadingFailed: RCTDirectEventBlock? = nil
  @objc public var onImageLoaded: RCTDirectEventBlock? = nil
  
  private var panoramaView : CTPanoramaView? = nil
  private var cancel : RCTImageLoaderCancellationBlock? = nil
  
  // imageUrl로부터 image파일을 로드하고 로드된 파일을 panoramaView 클래스에 전달
  @objc public var imageUrl: String? = nil {
    didSet{
      if(!(imageUrl?.isEmpty ?? true)){
        if(self.bridge == nil){
          self.onImageLoadingFailed?(["error": "Bridge is not ready or not set."])
          return
        }
        // React에서 제공하는 ImageLoader 모듈을 사용
        let loader = self.bridge?.module(forName: "ImageLoader", lazilyLoadIfNecessary: true) as!RCTImageLoader
        self.cancel?()
        self.cancel = nil
        guard let request = RCTConvert.nsurlRequest(imageUrl) else {
          return
        }
        self.cancel = loader.loadImage(with: request, callback: { (error, image) in
          DispatchQueue.main.async {
            if(self.cancel == nil){
              return
            }
            if (error != nil) {
              self.onImageLoadingFailed?(["error": error!.localizedDescription])
            }
            else{
              // 클래스에 로드된 이미지파일 전달
              if(image != nil){
                self.panoramaView?.image = image
                self.onImageLoaded?(nil)
              }
              else{
                self.onImageLoadingFailed?(["error": "Image was empty or failed to load."])
              }
            }
            self.cancel = nil
          }
        })
      }
      else{
        self.cancel?()
        self.cancel = nil
      }
    }
  }
 
  public override init(frame: CGRect) {
    super.init(frame: frame)
    // CTPanoramaView 클래스 생성하여 시작 각도, 파노라마 타입, 조작방식 설정
    let view = CTPanoramaView(frame: frame)
    view.startAngle = 7.45
    view.panoramaType = .spherical
    view.controlMethod = .touch;
    // addSubview 함수 통해 렌더 대상으로 설정
    self.panoramaView = view
    self.addSubview(view)
  }
  
  public required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  public override func layoutSubviews() {
    super.layoutSubviews()
    self.panoramaView?.frame = CGRect(x: 0, y: 0, width: frame.size.width, height: frame.size.height)
    self.panoramaView?.setNeedsDisplay()
  }
}

 

3. PanoramaViewManager.swift 파일 생성하기

이제 PanoramaViewManager 파일을 추가해 구현한 SwiftUI 뷰를 React Native가 읽을 수 있는 인스턴스로 변환해주자.

더보기
import Foundation
import UIKit
//@objc 어노테이션이 있어야, 이를 React Native에서 인식할 수 있다.
@objc(PanoramaViewManager)
class PanoramaViewManager: RCTViewManager {
    
    //  메인 스레드에서 실행되어야하는 지 여부를 반환
    override static func requiresMainQueueSetup() -> Bool {
        return true
    }
    //  React Native에서 사용할 PanoramaView 인스턴스를 생성 및 반환
    override func view() -> UIView! {
        let panoramaView = PanoramaView()
        panoramaView.bridge = self.bridge
        return panoramaView
    }
}

인스턴스으로의 변환 로직 외에도, requiresMainQueueSetup 함수가 있는 것을 확인할 수 있는데, 이는 메인 쓰레드에서 실행되는 지 여부를 정의하는 함수이다. 네이티브 모듈의 경우, Javascript 단의 코드가 메인 쓰레드에서 실행되며 네이티브 모듈 코드는 Secondary thread에서 실행된다. 따라서 requiresMainQueueSetup 함수를 통해, 네이티브 모듈에서 실행되는 액션이 UI 업데이트에 직접적인 영향을 줄 수 있도록 한다.

 

현재까지의 진행상황을 도식화한 이미지이다. Swift UI 뷰 파일이 이제 React Native에서 읽을 수있는 뷰 인스턴스로 변환이 완료되었기에, 이제 해당 인스턴스를 Objective-C파일에서 호출한 다음 React Native로 연결해주는 작업만 진행되면 된다.

 

 

 

 

 

 

 

 

 

 

 

4. PanoramaViewManager.m 파일 생성하기

PanoramaViewManager라는 명칭의 Objective-C 파일을 생성한 후, 앞서 생성한 Objective-C 뷰 인스턴스를 RCT_EXTERN_MODULE 함수를 통해 React Native에서 사용할 수 있도록 노출시킨다.

추가적으로 React Native에서 네이티브 모듈로 특정 인자값을 전달해야할 경우, 네이티브 모듈에서 전달받아야 할 값을 RCT_EXPORT_VIEW_PROPERTY 함수를 통해 명시해주면 된다. 앞서 파노라마 Swift UI 뷰 구현했을 때 image 링크를 미디어 파일로 변환하는 로직을 구현하였었는데, 이때 인풋으로 받아야하는 이미지 링크를 여기서 명시해주면 된다.

더보기
// PanoramaViewManager.m

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

// 앞서 생성한 뷰 인스턴스, 뷰 인스턴스가 상속받는 클래스를 인자로 삽입
@interface RCT_EXTERN_MODULE(PanoramaViewManager, RCTViewManager)
    // React Native로부터 전달받는 인자명, 해당 인자의 타입을 인자로 삽입
    RCT_EXPORT_VIEW_PROPERTY(imageUrl, NSString);
@end

 

 

 

5. 네이티브 모듈을 React Native 컴포넌트로 호출하기

마지막으로, React Native 단에서 requireNativeComponent 함수를 통해 네이티브 모듈을 호출하면, 구현한 파노라마 뷰가 React Native 앱에서 동작하는 것을 확인할 수 있다.

// .../panoramaView/index.tsx
import {ViewStyle, requireNativeComponent} from 'react-native';

export type PanoramaViewProps = {
  style: ViewStyle;
  imageUrl: string;
  dimensions?: {width: number; height: number};
};

  // requireNativeComponent 인자값이 네이티브 단에서 선언한 이름과 일치해야함.
export const PanoramaView = requireNativeComponent<PanoramaViewProps>('PanoramaView');

 

 

이때 requireNativeComponent 로 모듈을 불러오는 구문과 모듈과 연결된 View 컴포넌트를 렌더하는 구문이 한 파일에 같이 존재할 경우, 하기 에러가 반환된다.

Invariant Violation: Tried to register two views with the same name PanoramaView

따라서, 위처럼 모듈을 불러오는 구문을 한 파일로 따로  빼서 구현해주도록 하자.

 

위 절차를 모두 문제없이 진행하였을 경우, 아래처럼 파노라마 뷰가 깔끔하게 렌더되는 모습을 확인할 수 있다.

 

 

 

 

 


Reference

https://teabreak.e-spres-oh.com/swift-in-react-native-the-ultimate-guide-part-2-ui-components-907767123d9e

 

Swift in React Native - The Ultimate Guide Part 2: UI Components

This is a 2 part series intended to help all web & mobile developers that need to build custom Swift UI Components and use them in React…

teabreak.e-spres-oh.com