React 프로젝트에서 간단히 룰렛을 구현하고 싶다면 react-custom-roulette을 활용하는 방법을 추천드립니다.
이번에 우연하게 룰렛을 구현하는 작업을 진행하게 되었는데, 작업하면서 라이브러리를 사용해보니까 디자인에 제한사항은 있었지만 매우 간편하게 작업이 가능했어서 어떻게 활용했는지 정리해보았습니다.
가장 먼저 작업을 진행하기 위해 React 프로젝트를 생성하고 필요한 라이브러리를 설치했습니다. 저는 모달창이나 카드같은 부분을 빠르게 작업하기 위해 MUI(Material-UI)를 함께 활용하였습니다.
npx create-react-app my-app --template typescript
npm install react-custom-roulette
npm install @mui/material @emotion/react @emotion/styled
react-custom-roulette
는 Wheel 컴포넌트에 데이터를 전달하여 간단하게 룰렛을 생성할 수 있습니다. 이번에 6칸의 룰렛을 만들기 위해 아래와 같이 이름, 수량, 이미지가 들어간 상품 데이터를 작성했습니다. (실제 작업에 사용한 데이터는 사용이 불가능해 동물로 대체하였습니다🥲)
export const productList: ProductList = {
prize_1: { name: '강아지', quantity: 2, img: images.dog, icon: '🐶' },
prize_2: { name: '고양이', quantity: 3, img: images.cat, icon: '🐱' },
prize_3: { name: '여우', quantity: 5, img: images.fox, icon: '🦊' },
prize_4: { name: '돼지', quantity: 10, img: images.pig, icon: '🐷' },
prize_5: { name: '판다', quantity: 30, img: images.panda, icon: '🐼' },
prize_6: { name: '원숭이', quantity: 150, img: images.monkey, icon: '🙈' },
};
그 다음, 룰렛 컴포넌트에서 사용할 데이터를 PrizeData 배열 형태로 변환했습니다. 각 상품의 확률은 quantity 값을 기반으로 설정했으며, 상품별로 스타일 및 이미지 정보를 함께 담았습니다.
export const data: PrizeData[] = [
{
option: productList.prize_1.name,
style: { backgroundColor: rouletteRed, textColor: '#FFFFFF' },
probability: productList.prize_1.quantity > 0 ? 3 : 0,
image: {
uri: productList.prize_1.img,
},
},
{
option: productList.prize_2.name,
style: { backgroundColor: rouletteWhite, textColor: '#868686' },
probability: productList.prize_2.quantity > 0 ? 7 : 0,
image: { uri: productList.prize_2.img },
},
{
option: productList.prize_3.name,
style: { backgroundColor: rouletteRed, textColor: '#FFFFFF' },
probability: productList.prize_3.quantity > 0 ? 15 : 0,
image: { uri: productList.prize_3.img },
},
{
option: productList.prize_4.name,
style: { backgroundColor: rouletteWhite, textColor: '#868686' },
probability: productList.prize_4.quantity > 0 ? 25 : 0,
image: { uri: productList.prize_4.img },
},
{
option: productList.prize_5.name,
style: { backgroundColor: rouletteRed, textColor: '#ffffff' },
probability: productList.prize_5.quantity > 0 ? 25 : 0,
image: { uri: productList.prize_5.img },
},
{
option: productList.prize_6.name,
style: { backgroundColor: rouletteWhite, textColor: '#868686' },
probability: 50,
image: { uri: productList.prize_6.img },
},
];
react-custom-roulette
은 룰렛 내부에 이미지와 글자를 동시에 표시할 수가 없어서, 만약 룰렛 안에 이미지만 넣고 싶으면 image를 활용하고, 이미지 없이 글자만 넣고 싶은 경우에는 option에만 데이터를 넣어주면 됩니다.
저는 글자와 이미지를 동시에 넣고 싶어 피그마로 아래와 같이 별도로 상품 이미지를 만들어서 룰렛에 적용해 주었습니다.
룰렛 컴포넌트는 크게 다음 네 가지로 설계했습니다.
추가로 context api를 활용하기 위해 Provider를 만들어서 전체 컴포넌트를 감싸주었습니다.
react-custom-roulette
은 기본적으로 룰렛 시작 버튼이 하단에 위치하고 룰렛 포인터는 중앙이 아닌 우측에 위치해 있습니다.
뭔가 보기만 해도 그대로 쓰면 안될것 같은 느낌이 들어서 크게 3가지를 바꾸도록 디자인 했습니다.
1. 룰렛 포인터는 상단 중앙 이동
2. 룰렛 시작 버튼은 룰렛 중앙 이동
3. 전체 룰렛을 감싸는 테두리 추가
먼저 포인터의 위치를 중앙으로 옮기기 위해 테두리와 룰렛 전체를 감싸는 박스에 rotate: -47deg;
을 적용하여 회전 각도를 적용했습니다.
이후 테두리와 장식 원을 만들기 위해 SVG로 벡터 이미지를 만들어 활용했고, 포인터의 이미지도 변경하고 싶어서 별도의 이미지 만들어서 넣어주었습니다.
export function Border() {
const { mustSpin } = useRoulette();
const borderWidth = 15;
const smallCircleRadius = 3;
const numCircles = 12;
const svgSize = 220;
const outerRadius = (svgSize - borderWidth) / 2;
return (
<>
<div className="border-container">
<div className={`pointer-container ${mustSpin ? 'spinning' : ''}`}>
<img src={pointerImg} alt="pointer" style={{ width: '120px' }} />
</div>
<div className="border-wrapper">
{/* 벡터이미지 사용 */}
<svg viewBox={`0 0 ${svgSize} ${svgSize}`} className="roulette-svg">
{/* 큰 원 (테두리) */}
<circle
cx={svgSize / 2}
cy={svgSize / 2}
r={outerRadius}
fill="none"
stroke="#020b3e"
strokeWidth={borderWidth}
/>
{/* 작은 원 */}
{[...Array(numCircles)].map((_, index) => {
const angle = (index / numCircles) * 2 * Math.PI;
const x = svgSize / 2 + outerRadius * Math.cos(angle);
const y = svgSize / 2 + outerRadius * Math.sin(angle);
return <circle key={index} cx={x} cy={y} r={smallCircleRadius} fill="white" />;
})}
</svg>
</div>
</div>
</>
);
}
룰렛 가운데에 버튼을 넣기 위해 버튼을 감싸는 div박스 position을 absolute로 설정하여 중앙에 위치시키고, Wheel 컴포넌트에 미리 만들어둔 데이터를 연결하고, 각 속성들을 조절하여 화면을 구성했습니다.
<Wheel
mustStartSpinning={mustSpin}
data={data.map((item: any) => ({
option: item.option,
style: item.style,
image: item.image,
}))}
// startingOptionIndex={0}
prizeNumber={prizeNumber}
outerBorderColor={grey[300]}
outerBorderWidth={0}
innerBorderWidth={1}
innerBorderColor={grey[300]}
radiusLineWidth={0}
innerRadius={10}
fontSize={13}
onStopSpinning={() => {
setMustSpin(false);
}}
spinDuration={0.5}
backgroundColors={data.map((item: any) => item.style.backgroundColor)}
textColors={data.map((item: any) => item.style.textColor)}
pointerProps={{
src: '', // 포인터 이미지 URL
style: { display: 'none' }, // 기본 포인터 숨김
}}
perpendicularText={true}
textDistance={75}
/>
앞서 테두리에서 별도로 포인터 이미지를 넣었기 때문에, pointerProps의 스타일을 통해 기본 포인터는 보이지 않게 처리했습니다.
룰렛 시작 버튼의 경우 룰렛 중앙에 위치시키기 위해 position: absolute를 활용해 적절한 위치에 배치했습니다.
마지막으로 헤더 부분에 잔여 수량을 알 수 있도록 처음에 구성해놨던 productList데이터를 연결해주고,
해당 정보를 로컬스토리지에 저장하여 새로고침을 하더라도 룰렛 결과들을 반영하여 수량을 맞추도록 하였습니다.
작업을 진행하면서, 룰렛의 원 테두리를 넣고, 포인터를 상단 중앙에 위치시키고 시작 버튼을 가운데에 넣도록 구성하고 싶었는데, 어찌어찌 하다보니 생각한데로 잘 구현이 되었습니다.
처음으로 작업 내용을 블로그에 정리해 보았는데, 다소 두서없을 수 있지만 누군가 룰렛을 구현할 때 작은 참고가 되었으면 합니다 🥹
내용과 관련해서 궁금한 부분은 언제든 댓글로 남겨주세요!!!
자세한 코드는 아래 GitHub에서 확인 가능합니다!
리액트 룰렛 GitHub 바로가기