Neoself의 기술 블로그

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

개발지식 정리/React Native

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

Neoself 2024. 6. 10. 00:31

현재까지 프로젝트root/android 폴더 내 파일들을 설명하자면 다음과 같다.

android/app/src/main/java/com/{프로젝트명}

  1. React Native 초기 생성 때 기본으로 추가되는 MainActivity.kt, MainApplication.kt
  2. gvr-android-sdk를 모듈화하여 RN에 연결하기 위한 PanoramaViewManager.java 및 PanoramaViewPackage.java 파일
  3. gvr-android-sdk가 제공하는 패키지 및 함수가 호출되고 사용되는 파일들이 위치한 video360 폴더
    1. MediaLoader.java = 렌더되는 파노라마 이미지 처리 및 관리 관련 클래스가 구현되어있는 파일
    2. VideoUiView.java = 렌더 대상을 이미지와 비디오 사이에서 토글하기 위한 Ui 구현에 필요한 파일
    3. MonoscopicView.java = 하나의 시점으로 360도 이미지를 볼 수 있는 View 컴포넌트, 즉 파노라마뷰 클래스가 구현된 파일
    4. 미디어파일을 파노라마뷰에 맞추기 위해 구형형태로 펴주는? createUvSphere(), 파노라마 뷰 구현에 사용되는 OpenGL 프로그램을 호출시키는 compileProgram() 등의 함수들이 정의되어있는 rendering 폴더

 

위와 같이 파일들을 추가하여 네이티브 모듈 연결을 완료한 후, 네이티브 모듈을 본래 기획에 맞게 수정하는 과정을 거쳤다. gvr-android-sdk 구현코드와 본래기획과 다른 점은 다음와 같다.

본래 기획 gvr-android-sdk
RN에서 imageURL을 통해 파나로마 이미지 전달 Android Intent를 통해 이미지 파일 전달
핀치 제스처를 통해 줌인, 줌아웃 기능 필요 핀치줌 제스처 관련 로직 없음
터치 제스처로만 화면 각도 변경 기기 회전각도와 드래그 제스처 둘다 화면 각도에 영향

 

1. React Native 컴포넌트 속성값으로 파노라마 이미지 파일 전달되게 로직 변경

가장 먼저, MediaLoader 파일 내부에 선언된 MediaLoaderTask 클래스를 수정하였다. 기존 로직의 경우 Intent 내부에 첨부된 imageURI를 통해 이미지 파일 전달이 이루어졌는데, RN에서 전달받은 String 인자로 이미지 해석 및 load가 되게 코드를 변경하였다.

import ...

public class MediaLoader 
  ...
  // string 타입의 imageUrl을 RN으로부터 받아, 이를 수정한 MediaLoaderTask에 삽입하는 함수 추가
  public void setImage(String imageUrl){
    mediaLoaderTask = new MediaLoaderTask();
    mediaLoaderTask.execute(imageUrl);
  }
  
  // Intent를 수신받을 때 실행되는 대신, setImage 함수가 외부에서 호출될때 실행되도록 변경
  public class MediaLoaderTask extends AsyncTask<String, Void, Boolean> {
    @Override
    protected Boolean doInBackground(String... urls) {
      String value = urls[0];
      if (isCancelled()) {
        return null;
      }
      InputStream istr = null;
      try {
      	// 1. 인자로 전달받은 url string값을 Uri로 parse
        Uri imageUri = Uri.parse(value);
        String scheme = imageUri.getScheme();
        // 2. InputStream으로 변환
        if (scheme == null || scheme.equalsIgnoreCase(SCHEME_FILE)) {
          istr = new FileInputStream(new File(imageUri.getPath()));

        } else {
          URL url = new URL(value);
          HttpURLConnection connection = (HttpURLConnection) url.openConnection();
          connection.connect();

          istr = connection.getInputStream();
        }
        Assertions.assertCondition(istr != null);
        // 3. InputStream을 Bitmap 이미지파일로 decode한후 클래스 변수인 mediaImage로 저장
        mediaImage = decodeSampledBitmap(istr);

      } catch (Exception e) { 
        if (isCancelled()) {
          return null;
        }
        Log.e("MediaLoader", "Could not load file: " + e);
        return null;
      }
      // 4. 클래스 변수인 mesh로 Monoscopic UvSphere 저장
      mesh = Mesh.createUvSphere(
              SPHERE_RADIUS_METERS, DEFAULT_SPHERE_ROWS, DEFAULT_SPHERE_COLUMNS,
              DEFAULT_SPHERE_VERTICAL_DEGREES, DEFAULT_SPHERE_HORIZONTAL_DEGREES,
              Mesh.MEDIA_MONOSCOPIC);
	  // 5. displayWhenReady 호출
      displayWhenReady();
      return false;
    }

  ...

  @AnyThread
  private synchronized void displayWhenReady() {
    ...
	if (mediaImage != null) {
    // 6. 캔버스 객체에 클래스변수 mediaImage에 저장된 이미지 파일 표시
      displaySurface = sceneRenderer.createDisplay(
          mediaImage.getWidth(), mediaImage.getHeight(), mesh);
      Canvas c = displaySurface.lockCanvas(null);
      c.drawBitmap(mediaImage, 0, 0, null);
      displaySurface.unlockCanvasAndPost(c);
    } else {
     ...
    }
  }
  ...
  
}

 

