가끔씩 여러 앱을 사용하다 보면 이런 기능은 어떻게 구현했을까 라는 궁금증이 생긴다.
토스의 만보기를 통해 받는 복권긁기 역시 그런 궁금증을 가지게 하는 기능 중 하나였다.
토스의 일부는 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 모드는 마스크의 밝기 값을 기준으로 렌더링을 제어한다.
- 밝은 영역(흰색)은 보이게 하고, 어두운 영역(검정색)은 숨기는 방식.
- 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 관련 오류가 발생하기 때문에 이 부분을 고치는 것이 다음에 할 일이 되었다고 한다...🥲
'개발 > React & React Native' 카테고리의 다른 글
[React Native] 리액트 네이티브에서 Apollo Client로 GraphQL 사용하기 (with. SpaceX) (1) | 2024.11.28 |
---|---|
[React Native] 리액트 네이티브 최신 버전 New Architecture 설정 끄기 (1) | 2024.11.18 |
[React] 리액트 라인차트 (Line Chart) 컴포넌트 만들기 (1) | 2024.11.08 |
[React] 리액트 원형 프로그래스 (Circle Progress Bar) 컴포넌트 만들기/예제 (1) | 2024.11.04 |
[React Native] React Native의 New Architecture (JSI, JavaScript Interface) (5) | 2024.10.31 |