[React,Emotion] 회전 Dial 컴포넌트 만들기
프로젝트/소규모프로젝트들

[React,Emotion] 회전 Dial 컴포넌트 만들기

728x90

프로젝트를 하다가 아래와 같이 회전하는 Dial모양의 컴포넌트를 만들게 되었다.

 

 

 

 

 

 

우선 위 컴포넌트를 만들어낸 방식은 아래와 같다.

 

실제로는 아래와 같이 미리 렌더링을 하고 , 빨간색 박스 부분만 보여지도록 제어를 해주면 된다.

 

 

 

이때 회전을 주기위해서 rotate 효과를 활용할 수 있다.

 

const [rotation, setRotation] = useState(0);

 

따라서 위와같이 회전값을 지정해준 후 버튼이 눌릴때마다 회전을 시켜주면 된다.

 

 

그런데 단순하게 위 그림대로 만들어 두고 회전하는 방식으로는 정확하게 180도씩 회전하지 않았다.

 

 

 

위 gif에서 검은색 박스를 보자. rotate 으로 각도를 변화시키면 영역을 감싸고 있는 큰 박스자체가 회전을 하게 된다.

 

이때 세개의 작은 Dial들이 있는 부분을 absolute속성으로 위치시켰다면 회전시켰을때 정확히 서로의 위치에 가게 제어하는 것이 어려웠다.

 

 

따라서 한개의 큰 영역을 회전시켜서 맞추는 것이 아닌, 아래와 같은 영역을 개개별로 3개를 만들어서 미리 회전시켜두었다.

 

 

 

이 경우 시작점이 세개의 회전 Dial 모두 0도에서 시작하기 때문에 회전을 할때 정확하게 서로의 위치가 맞게 된다!!

 

 

/** @jsxImportSource @emotion/react */
import React, { useState } from "react";
import styled from "styled-components";
import CircleTyle from "./CircleTyle";
import Dial from "./Dial";
import { circleType, searchType, tmpSelectType } from "types/home";
import { waitSecond } from "utils/time";
import { homeColor } from "styles/color";

interface CircleProp {
  rotation: number;
  circleType: circleType;
  isAnimate: boolean;
}

interface Props {
  windowWidth: number;
}

const circleDegree = {
  main: 0,
  before: 240,
  after: 120,
};

const Wheel = ({ windowWidth }: Props) => {
  const [rotation, setRotation] = useState(0);
  const [dialRotation, setDialRotation] = useState(0);
  const [dialMainType, setDailMainType] = useState<tmpSelectType>("searchType");
  const [isAnimate, setIsAnimate] = useState(true);

  // 현재 다이얼에 표시될 메뉴의 순서 (메인 , 이전 , 이후 순서)
  const [menuList, setMenuList] = useState<searchType[]>([
    "views",
    "popular",
    "followers",
  ]);
  const menuOrder: circleType[] = ["main", "before", "after"];
  const degreeOrder = [0, -120, 120];

  /**
   * 휠을 오른쪽으로 회전시키는 함수
   * @param isturnRight
   */
  const rotateWheelRight = async (isturnRight: boolean) => {
    setRotation(rotation + (isturnRight ? 120 : -120));

    await waitSecond(1); // 애니메이션 시간 대기
    setIsAnimate(false); // 애니메이션을 끄고 새로운 정보로 대체
    setRotation(0);
    setDialRotation(0);
    setDailMainType("searchType");

    if (isturnRight) setMenuList([menuList[1], menuList[2], menuList[0]]);
    else setMenuList([menuList[2], menuList[0], menuList[1]]);

    await waitSecond(0.01); // 다시 애니메이션이 적용될 수 있도록 적용
    setIsAnimate(true);
  };

  return (
    <Wrapper>
      <WheelIntroduce>
        <Title>
          <p>
            <span>Tyle</span>의 카테고리별
          </p>
          <p>랭킹 확인</p>
        </Title>
        <Ment>
          <p>원판을 돌려 더많은 순위정보를</p>
          <p>확인해 보세요!</p>
        </Ment>
      </WheelIntroduce>
      <WheelWrapper>
        <Dial
          searchType={menuList[0]}
          beforeType={menuList[1]}
          afterType={menuList[2]}
          rotateWheelRight={rotateWheelRight}
          rotation={dialRotation}
          setRotation={setDialRotation}
          mainType={dialMainType}
          setMainType={setDailMainType}
          isAnimate={isAnimate}
          windowWidth={windowWidth}
        ></Dial>
        {menuList.map((menu, idx) => (
          <BigCircleArea
            isAnimate={isAnimate}
            rotation={rotation + degreeOrder[idx]}
            key={idx}
          >
            <BigCircle>
              <Circles>
                <CircleTyleArea
                  isAnimate={isAnimate}
                  rotation={rotation}
                  circleType={menuOrder[idx]}
                >
                  <CircleTyle
                    width={windowWidth * 0.2}
                    userType="silver"
                    searchType={menu}
                  ></CircleTyle>
                </CircleTyleArea>
                <CircleTyleArea
                  isAnimate={isAnimate}
                  rotation={rotation}
                  circleType={menuOrder[idx]}
                >
                  <CircleTyle
                    width={windowWidth * 0.2}
                    userType="gold"
                    searchType={menu}
                  ></CircleTyle>
                </CircleTyleArea>
                <CircleTyleArea
                  isAnimate={isAnimate}
                  rotation={rotation}
                  circleType={menuOrder[idx]}
                >
                  <CircleTyle
                    width={windowWidth * 0.2}
                    userType="bronze"
                    searchType={menu}
                  ></CircleTyle>
                </CircleTyleArea>
              </Circles>
            </BigCircle>
          </BigCircleArea>
        ))}
      </WheelWrapper>
    </Wrapper>
  );
};