이제 ImageUrl String값으로 이미지파일을 생성하는 MediaLoaderTask 함수가 완성되었으니 React Native 인자값을 받을 때 해당 함수가 호출되도록 연결해보자.

가장 먼저, MediaLoader 클래스에 접근이 가능한 MonoscopicView 클래스 내부에 setImageSource 함수를 생성하여, mediaLoader 내부에 접근 & MediaLoaderTask 함수를 호출할 수 있도록 연결한다.

import ...

public final class MonoscopicView extends GLSurfaceView {
  private MediaLoader mediaLoader;	
  private Renderer renderer;
  private TouchTracker touchTracker;

  public MonoscopicView(Context context, AttributeSet attributeSet) {
    super(context, attributeSet);
    setPreserveEGLContextOnPause(true);
  }

  public void setDimensions(ReadableMap dimensions) {
    mediaLoader.setDimensions(dimensions);
  }
  // 여기! MediaLoader 클래스가 private으로 선언되어 MonoscopicView를 거쳐가게 로직을 설계하였는데
  // 지금 돌아보면 Manager파일에서 바로 MediaLoader 내부 메서드를 실행하게 변경이 필요해보인다.
  public void setImageSource(String value){
    mediaLoader.setImage(value);
  }

 

PanoramaViewManager 파일 상에는 @ReactProp() 어노테이션을 통해 어노테이션 내부 선언된 name과 동일한 React Native 단 컴포넌트 속성이 존재할 경우, 전달받은 속성값을 매개체로 함수를 실행할 수 있다. 이를 활용해 RN에서 전송하는 imageUrl을 핸들링하는 imageUrl 함수를 추가하였다. 

또한 이제 Intent를 통한 이미지 파일 전송 로직을 사용하지 않기에 PanoramaViewManager initialize 단계에 실행되는 intent 핸들링 로직을 일괄 제거해주었다.

// PanoramaViewManager

public class PanoramaViewManager extends SimpleViewManager<MonoscopicView> implements LifecycleEventListener {
    private static final String REACT_CLASS = "PanoramaView";

    @NonNull
    @Override
    public String getName(){ return REACT_CLASS; }

    public MonoscopicView videoView;

    public PanoramaViewManager(ReactApplicationContext context) {
        context.addLifecycleEventListener(this);

        LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        videoView = (MonoscopicView) inflater.inflate(R.layout.video_activity, null);
        videoView.initialize();

		// 아래 구문 모두 제거
        //Intent intent = new Intent(android.content.Intent.ACTION_VIEW);
        //intent.setClassName("com.google.vr.sdk.samples.video360", "com.google.vr.sdk.samples.video360.VrVideoActivity");
        //intent.setData(Uri.parse("file:///sdcard/IMAGE.JPG"));
        //intent.putExtra("stereoFormat", 2);=
        //videoView.loadMedia(intent);
    }

