본문 바로가기

개발/React & React Native

[React Native] react-native-skia 를 활용해 토스 복권 긁기 따라해보기

728x90

 

가끔씩 여러 앱을 사용하다 보면 이런 기능은 어떻게 구현했을까 라는 궁금증이 생긴다.

토스의 만보기를 통해 받는 복권긁기 역시 그런 궁금증을 가지게 하는 기능 중 하나였다.

토스의 일부는 React Native로 만드는 것으로 알고 있어서(아닐 수도 있음.. 채용 공고보고 그런갑다 하는거임) 한번 구현해보자! 하는 맘으로 만들어보았다.

 

 

 

 

우선 토스의 복권 긁기 기능을 살펴보았다. 복권을 받고 광고를 보면 나오는 화면이 위와 같다.

'여기를 긁어보세요' 문구와 함께 애니메이션 동작 -> 사용자가 직접 복권을 긁음 -> 다음 화면으로 넘어감 -> 당첨금 텍스트 및 카드, 상단 팝업과 하단 버튼이 Fade-in으로 등장

 

 

 

구글에 'react native scratch card'를 검색해보니 꽤나 괜찮은 예제들이 있었다.

이번 포스팅에서는 다음 영상을 참고해 코드를 작성할 예정이다.

https://www.youtube.com/watch?v=zmW_QjrNofU

 

 

 

전체 코드

import React, {
  forwardRef,
  Ref,
  useCallback,
  useImperativeHandle,
  useRef,
  useState,
} from 'react';
import {StyleSheet} from 'react-native';
import {
  Canvas,
  Group,
  Skia,
  Path,
  Mask,
  Rect,
  SkPath,
  LinearGradient,
  vec,
} from '@shopify/react-native-skia';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {svgPathProperties} from 'svg-path-properties';

interface ScratchWithGuestureHandlerProps {
  width: number;
  height: number;
}

export interface ScratchWithGuestureHandlerRef {
  reset: () => void;
}

const Offer = ({width, height}: ScratchWithGuestureHandlerProps) => {
  return (
    <Rect x={0} y={0} width={width} height={height}>
      <LinearGradient
        start={vec(0, 0)}
        end={vec(256, 256)}
        colors={['#bdf1ff', '#fcdcf0', '#b5ffea', '#f2fccc']}
      />
    </Rect>
  );
};

const ScratchPattern = ({width, height}: ScratchWithGuestureHandlerProps) => {
  return <Rect x={0} y={0} width={width} height={height} color="lightblue" />;
};

const ScratchWithGuestureHandler = forwardRef(
  (
    props: ScratchWithGuestureHandlerProps,
    ref: Ref<ScratchWithGuestureHandlerRef>,
  ) => {
    const {width, height} = props;

    const STROKE_WIDTH = useRef<number>(40);
    const totalAreaScratched = useRef<number>(0);
    const [isScratched, setIsScratched] = useState(false);
    const [paths, setPaths] = useState<SkPath[]>([]);

    const reset = useCallback(() => {
      setIsScratched(false);
      setPaths([]);
      totalAreaScratched.current = 0;
    }, []);

    useImperativeHandle(
      ref,
      () => ({
        reset,
      }),
      [reset],
    );

    const pan = Gesture.Pan()
      .onStart(g => {
        setPaths(prevPaths => {
          const newPaths = [...prevPaths];

          const path = Skia.Path.Make();
          newPaths.push(path);
          path.moveTo(g.x, g.y);

          return newPaths;
        });
      })
      .onUpdate(g => {
        setPaths(prevPaths => {
          const newPaths = [...prevPaths];
          const path = newPaths[newPaths.length - 1];
          path.lineTo(g.x, g.y);

          return newPaths;
        });
      })
      .onEnd(() => {
        const pathProperties = new svgPathProperties(
          paths[paths.length - 1].toSVGString(),
        );
        const pathArea = pathProperties.getTotalLength() * STROKE_WIDTH.current;
        totalAreaScratched.current += pathArea;

        const areaScratched =
          (totalAreaScratched.current / (width * height)) * 100;

        if (areaScratched > 60) {
          setIsScratched(true);
        }
      })
      .minDistance(1)
      .enabled(!isScratched);

    return (
      <GestureDetector gesture={pan}>
        <Canvas style={[styles.canvas, {width: width, height: height}]}>
          <Offer width={width} height={height} />
          {!isScratched ? (
            <Mask
              clip
              mode="luminance"
              mask={
                <Group>
                  <Rect
                    x={0}
                    y={0}
                    width={width}
                    height={height}
                    color="white"
                  />
                  {paths.map(p => (
                    <Path
                      key={p.toSVGString()}
                      path={p}
                      strokeWidth={STROKE_WIDTH.current}
                      style="stroke"
                      strokeJoin={'round'}
                      strokeCap={'round'}
                      antiAlias
                      color={'black'}
                    />
                  ))}
                </Group>
              }>
              <ScratchPattern width={width} height={height} />
            </Mask>
          ) : (
            <Offer width={width} height={height} />
          )}
        </Canvas>
      </GestureDetector>
    );
  },
);

const styles = StyleSheet.create({
  canvas: {
    marginTop: 60,
    borderRadius: 20,
    overflow: 'hidden',
  },
});

export default ScratchWithGuestureHandler;

 

 

Offer 컴포넌트

