개발지식 정리/React Native

React Native 라이브러리 없이 360도 이미지 뷰어 구현하기

Neoself 2024. 7. 17. 00:54

네이버에 자동차를 검색하면, 아래와 같이 드래그 동작으로 원하는 각도에서의 이미지 확인이 가능한 360도 이미지 뷰어를 본 적이 있을 것이다. 해당 뷰어는 실제로 3D모델을 사용하기보단, 다각도에서 촬영한 여러 사진들 중 하나를 드래그 제스처에 맞춰 렌더하는 방식으로 동작한다. 라이브러리를 사용하지 않고, React Native 프레임워크에서 해당 기능을 구현해보자.

아이오닉 5 사고싶다


 

우선 새로운 기능 구현을 할때마다 항상 그래왔듯 유사한 라이브러리의 깃허브 레포지토리를 확인해 동작방식을 분석해보았다. 구현목표와 가장 근접한 라이브러리는 react-native-360-image-viewer 라이브러리였다.

https://github.com/phuochau/react-native-360-image-viewer

 

GitHub - phuochau/react-native-360-image-viewer: Inspired by https://github.com/scaleflex/js-cloudimage-360-view. This is the 36

Inspired by https://github.com/scaleflex/js-cloudimage-360-view. This is the 360 degrees simulation from multiple images for React Native - phuochau/react-native-360-image-viewer

github.com

위 라이브러리의 경우, react-native에서 제공하는 터치 및 제스처 이벤트를 처리하는 PanResponder 객체를 활용하여 드래그 제스처에 따른 이미지 각도 전환 로직이 구현되어있다.

 

허나 위 라이브러리를 저사양 기기에서 사용할 경우 드래그 시, 간헐적으로 이미지 로드속도가 이미지 전환 속도를 못따라가 Flickering 현상이 발생하는 이슈가 있었으며, 두 손가락으로 핀치 제스처를 할때 이미지가 줌아웃되는 기능도 구현하고자 했기 때문에, 이에 대한 추가 구현이 필요했다.

1. Pinch 제스처 로직 추가

UIKit의 UIPinchGestureRecognizer 클래스와 UIPanGestureRecognizer 클래스처럼 제스처 종류별로 클래스가 나뉘어져 있는 iOS와 달리, React Native에서 제공하는 PanResponder 객체는 단일 및 핀치 제스처를 포함한 복수 터치를 모두 핸들링할 수 있다. 물론 핀치 제스처과 드래그 제스처 처리 동작이 원활하게 구분되기 위해선, 제스처 이벤트가 진행될때마다 어떤 종류의 제스처인지 파악해 로직을 분기해야한다.

 

기존 Android 네이티브 모듈을 구현한 경험을 살려, isPinching ref 객체를 생성해 복수 터치 이벤트가 감지될 때와 제스처가 종료될 때 isPinching을 각 상황에 맞게 토글해주었고, 터치 동작이 유지될때 호출되는 handlePanResponderMove 함수에서 isPinching 변수를 읽고 해당 값에 따라 처리 로직을 분기하게끔 구조를 구성하였다.

