[Next.js] 포트폴리오 웹 페이지 제작기 - 6. 슬롯 머신 이펙트

olwooz·2023년 2월 14일
0

오늘은 Main 섹션에서 '나는 누구인가'를 표현하는 여러가지 키워드에 슬롯 머신 텍스트 효과를 줄 것이다.
TypeIt을 사용한 타이핑 이펙트도 후보로 고려했었지만, 라이브러리가 너무 직관적으로 쉽게 잘 돼있어서 이걸 사용한다고 해서 뭔가 크게 배울 수 있을거라고 생각하지 않기 때문에 사용하지 않기로 했다.

기초 공사

먼저 SlotMachine이라는 컴포넌트를 만들어 About에 넣어준다.

// components/Contents/Main/SlotMachine.tsx

import { useState } from 'react';

interface Props {
  textList: string[];
}

const SlotMachine = ({ textList }: Props) => {
  const [currentText, setCurrentText] = useState('???');
  return (
    <>
      <h1 className="mb-4 text-9xl font-thin">{currentText}</h1>
    </>
  );
};

export default SlotMachine;
// components/Contents/Main/Main.tsx

import ContentWrapper from '../ContentWrapper';
import { textList } from './data';
import SlotMachine from './SlotMachine';

const Main = () => {
  return (
    <ContentWrapper style="flex items-center">
      <div>
        <h3 className="mb-6 text-2xl font-light">안녕하세요.</h3>
        <SlotMachine textList={textList} />
        <h3 className="text-4xl font-black">누구누구입니다.</h3>
      </div>
    </ContentWrapper>
  );
};

export default Main;

샘플 데이터도 만들어준다. (희망사항)

// components/Contents/Main/data.ts

export const textList = ['기가 막힌 개발자', '협업 대상 1순위 개발자', 'ChatGPT가 참고하는 개발자'];

이펙트 구현

어떻게 구현할 지 정말 많은 고민을 했다. 구글 검색을 해서 나온 코드를 참고하려고 했으나, 코드를 뜯어보니 DOM 요소들과 style 요소들을 직접 조작하는 방식으로 코드가 작성되어 있어서 효율성도 떨어지고 코드도 지저분해 보였다.

그것도 그렇고 '내 포트폴리오 웹사이트'인데, 내 코드가 아니라면 의미가 있나 싶어서 직접 부딪히기로 했다.

textList의 요소 하나를 랜덤으로 뽑고, 기존 요소와 새로 선택된 요소가 transition되는 사이에 나머지 요소들을 스쳐지나가듯 보여줘야 한다.
내가 원하는 모습을 구현하려면 아래 조건을 만족해야 한다.

1. 버튼을 클릭할 때마다 다른 요소가 선택된다.

2. 버튼을 클릭하면 선택된 요소가 보이기 전에 배열의 나머지 요소들을 빠르게 보여준다.

가장 관건은 transition animation이니까 먼저 구현해보도록 하겠다.
Framer Motion을 사용하고 싶었다.

// components/Contents/Main/SlotMachine.tsx

import { useEffect, useState } from 'react';
import { AnimatePresence, motion, Variants } from 'framer-motion';
import ShuffleIcon from '@/components/Icons/shuffle';

interface Props {
  textList: string[];
}

interface VariantProps {
  scaleY: number;
  y: string | number;
  opacity: number;
  filter?: string;
}

const SlotMachine = ({ textList }: Props) => {
  const t = [...textList, ...textList, ...textList, ...textList];
  const [textListIndex, setTextListIndex] = useState(0);
  const buttonColor = 'rgb(75, 85, 99)';

  useEffect(() => {
    const interval = setInterval(() => {
      setTextListIndex((prev) => {
        return prev < t.length - 1 ? prev + 1 : prev;
      });
    }, getDuration(10, textListIndex));

    return () => clearInterval(interval);
  }, [textListIndex, t.length]);

  const variants: Variants = {
    initial: { scaleY: 0.3, y: '-50%', opacity: 0 },
    animate: ({ isLast }) => {
      let props: VariantProps = { scaleY: 1, y: 0, opacity: 1 };
      if (!isLast) props['filter'] = 'blur(1.5px)';

      return props;
    },
    exit: { scaleY: 0.3, y: '50%', opacity: 0 },
  };

  function handleClick() {
    setTextListIndex((prev) => (prev + 1) % t.length);
  }

  function getDuration(base: number, index: number) {
    return base * (index + 1) * 0.5;
  }

  return (
    <div className="flex justify-between">
      <AnimatePresence mode="popLayout">
        {t.map((item, i) => {
          const isLast = i === t.length - 1;

          return (
            i === textListIndex && (
              <motion.p
                className="overflow-hidden text-7xl font-thin"
                key={item}
                custom={{ isLast }}
                variants={variants}
                initial="initial"
                animate="animate"
                exit="exit"
                transition={{ duration: getDuration(isLast ? 0.1 : 0.01, i), ease: isLast ? 'easeInOut' : 'linear' }}
              >
                {item}
              </motion.p>
            )
          );
        })}
      </AnimatePresence>
      <motion.button className="mr-[650px]" onClick={handleClick} whileTap={{ scale: 0.9, scaleY: 1 }} whileHover={{ scaleY: -1 }}>
        <ShuffleIcon fill={buttonColor} />
      </motion.button>
    </div>
  );
};