/* STYLE */
const Wrapper = styled.div``;

const WheelWrapper = styled.div`
  position: relative;
  width: 100vw;
  height: 50vw;
  overflow: hidden;
`;

const WheelIntroduce = styled.div`
  padding: 8rem 2rem;
  & span {
    color: ${homeColor.empahsis};
  }
`;

const Title = styled.div`
  font-size: 50px;
  font-weight: 700;
  p {
    margin-bottom: 0.5rem;
  }
`;
const Ment = styled.div`
  font-size: 22px;
  font-weight: 700;
  color: ${homeColor.wheelIntroduceGray};
`;

const BigCircleArea = styled.div<{ rotation: number; isAnimate: boolean }>`
  position: absolute;
  top: -25%;
  left: -50%;
  width: 100%;
  height: 100%;
  transform: rotate(${({ rotation }) => rotation}deg);
  transition: ${({ isAnimate }) => (isAnimate ? "1s" : "0s")};
`;

const BigCircle = styled.div`
  width: 100%;
  height: 100%;
  position: relative;
`;

const Circles = styled.div`
  position: absolute;
  display: flex;
  width: 100%;
  gap: 1rem;
  top: 43%;
  left: 80%;
`;

const CircleTyleArea = styled.div<CircleProp>`
  rotate: ${({ rotation, circleType }) =>
    360 - rotation - circleDegree[circleType]}deg;

  transition: ${({ isAnimate }) => (isAnimate ? "1s" : "0s")};
`;

export default Wheel;

 

 

 

 

 

 

 

 

큰 Dial안의 영역이 바뀌는 것 또한 똑같은 원리를 한번 더 적용해서 만들어주었다.

/** @jsxImportSource @emotion/react */
import React from "react";
import styled, { css } from "styled-components";
import { homeColor } from "styles/color";
import { homeZIdx } from "styles/zIndex";
import { searchType, tmpSelectType } from "types/home";

interface Props {
  searchType: searchType;
  beforeType: searchType;
  afterType: searchType;
  rotateWheelRight: (isturnRight: boolean) => void;
  rotation: number;
  setRotation: React.Dispatch<React.SetStateAction<number>>;
  mainType: tmpSelectType;
  setMainType: React.Dispatch<React.SetStateAction<tmpSelectType>>;
  isAnimate: boolean;
  windowWidth: number;
}

interface TmpSelectTypeProp {
  rotation: number;
  isAnimate: boolean;
  windowWidth: number;
}

const typeMent = {
  views: "조회순",
  popular: "인기순",
  followers: "구독자순",
};