	// RN 네이티브 컴포넌트의 imageUrl이라는 속성 존재시, imageUrl 함수 실행
    @ReactProp(name = "imageUrl")
    public void imageUrl( MonoscopicView view,  String imageUrl) {
        view.setImageSource(imageUrl);
    }
    
    @ReactProp(name = "dimensions")
    public void setDimensions(MonoscopicView view, ReadableMap dimensions) {
        view.setDimensions(dimensions);
    }

 

상기 절차들을 모두 진행하고 나면, 아래와 같이 React Native 단에서 호출하는 PanoramaView 컴포넌트의 인자를 통해 렌더 이미지 링크를 직접 전달할 수 있게 된다.

// React Native

return (
    <PanoramaView
      imageUrl={
        'https://live.staticflickr.com/4066/5147559690_54a4024c80_b.jpg'
      }
      style={{height: '100%'}}
    />
);

2.  핀치 제스처를 통해 줌인, 줌아웃 할 수 있는 기능 추가

기존 MonoscopicView.java 파일 내부 제스처 핸들링 구문

더보기
static class TouchTracker implements OnTouchListener {
	...
    private final Renderer renderer;
    public TouchTracker(Renderer renderer) {
      this.renderer = renderer;
    }
    @Override
    public boolean onTouch(View v, MotionEvent event) {
      switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
          previousTouchPointPx.set(event.getX(), event.getY());
          return true;
        case MotionEvent.ACTION_MOVE:
          float touchX = (event.getX() - previousTouchPointPx.x) / PX_PER_DEGREES;
          float touchY = (event.getY() - previousTouchPointPx.y) / PX_PER_DEGREES;
          previousTouchPointPx.set(event.getX(), event.getY());

          float r = roll;  // Copy volatile state.
          float cr = (float) Math.cos(r);
          float sr = (float) Math.sin(r);
          accumulatedTouchOffsetDegrees.x += cr * touchX - sr * touchY;
          accumulatedTouchOffsetDegrees.y += sr * touchX + cr * touchY;
          accumulatedTouchOffsetDegrees.y =
                  Math.max(-MAX_PITCH_DEGREES,
                          Math.min(MAX_PITCH_DEGREES, accumulatedTouchOffsetDegrees.y));
          renderer.setPitchOffset(accumulatedTouchOffsetDegrees.y);
          renderer.setYawOffset(accumulatedTouchOffsetDegrees.x);
          return true;
        default:
          return false;
      }
    }

제스처 상호작용을 처리하는 구문은 모두 MonoscopicView.java에 구현되어있다. 기존 제스처 핸들링 로직을 간단하게 설명하자면, OnTouchListener를 상속받는 TouchTracker 클래스 하나로 모든 제스처 이벤트를 처리되는 구조였다. 문제는 onTouchTracker 클래스 자체로는 pinch 제스처 핸들링이 불가능했으며, 두 손가락 사이의 거리를 측정하는 등 핀치 제스처에 최적화된 ScaleGestureDetector 클래스를 별도로 추가해야만 했다. 결국, 핀치줌이 실행되는 경우, 그리고 단일 터치가 이뤄지는 경우가 분기되어서 제스처가 핸들링되어야 했기때문에, onTouchListener를 통해 터치 동작이 감지될 경우 ScaleGestureDetector 그리고 단일 터치 동작만 감지하는 GestureDetector 두 클래스 중 하나만 실행될 수 있도록 다음과 같이 구문을 수정해주었다.

public void initialize() {
	 ...
     gestureDetector = new GestureDetector(getContext(), new MyGestureListener(renderer));
     scaleGestureDetector = new ScaleGestureDetector(getContext(), new MyScaleListener(renderer));
     setOnTouchListener(touchListener);
   }
   