export default SlotMachine;

모션이 심심해 보여서 배열의 길이를 임의로 늘려줬다.
우선 랜덤 선택 기능을 빼고 효과를 먼저 구현했다.
textListIndex를 일정 시간마다 늘려주면서 마지막 인덱스에 도달하면 멈추게 한다.
scaleYtranslateY를 조절해 텍스트들이 동그란 면 위에서 돌아가는 듯한 느낌을 줬다.
마지막 인덱스가 아니라면 duration을 짧게 하고 filter: blur를 줘서 더 생동감있게 만들었다.
그리고 인덱스에 비례하게 duration을 늘려서 더욱 부드러운 이펙트를 만들어줬다.
마지막 인덱스는 duration을 정해진 비율보다 더 늘려서, 선택된 요소가 보여지는 순간의 느낌을 보강했다.

랜덤 선택 기능은 transition animation 처리를 위해서 배열의 특정 요소를 맨 뒤로 보내는 것으로 정했는데, 이 덕분에 매번 다른 요소를 보여주는 게 더 쉬워졌다.
count와 같은 state를 0으로 초기화하고, shuffle 버튼을 누를 때마다 count를 증가시키면서, 다음 선택은 배열의 길이 - count - 1 에서 멈추면 된다.

// components/Contents/Main/SlotMachine.tsx

import { useEffect, useState } from 'react';
import { AnimatePresence, motion, Variants } from 'framer-motion';
import ShuffleIcon from '@/components/Icons/shuffle';

interface Props {
  textData: string[];
}

interface VariantProps {
  scaleY: number;
  y: string | number;
  opacity: number;
  filter?: string;
}

const BUTTON_COLOR = 'rgb(75, 85, 99)';
const ARRAY_REPEAT = 5;

const SlotMachine = ({ textData }: Props) => {
  const [count, setCount] = useState(0);
  const [currentIndex, setCurrentIndex] = useState(0);
  const textArr = Array(ARRAY_REPEAT).fill(textData).flat();
  const lastIndex = textArr.length - 1 - count;

  useEffect(() => {
    const interval = setInterval(() => {
      setCurrentIndex((prev) => {
        return prev < lastIndex ? prev + 1 : prev;
      });
    }, getDuration(10, currentIndex));

    return () => clearInterval(interval);
  }, [currentIndex, lastIndex, count]);

  const variants: Variants = {
    initial: { scaleY: 0.3, y: '-50%', opacity: 0 },
    animate: ({ isLast }) => {
      let props: VariantProps = { scaleY: 1, y: 0, opacity: 1 };
      if (!isLast) props['filter'] = 'blur(1.5px)';

      return props;
    },
    exit: { scaleY: 0.3, y: '50%', opacity: 0 },
  };

  function handleClick() {
    setCurrentIndex(0);
    setCount((prev) => {
      return prev < textData.length - 1 ? prev + 1 : 0;
    });
  }

  function getDuration(base: number, index: number) {
    return base * (index + 1) * 0.5;
  }

  return (
    <div className="flex justify-between">
      <AnimatePresence mode="popLayout">
        {textArr.map((text, i) => {
          const isLast = i === lastIndex;

          return (
            i === currentIndex && (
              <motion.p
                className="overflow-hidden text-7xl font-thin"
                key={text}
                custom={{ isLast }}
                variants={variants}
                initial="initial"
                animate="animate"
                exit="exit"
                transition={{ duration: getDuration(isLast ? 0.1 : 0.01, i), ease: isLast ? 'easeInOut' : 'linear' }}
              >
                {text}
              </motion.p>
            )
          );
        })}
      </AnimatePresence>
      <motion.button className="mr-[650px]" onClick={handleClick} whileTap={{ scale: 0.9, scaleY: 1 }} whileHover={{ scaleY: -1 }}>
        <ShuffleIcon fill={BUTTON_COLOR} />
      </motion.button>
    </div>
  );
};

export default SlotMachine;

구현하면서 변수명도 적절하게 수정했고, 자주 쓰는 식들을 상수로 변경해서 코드를 조금이나마 더 깔끔하게 만들었다.

결과

타협하지 않고 내가 생각했던 대로 만들기 위해서 시간은 어느 정도 걸렸지만 결과물이 잘 나와서 뿌듯했다. 이제 큰 산들은 얼추 넘어간 것 같다.
이렇게 텍스트에 슬롯머신 효과를 부여해주는 라이브러리는 못 찾았는데, 포트폴리오 개발이 완료되면 이걸 조금 더 다듬어서 라이브러리를 직접 하나 만들어봐야겠다.

0개의 댓글