const Offer = ({width, height}: ScratchWithGuestureHandlerProps) => {
  return (
    <Rect x={0} y={0} width={width} height={height}>
      <LinearGradient
        start={vec(0, 0)}
        end={vec(256, 256)}
        colors={['#bdf1ff', '#fcdcf0', '#b5ffea', '#f2fccc']}
      />
    </Rect>
  );
};

 

복권을 긁으면 나타나는 컴포넌트이다. 토스와 최대한 비슷한 UI를 구현해주기 위해 react-native-skia의 LinearGradient를 사용했다.

 

 

ScratchPattern 컴포넌트

const ScratchPattern = ({width, height}: ScratchWithGuestureHandlerProps) => {
  return <Rect x={0} y={0} width={width} height={height} color="lightblue" />;
};

 

복권을 긁는 영역을 보여주는 컴포넌트이다.

 

 

Gesture.Pan()

const pan = Gesture.Pan()
  .onStart(g => {
    setPaths(prevPaths => {
      const newPaths = [...prevPaths];
      const path = Skia.Path.Make();
      newPaths.push(path);
      path.moveTo(g.x, g.y);
      return newPaths;
    });
  })
  .onUpdate(g => {
    setPaths(prevPaths => {
      const newPaths = [...prevPaths];
      const path = newPaths[newPaths.length - 1];
      path.lineTo(g.x, g.y);
      return newPaths;
    });
  })
  .onEnd(() => {
    const pathProperties = new svgPathProperties(
      paths[paths.length - 1].toSVGString(),
    );
    const pathArea = pathProperties.getTotalLength() * STROKE_WIDTH.current;
    totalAreaScratched.current += pathArea;

    const areaScratched =
      (totalAreaScratched.current / (width * height)) * 100;

    if (areaScratched > 60) {
      setIsScratched(true);
    }
  });

 

사용자가 복권 긁기 동작을 실행했을 때 각 터치 시작, 업데이트, 끝 동작에 이벤트를 설정한 부분이다.

  • onStart: 새로운 경로(SkPath)를 시작.
  • onUpdate: 터치 이동 경로를 lineTo로 이어줌.
  • onEnd: 긁힌 영역을 계산해서 조건에 따라 이벤트를 실행.

여기서 예제 코드의 일부를 수정했는데, onStart 부분에서 그냥 setPath(newPaths); 를 호출하면 paths 상태가 비동기적으로 업데이트되어 다음 onStart 호출 시 이전 상태를 참조하게 된다.

그렇게 되면 새로운 선을 그을 때 이전에 손가락을 뗀 부분(onEnd이 실행된 좌표)부터 새로운 좌표가 연결되는데, 나는 이게 마음에 들지 않아 setPath를 함수형 업데이트로 변경해 주었다.

 

 

Mask 컴포넌트

<Mask
  clip
  mode="luminance"
  mask={
    <Group>
      <Rect x={0} y={0} width={width} height={height} color="white" />
      {paths.map(p => (
        <Path
          key={p.toSVGString()}
          path={p}
          strokeWidth={STROKE_WIDTH.current}
          style="stroke"
          strokeJoin="round"
          strokeCap="round"
          antiAlias
          color="black"
        />
      ))}
    </Group>
  }>
  <ScratchPattern width={width} height={height} />
</Mask>

 

사용자가 복권을 긁은 영역만 드러나도록 처리하는 부분이다.

Mask는 react-native-skia의 주요 기능 중 하나로 특정 영역을 마스킹 처리해 해당 부분만 렌더링 하거나 숨길 수 있다.

복권 긁기 기능에서는 이 Mask가 긁힌 영역을 계산하고 해당 부분만 드러나도록 처리하는 데 사용된다.

 

 

  • clip 속성
    • clip 속성을 활성화하면 마스크에 지정된 영역만 캔버스에 렌더링 된다.
    • 예제에서는 흰색 Rect와 사용자가 긁은 검정색 Path가 마스크로 작동하고, 검정색 부분만 드러나도록 설정돼.
  • mode 속성
    • luminance 모드는 마스크의 밝기 값을 기준으로 렌더링을 제어한다.
      • 밝은 영역(흰색)은 보이게 하고, 어두운 영역(검정색)은 숨기는 방식.

 

 

복권 초기화

const reset = useCallback(() => {
  setIsScratched(false);
  setPaths([]);
  totalAreaScratched.current = 0;
}, []);

useImperativeHandle(
  ref,
  () => ({
    reset,
  }),
  [reset],
);

 

예제에 있는 초기화 기능이 테스트할 때 유용하게 사용될 것 같아 추가해 보았다.

나는 GestureDetector 부분을 자식 컴포넌트로 따로 빼서 화면에 연결했기 때문에 부모 컴포넌트에서 reset() 함수를 호출할 수 있도록 forwardRef 및 useImperativeHandle를 사용해 주었다.

 

 

 

 

 

 

이렇게 react-native-skia와 react-native-gesture-handler를 활용해 토스 복권 긁기와 유사한 기능을 구현해 보았다.

다만 다음 결과 화면을 구현하려 react-native-reanimated 라이브러리를 추가하면 해당 컴포넌트에서 UI Thread 관련 오류가 발생하기 때문에 이 부분을 고치는 것이 다음에 할 일이 되었다고 한다...🥲

300x250