const Image360Viewer = ({
  // isPinching ref객체 생성
  const isPinching = useRef<boolean>(false);
  
  const handlePanResponderMove = (
    evt: GestureResponderEvent,
    gestureState: PanResponderGestureState,
  ) => {
    const { nativeEvent: {touches} } = evt;
    if (touches.length === 2) {
      ...
      if (!isPinching.current) {
        // isPinching이 false인 상태에 2개 터치가 감지되면 핀치 제스처가 처음 시작함을 의미
        isPinching.current = true;
        ...
      } else {
        // isPinching가 true로 변경되었다면, 이미지 줌아웃 로직 실행
        ...
      }
    } else if (touches.length === 1 && !isPinching.current) {
      // 단일 터치이며 isPinching이 false면 드래그 동작 로직 실행
      ...
    }
  };
  
  const handlePanResponderEnd = () => {
    ...
    // 추후 제스처 종류를 정상 구분할 수 있게끔, isPinching을 false로 초기화
    isPinching.current = false;
  };

 

제스처 종류에 따른 분기 처리를 완료한 후에는 이미지 확대 및 축소 기능을 구현하였다. 기존에는 Image 컴포넌트로 전달된 이미지 파일을 정적으로 렌더하였는데, 핀치 제스처를 통해 변경되는 animatedScale ref 객체값에 따라 실시간으로 이미지의 크기가 변경되도록 하기 위해, Image 컴포넌트를 react-native에서 제공하는 Animated.Image 컴포넌트로 변경했다.

return (
    // 본래 영역을 튀어나오는 이미지는 가려준다
    <View style={{overflow: 'hidden'}} {...panResponder.panHandlers}>
      // 아래 Image 컴포넌트 제거 후 Animated.Image로 대체
      // <Image source={getImage()} ... /> 
      <Animated.Image
          source={getImage()}
          // animatedScale값에 따라 이미지 크기 실시간 변경
          style={{transform: [{scale: animatedScale}]}} 
          ...
        />
    </View>
  );
};

 

2. Flickering 현상 수정

2.1. 이미지 로드 시점 조정

해당 라이브러리의 깃허브 issue를 통해 이슈 해결 힌트를 찾을 수 있었다.

https://github.com/phuochau/react-native-360-image-viewer/issues/4

기존 라이브러리의 경우, getImage 함수를 통해 Image 컴포넌트의 source 속성으로 전달되는 이미지 데이터 자체가 드래그 제스처에 따라 변경되는 로직이다. 즉, 이미지가 보여야하는 시점부터 정직하게 로드가 시작되기 때문에, 렌더 간 딜레이가 생길 수 밖에 없는 구성이다.

 

하지만, position:'absolute'가 적용된 모든 이미지 파일들을 초기에 다 렌더한 후, 드래그 제스처가 진행됨에 따라 필요한 이미지만 눈에 보이게 스타일을 적용하게 되면 어떻게 될까? 당장 눈에 보이진 않더라도 모든 이미지가 처음부터 일괄적으로 로드되는 만큼, 극초반을 제외하면 드래그 시 이미지 로드로 인한 딜레이가 발생하지 않을 것이다.

// 외부로부터 이미지 배열 srcset을 전달받는다.
const Image360Viewer = ({ srcset }) => {
	...
    /* 기존 getImage 함수는 이미지 파일 자체를 반환한다
    const getImage = () => {
      const mRotation = rotation - Math.floor(rotation / 360) * 360;
      const index = Math.floor(mRotation / ROTATE_PERIOD);
      return srcset[index];
    };
    */

    // 이미지 식별을 위한 Index값만 반환
    const getIndex = () => {    
      const mRotation = rotation - Math.floor(rotation / 360) * 360;
      const index = Math.floor(mRotation / ROTATE_PERIOD);
      return index;
    }

    return (
      <View style={{overflow: 'hidden'}} {...panResponder.panHandlers}>
          // map함수로 배열 내부 이미지들 일괄 렌더
          {srcset.map((item: string, id: number) => (
            <Animated.Image
              key={id}
              source={{
                uri: item,
              }}
              // opacity 속성값 통해 특정 index값에 해당하는 이미지만 눈에 보이게
              style={{
                  transform: [{scale: animatedScale}],
                  opacity: getIndex() === id ? 1 : 0,
                }}
            />
          ))}
        </View>
    )
}

필자는 opacity 스타일 속성을 통해 특정 이미지만 보이게 로직을 구성하였으나, zIndex나 tintColor 속성을 통해 구성하여도 무방하다.

 

2.2.  이미지 로드시간 축소 및 이미지 캐싱

이미지 로드 시점을 앞당기는 것만으로도 Flickering 현상은 최소화되지만, Image 컴포넌트 대신 react-native-fast-image의 FastImage 컴포넌트를 사용하면 전체적인 이미지 로드시간이 짧아져 극초반에 드래그를 하여도 Flickering이 상당 부분 해소될 수 있으며, 이미지 데이터 캐싱 비중이 높아지기 때문에, 반복적인 드래그 동작을 하여도 적은 리소스를 사용하게 됨에 따라 성능을 최적화 할 수 있다.

FastImage 또한 React 컴포넌트 타입이기 때문에 Animated.createAnimatedComponent() 함수의 인자로 삽입이 가능하다. 따라서 핀치 줌이 가능한 FastImage 컴포넌트, AnimatedFastImage를 따로 생성해 사용하였다.


최종코드

// Image360Viewer.tsx

import {FC, useRef, useState} from 'react';
import {
  View,
  PanResponder,
  Dimensions,
  PanResponderGestureState,
  GestureResponderEvent,
  Animated,
  StyleSheet,
} from 'react-native';
import FastImage, {FastImageProps} from 'react-native-fast-image';

interface Point {
  x: number;
  y: number;
}

const { width: _width } = Dimensions.get('window');

const AnimatedFastImage = Animated.createAnimatedComponent(
  FastImage as FC<FastImageProps>,
);

const calcDistance = (pointA: Point, pointB: Point) => {
  return Math.sqrt(
    Math.pow(pointB.x - pointA.x, 2) + Math.pow(pointB.y - pointA.y, 2),
  );
};

const Image360Viewer = ({
  width = _width,
  height = 220,
  srcset = [],
  rotationRatio = 0.5,
}: {
  width: number;
  height?: number;
  srcset: any;
  rotationRatio?: number;
}) => {
  // 하나의 사진이 머무르게 되는 각도 너비, ROTATE_PERIOD이 30이면, x도~x+30도까지 같은 사진이 띄워진다
  const ROTATE_PERIOD = 360 / srcset.length;
  // 터치 시작 시 터치영역의 X좌표, 드래그 시 총 변경되는 각도너비(rotation)를 계산하기 위해 필요
  const startPosition = useRef({value: 0});
  // 터치를 시작 시 각도, -Infinity~+Infinity
  const startRotation = useRef({value: 0});
  // 현재 사진이 해당되는 각도, -Infinity~+Infinity
  const [rotation, setRotation] = useState(0);

  // 핀치 or 한 손가락 터치를 분기하기 위해 필요
  const isPinching = useRef<boolean>(false);
  // 핀치 시작 시, 두 손가락 사이의 거리
  const initialDistance = useRef<number>(0);
  // 핀치 시작 시, 이미지가 확대(축소)되어있던 비율(핀치 제스처를 완료할때마다 갱신)
  const prevScale = useRef<number>(1);
  // 핀치하며 실시간으로 조정되는 이미지크기 비율값, transform[{scale}]에 사용됨
  const animatedScale = useRef<any>(new Animated.Value(1)).current;

  const panResponder = PanResponder.create({
    onMoveShouldSetPanResponder: () => true,
    onPanResponderGrant: (
      evt: GestureResponderEvent,
      gestureState: PanResponderGestureState,
    ) => {
      handlePanResponderGrant(gestureState);
    },
    onPanResponderMove: (e, gestureState) => {
      handlePanResponderMove(e, gestureState);
    },
    onPanResponderEnd(e) {
      handlePanResponderEnd(e);
    },
  });

  const handlePanResponderGrant = (gestureState: PanResponderGestureState) => {
    startPosition.current.value = gestureState.moveX;
    startRotation.current.value = rotation;
  };

  const handlePanResponderMove = (
    evt: GestureResponderEvent,
    gestureState: PanResponderGestureState,
  ) => {
    const {
      nativeEvent: {touches},
    } = evt;

    if (touches.length === 2) {
      // 피칭을 실시하는 경우, 두 손가락의 위치값 실시간 수집
      const pointA = {x: touches[0].pageX, y: touches[0].pageY};
      const pointB = {x: touches[1].pageX, y: touches[1].pageY};

      if (!isPinching.current) {
        // 피칭을 시작할때, 이미지크기 배율의 계산에 사용되는 base값인 initialDistance 저장
        isPinching.current = true;
        initialDistance.current = calcDistance(pointA, pointB);
      } else {
        // (피칭 중인 두손가락의 거리 / 피칭 초기 당시 두손가락의 거리)에 기존 이미지 배율을 곱하여 최종 이미지 배율 scale 계산
        const distance = calcDistance(pointA, pointB);
        let scale = (distance / initialDistance.current) * prevScale.current;
        //한번 피칭하면서 조정되는 이미지 배율의 상, 하한선 정의
        if (scale > 5) {
          scale = 5;
        } else if (scale < 1) {
          scale = 1;
        }
        //Animated.Image의 transform 속성에 연결되는 animatedScale Ref객체에 scale값 대입
        animatedScale.setValue(scale);
      }
    } else if (touches.length === 1 && !isPinching.current) {
      // 한 손가락 드래그 시, 실행되는 분기점
      // 터치 시작때 저장한 x좌표와 드래그중인 x좌표 값을 활용해 변경된 각도너비를 계산한다.
      const currentX = gestureState.moveX;
      //rotationRatio 속성을 지정하여 드래그 시, 사진변경 속도를 조정할 수 있다.
      const deltaRotation =
        ((currentX - startPosition.current.value) * 180) /
        (rotationRatio * width);
      // 기존 각도값에 변경된 각도 너비값 더하여 최종 각도값 rotation 상태변수에 저장
      setRotation(startRotation.current.value + deltaRotation);
    }
  };

  const handlePanResponderEnd = (e: GestureResponderEvent) => {
    //이미지 배율 prevScale ref값에 저장, isPinching Ref변수 false로 변경
    const { nativeEvent: {touches} } = e;
    if (!touches.length) {
      isPinching.current = false;
      prevScale.current = animatedScale._value;
    }
  };

  const getIndex = () => {
    //-Infinity ~ +Infinity 범위의 값에 360의 배수를 빼거나 더하여 0~360 범위의 값 mRotation으로 변경
    const mRotation = rotation - Math.floor(rotation / 360) * 360;
    //사진의 개수에 따라 변경되는 ROTATE_PERIOD로 나누어 현재 각도값에 맞는 사진의 index 최종반환
    const index = Math.floor(mRotation / ROTATE_PERIOD);
    return index;
  };

  return (
    <View
      style={[styles(width, height).container, {overflow: 'hidden'}]}
      {...panResponder.panHandlers}>
      {srcset.map((item: string, id: number) => (
        <AnimatedFastImage
          key={id}
          source={{
            uri: item,
            priority: FastImage.priority.high,
          }}
          resizeMode={FastImage.resizeMode.cover}
          style={[
            styles(width, height).image,
            {
              transform: [{scale: animatedScale}],
              opacity: getIndex() === id ? 1 : 0,
            },
          ]}
        />
      ))}
    </View>
  );
};

export default External360Viewer;

const styles = (width: number, height: number) =>
  StyleSheet.create({
    container: {
      width,
      height,
    },
    image: {
      width,
      height,
      position: 'absolute',
      alignSelf: 'center',
    },
});

 

// 360도 이미지 기능이 포함된 스크린.tsx

const TestScreen = () => {
  const {width} = Dimensions.get('window');
  const PATH =
    'https://github.com/phuochau/react-native-360-image-viewer/blob/master/example/images/';

  const imagesUri =[
    PATH + 'iris-36.webp?raw=true',
    PATH + 'iris-35.webp?raw=true',
    ...
    PATH + 'iris-1.webp?raw=true',
  ]
  
  return (
    <View style={{flex: 1, justifyContent: 'center'}}>/>
      <Image360Viewer srcset={imagesUri} width={width} />
    </View>
  );
}

 

위 코드와 같이 최종 Image360Viewer를 호출하면 아래와 같이 라이브러리 없이 핀치 줌과 드래그를 통한 각도전환이 가능한 360도 이미지 뷰어를 구현할 수 있다.