react-native-masked-carousel 만들기

simplefunction·2023년 8월 16일

RN Playground

목록 보기
1/4
post-thumbnail

시작

어느 날인가 문득 무신사 앱에 위치한 - 사람에 따라 이미지 슬라이더라고 부르기도 하는 - Carousel(캐러셀)이 눈길을 사로잡았다. 별 생각없이 이미지를 넘기다 뭔가 기존과 다른 느낌을 받았기 때문이다.

일반적인 캐러셀은 아래와 같이 양 옆 이미지가 가운데 위치한 이미지를 밀어내며 이동한다.

ex) react-native-reanimted-carousel, normal-horizontal mode


그러나 무신사 첫 화면의 캐러셀은 양 옆 이미지가 가운데 이미지가 스와이프에 따라 걷히며 나타난다. 사용자 제스처에 따라 가운데 이미지가 덮고 있는 양 옆의 이미지를 드러나게 하는 것 처럼 보인다.

팀에서 그래프 애니메이션과 UX 마이크로 인터랙션 등 애니메이션 효과를 가장 좋아해서 주로 개발하는 나는 Reanimated를 통해 해당 컴포넌트를 재현해보면 재밌겠다는 생각이 들었다. 그래서 masked view를 사용하여 무신사의 캐러셀처럼 동작하는 react-native-masked-carousel 를 만들어 보기로 했다.


분석

  • 아래 요소들로 구성된 덩어리 세 개(n-1, n, n+1)로 구성되어 있다.
    • 이미지
    • 이미지 중앙에 위치하는 타이틀
    • 타이틀 아래 텍스트
  • 제스처를 통해 스와이프 할때 이미지는 서로 밀어내지 않고 아래에 위치하는 이미지가 드러나는 것 처럼 보인다.
  • 타이틀과 그 아래 텍스트는 제스처 이벤트로 발생되는 값으로 이동한다. 그러나 둘은 그 값에 따라 이동하는 정도가 다르다.

설계

  1. 사용자 스와이프 방향에 따라 왼쪽 또는 오른쪽의 MaskedView를 움직인다.
  2. 사용자가 Fling할 경우 또는 사용자가 Swipe한 거리가 화면의 절반을 넘을 경우 왼쪽 또는 오른쪽의 MaskedView를 화면밖으로 끝까지 이동시킨다. (이미지를 모두 드러낸다)
    2-1. 제스쳐 이벤트 값에 따라 타이틀 및 하단 텍스트도 이동 시킨다.
  3. 가운데 이미지를 2번에서 모두 드러난 이미지로 변경하고, 양쪽 이미지도 인덱스의 n-1, n+1 번째 이미지로 교체한다.
    3-1. 타이틀 및 하단 텍스트도 인덱스에 맞게 교체한다.
  4. 제스쳐 이벤트가 발생하면 1~3번 동작을 반복한다.

구현

먼저 사용자의 제스처를 받아 동작하기 위해 컴포넌트를 GestureDetector로 감싸준다.
<GestureDetector gesture={onDragGesture}> // onDragGesture -> Panning 이벤트 리스너
  //...캐러셀 동작 부분
</GestureDetector>
가운데 - 왼쪽 - 오른쪽 순서로 이미지들을 쌓는다. 왼쪽 - 오른쪽 순서는 무관할 것 같다.
	{...}
        {/* CENTER IMAGE */}
		<View>
          <FastImage // Image cache를 위해 react-native-fast-image 사용
            ...
          />
        </View>

        {/* LEFT IMAGE */}
        <MaskedView
          ...
          maskElement={ // 제스처 이벤트에 따라 움직이는 부분
            <Animated.View
              style={{
                ...animatedStylesLeft, // 마스킹 영역을 움직이기 위한 AnimatedStyle
              }}
            >
              ...
            </Animated.View>
          }
        >
          <FastImage />
        </MaskedView>

        {/* RIGHT IMAGE */}
        <MaskedView
		  {...props}
        >
          {...}
        </MaskedView>
	{...}

텍스트를 넣기 전 Carousel

아래에 각 이미지에 들어갈 타이틀 및 텍스트도 쌓는다. 텍스트는 MaskedView가 필요 없다.

        {/* CENTER TITLE, TEXT */}
		// optinoal Prop인 title/subtitle이 있을 때만 출력
        {data[centerImageIndex].title ? ( 
          <Animated.View>
            <Text>
              {data[centerImageIndex].title}
            </Text>
          </Animated.View>
        ) : null}
        {data[centerImageIndex].subTitle ? (
          <Animated.View>
            <Text>
              {data[centerImageIndex].subTitle}
            </Text>
          </Animated.View>
        ) : null}

        {/* LEFT TITLE, TEXT */}
        {...}

        {/* RIGHT TITLE, TEXT */}
        {...}

사용자 제스처에 따른 리스너를 만들고 그 값에 따라 움직이도록 애니메이션을 정의한다.

const distanceRatioFromCenter = 1.4 // title이 이미지와 움직이는 속도를 다르게 하기 위함
      
const dragGesture = Gesture.Pan()
    .onUpdate((e) => {
      // 제스처를 통해 MaskedView가 이동한 정도를 계산 -> 시작 값 + 움직인 값
      offsetXValue = startXValue + translationXValue;

      // 지정된 위치를 벗어나는 경우 MaskedView 및 텍스트를 움직이지 않도록 한다.
      if (offsetXValue < -width) { 
        offsetXValue = -width;
      } else if (offsetXValue > width) {
        offsetXValue = width;
      }
    })
    .onEnd((e) => {
      // Fling 제스처일때 (판단기준 -> 제스쳐 속도가 일정 기준 이상일때)
      if (Math.abs(velocityX) > 500) {
        if (velocityX > 0 && isLeft) { // 왼쪽 이미지가 중앙으로 이동했을 경우
          // MaskedView 이동
          offsetXValue = width
          //이미지 교체
          centerImageIndex = centerImageIndex - 1 < 0 ? data.length - 1 : centerImageIndex - 1; // for infinite loop
      	  // 텍스트 이동
          offsetXTextValue = width * distanceRatioFromCenter; 
          return;
        }
           
        if (velocityX < 0 && isRight) { // 오른쪽 이미지가 중앙으로 이동했을 경우
      	  	...
            return;
        } else {
			...
        }
      } else { // swipe 제스쳐 일때
        if (isLeft && offsetXValue > 0) {
          if (offsetXValue > width / 2) { // 화면 절반 이상 swipe 된 경우
            ...
          } else {
            ...
          }
          return;
        }
        if (isRight && offsetXValue < 0) {
          ...
          } else {
            ...
          }
          return;
        }
      }
    });

결과

텍스트까지 넣고 난 뒤 아래처럼 무신사의 Carousel을 따라만든 react-native-masked-carousel이 완성되었다.

소스코드 전체는 여기에서 확인가능하다!

profile
RN developer

4개의 댓글

comment-user-thumbnail
2023년 8월 16일

이런 유용한 정보를 나눠주셔서 감사합니다.

1개의 답글
comment-user-thumbnail
2024년 4월 12일

글 재밌게 잘 읽었습니다!

1개의 답글