   OnTouchListener touchListener = new View.OnTouchListener() {
     @Override
     public boolean onTouch(View v, MotionEvent event) {
       // 터치가 감지되면, scaleGestureDetector에게 터치이벤트 우선 전달
       scaleGestureDetector.onTouchEvent(event);
       // scaleGestureDetector가 처리하는 터치 이벤트가 아닐 경우 gestureDetector로 이벤트 전달
       if(!scaleGestureDetector.isInProgress()){
         gestureDetector.onTouchEvent(event);
       }
       return true;
     }
   };

하지만 이 코드의 경우, 핀치 제스처를 하는 도중에 한 손가락을 뗄 경우 갑작스레 드래그 동작 즉, 파노라마뷰 화면각도 전환 로직으로 넘어가게 되어, 사용자 경험이 대폭 낮아지는 이슈가 있음을 확인할 수 있었다. 따라서 이 이슈를 해결하고자 isPinching이라는 전역변수를 생성하여 gestureDetector로 넘어가는 조건을 하나 더 추가해주었다.

public final class MonoscopicView extends GLSurfaceView {
      // 클래스 내부에 isPinching 전역변수 추가
      private boolean isPinching = false;
      ...
      @Override
      public boolean onTouch(View v, MotionEvent event) {
        scaleGestureDetector.onTouchEvent(event);
        if(!scaleGestureDetector.isInProgress()){
          if(event.getAction() == MotionEvent.ACTION_UP){
            // 두 손가락이 모두 화면을 벗어날 경우 isPinching을 false로 변경
            isPinching=false;
          } else{
             // isPinching이 false일 경우에만 gestureDetector 실행되게 조건 추가
             if(!isPinching){
               gestureDetector.onTouchEvent(event);
             }
          }
        }
        return true;
      };
      ...
      private class MyScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
        // ScaleGestureDetector에서 핀치동작이 감지될 경우 isPinching변수 true로 변환
        public boolean onScaleBegin(ScaleGestureDetector detector) {
          isPinching=true;
          return true;
        }
      }
      ...   
}

 

3.  터치 제스처로만 화면 각도 변경되게 변경

기존 video360 SampleApp 코드의 경우 드래그 제스처뿐만 아니라 기기 회전각도도 화면 각도에 영향을 주었다. 아마 핸드폰을 가상현실 HMD처럼 활용하고자 해당 로직이 구현된 것으로 보이지만, 파노라마 뷰에서는 해당 로직이 필요없으므로, 가속도센서값 Listener와 같이 위 로직과 연관된 코드들을 모두 제거해주었다.

public final class MonoscopicView extends GLSurfaceView {
  // 모바일 각도 회전값 측정에 필요했던 객체들 일괄 제거
  // private SensorManager sensorManager;
  // private Sensor orientationSensor;
  // private PhoneOrientationListener phoneOrientationListener;
  
  
   public void initialize() {
    // 모바일 기기 방향 Listner 및 센서 선언 구문 모두 제거
    // sensorManager = (SensorManager) getContext().getSystemService(Context.SENSOR_SERVICE);
    // orientationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR);
    // phoneOrientationListener = new PhoneOrientationListener();
   }
  
  public void onResume() {
    // onResume 호출 시 실행되는 방향 listner 등록 구문 삭제
    // sensorManager.registerListener(phoneOrientationListener, orientationSensor, SensorManager.SENSOR_DELAY_FASTEST);
    ...
  }
  
  public void onPause() {
    // onPause 호출 시 실행되는 방향 listner 등록해제 구문 삭제
    // sensorManager.unregisterListener(phoneOrientationListener);
  }
  
  // PhoneOrientationListener 전체 삭제
  // private class PhoneOrientationListener implements SensorEventListener {
  //  ...
  // }
  
  // 모바일 기기의 회전값을 파노라마 뷰 각도값으로 치환하는 setRoll 함수 불요하기에 삭제
  // public void setRoll(float roll) {
      // We compensate for roll by rotating in the opposite direction.
      // this.roll = -roll;
  // }
  
  // setDeviceOrientation 함수 삭제
  // @BinderThread
  // public synchronized void setDeviceOrientation(float[] matrix, float deviceRoll) {
  //   System.arraycopy(matrix, 0, deviceOrientationMatrix, 0, deviceOrientationMatrix.length); //**
  //   this.deviceRoll = -deviceRoll;
  //   updatePitchMatrix();
  // }