const Dial = ({
  searchType,
  beforeType,
  afterType,
  rotateWheelRight,
  rotation,
  setRotation,
  mainType,
  setMainType,
  isAnimate,
  windowWidth,
}: Props) => {
  const rotateDialRight = (isturnRight: boolean) => {
    setRotation(rotation + (isturnRight ? 60 : -60));
  };

  const clickBeforeType = () => {
    rotateWheelRight(true);
    rotateDialRight(true);
    setMainType("beforeType");
  };

  const clickAfterType = () => {
    rotateWheelRight(false);
    rotateDialRight(false);
    setMainType("afterType");
  };

  const typeObj = {
    searchType,
    beforeType,
    afterType,
  };

  return (
    <Wrapper>
      <DialBtn
        searchType={typeObj[mainType]}
        rotation={rotation - 120}
        isAnimate={isAnimate}
      >
        <TmpSelectType
          rotation={rotation - 120}
          isAnimate={isAnimate}
          windowWidth={windowWidth}
        >
          {typeMent[afterType]}
        </TmpSelectType>
      </DialBtn>
      <DialBtn
        searchType={typeObj[mainType]}
        rotation={rotation - 60}
        isAnimate={isAnimate}
      >
        <DialMenu
          rotation={rotation - 60}
          isSelect={mainType === "beforeType" ? true : false}
          windowWidth={windowWidth}
          searchType={typeObj[mainType]}
          isAnimate={isAnimate}
          onClick={clickBeforeType}
        >
          {typeMent[beforeType]}
        </DialMenu>
      </DialBtn>
      <MainDialBtn
        searchType={typeObj[mainType]}
        rotation={rotation}
        isAnimate={isAnimate}
      >
        <SelectType
          rotation={rotation}
          searchType={typeObj[mainType]}
          windowWidth={windowWidth}
          isTurn={mainType === "searchType" ? false : true}
          isAnimate={isAnimate}
        >
          {typeMent[searchType]}
        </SelectType>
      </MainDialBtn>
      <DialBtn
        searchType={typeObj[mainType]}
        rotation={rotation + 60}
        isAnimate={isAnimate}
      >
        <DialMenu
          rotation={rotation + 60}
          isSelect={mainType === "afterType" ? true : false}
          isAnimate={isAnimate}
          windowWidth={windowWidth}
          searchType={typeObj[mainType]}
          onClick={clickAfterType}
        >
          {typeMent[afterType]}
        </DialMenu>
      </DialBtn>
      <DialBtn
        searchType={typeObj[mainType]}
        rotation={rotation + 120}
        isAnimate={isAnimate}
      >
        <TmpSelectType
          rotation={rotation + 120}
          isAnimate={isAnimate}
          windowWidth={windowWidth}
        >
          {typeMent[beforeType]}
        </TmpSelectType>
      </DialBtn>
    </Wrapper>
  );
};

/* STYLE */
const Wrapper = styled.div``;

const MainTypeCSS = css<{ searchType: searchType; windowWidth: number }>`
  font-weight: 700;
  text-shadow: 3px 4px 4px ${({ searchType }) => homeColor[searchType]};
  font-size: ${({ windowWidth }) => `${windowWidth * 0.06}px`};
`;

const SubTypeCSS = css<{ windowWidth: number }>`
  font-weight: 700;
  color: ${homeColor.subSelectTypeGray};
  font-size: ${({ windowWidth }) => `${windowWidth * 0.03}px`};
`;

const DialBtn = styled.div<{
  searchType: searchType;
  rotation: number;
  isAnimate: boolean;
}>`
  position: absolute;
  left: -25%;
  width: 50%;
  height: 100%;
  transform: rotate(${({ rotation }) => rotation}deg);
  transition: ${({ isAnimate }) => (isAnimate ? "1s" : "0s")};
  pointer-events: none;
  z-index: ${homeZIdx.dial};
`;

const MainDialBtn = styled(DialBtn)`
  border-radius: 9999px;
  box-shadow: 4px 4px 20px 0px ${({ searchType }) => homeColor[searchType]};
`;

const noneSearchType = styled.div<TmpSelectTypeProp>`
  position: absolute;
  cursor: pointer;
  rotate: ${({ rotation }) => 360 - rotation}deg;
  transition: ${({ isAnimate }) => (isAnimate ? "1s" : "0s")};
  right: 5%;

  pointer-events: auto;
  top: calc(50% - ${({ windowWidth }) => `${windowWidth * 0.03}px`});
`;

const TmpSelectType = styled(noneSearchType)`
  ${SubTypeCSS}
`;

const DialMenu = styled(noneSearchType)<{
  isSelect: boolean;
  searchType: searchType;
}>`
  ${({ isSelect }) => (isSelect ? MainTypeCSS : SubTypeCSS)}
`;

const SelectType = styled(noneSearchType)<{
  searchType: searchType;
  isTurn: boolean;
}>`
  ${({ isTurn }) => (isTurn ? SubTypeCSS : MainTypeCSS)}
`;

export default Dial;
728x90