[FIFAPulse] 개발기록 - 스크롤 이벤트 + 애니메이션 진행하기

조민호·2023년 5월 15일
0
post-thumbnail

최초에 로그인을 완료하게 되면 메인 메뉴로 들어가게 된다

메인 메뉴는 아래의 4개로 구성되어 있다

  • 내 기록
  • 선수 포지션 추천 가이드
  • 다른 유저 기록 검색하기
  • 챌린지

여기서 메인 메뉴를 어떤 디자인으로 보여줄지 고민을 하다가

최근에 스터디에서 만나게 된 팀원의 프로젝트를 보고 정말 이쁘다고 생각이 들어서

그 분의 디자인을 참고했다

(개인적으로 정말 존경하시는 분이고 프로젝트 자체의 완성도 또한 매우 높으므로 한번씩 봐주세요!)

https://github.com/soohyun-dev/Randomly




React Awesome Reveal 으로 애니메이션 사용하기

리액트에서 간단한 애니메이션을 제공해주는 라이브러리 이다

그렇다고 부트스트랩처럼 컴포넌트 전체에 대해 디자인을 제공해 주는 것이 아니라

말 그대로 애니메이션만 제공해주는 것 같다

원래 JS로 개발할 때는 React Reveal을 사용하면 되지만 이건 TS를 지원하지 않기에
React Awesome Reveal 을 사용해야 한다

공식 문서를 보게 되면 여기서 기본적으로 제공해주는 애니메이션들이 존재한다

여기서 내가 사용할 옵션은 slide 이다

사용 방법 자체는 매우매우매우 간단하다

  • 모듈을 install한 다음
    npm install react-awesome-reveal @emotion/react
  • import만 해서 애니메이션을 적용하고자 하는 요소를 감싸기만 하면 된다
    <Slide triggerOnce>
              <Link to="my-record">내 기록</Link>
    </Slide>

이제 Slide 가 적용된 부분은 슬라이드 형태로 애니메이션이 적용된 형태로 등장하게 된다




실제 프로젝트에 스크롤 이벤트와 함께 적용하기

스크롤 이벤트 기본 개념

우선 , 리액트에서 스크롤 이벤트를 걸기 위해 기본적인 개념부터 짚고 넘어가자면

DOM요소에 접근을 해야 할 것 같았고 , 가장 먼저 생각났던 useRef를 사용할 수 없었다

  • useRef를 이용해서 DOM에 접근 후 ref.current.addEventListender 이용해서 스크롤 이벤트 적용

    addEventListender property를 찾을 수 없음

  • useRef를 이용해서 DOM에 접근 후 ref.current.scrollY 로 해당 요소의 스크롤 위치 찾기

    undefined값만 조회 됨


그러므로 다른 방법을 사용해야 한다

구글링을 해보니 다들 아래와 같은 방식으로 진행하고 있었다

import React, { useState, useEffect } from 'react';

const MyComponent = () => {
  const [show, setShow] = useState(false);

	// 스크롤 지점에 따른 상태 변경
  const checkScroll = () => {
    if (window.pageYOffset > 400) {
      setShow(true);
    } else {
      setShow(false);
    }
  };

	// 이벤트 리스터 할당
  useEffect(() => {
    window.addEventListener('scroll', checkScroll);
    return () => window.removeEventListener('scroll', checkScroll);
  }, []);

  return (
    <Div show={show}>Hello, World!</Div>
  );
};

export default MyComponent;
import styled from 'styled-components';

const Div = styled.div`
	display: ${(props) => (props.show? 'block' : 'none')};
`

위의 코드는 최초에는 div요소가 없다가 특정 스크롤 지점에 다다르면

div요소가 보여지는 것이다

  • window.pageYOffset은 브라우저의 window 객체에서 제공하는 속성으로, 문서가 수직으로 얼마나 스크롤되었는지를 픽셀 단위로 반환해준다

    예를 들어, 페이지의 최상단에 있을 때 window.pageYOffset 값은 0이며, 사용자가 페이지를 아래로 스크롤하면 그만큼 값이 증가한다
    그러므로 이걸 이용해서 특정 스크롤 위치에 다다르면
    styled-components로 넘겨주는 props값에 따라 조건부로 display속성을 변경해서
    특정 컴포넌트를 보여주는 것이다

  • 다만 위의 기능을 이용하려면 반드시 window 전역 객체에 스크롤 이벤트를 할당해 줘야 한다

    • 그러므로 화면이 최초에 실행 될 때 window.addEventListener('scroll', checkScroll); 로 이벤트를 할당해주고
    • useEffect의 뒷정리 함수를 사용해서

      컴포넌트가 삭제되기 직전(=언마운트)이나 업데이트되기 직전에 어떤 작업을 수행하고 싶다면 useEffect에다가 함수를 반환해주면 된다 (=클린업 함수 or 뒷정리 함수)

      해당 화면을 나가게 될 때 , 이벤트 할당을 제거해 줌으로써, 불필요한 메모리 사용을 방지하고, 여러번의 이벤트 설정을 막는 것이다


프로젝트에 적용

직접 내 프로젝트에 적용한 코드의 일부이다

import React, { useEffect, useState } from 'react';
import { signOut } from 'firebase/auth';
import { Fade, Slide } from 'react-awesome-reveal';
import { useNavigate, Link } from 'react-router-dom';
import { ChallengeDiv, MainHeading, MyRecordDiv, PositionGuideDiv, UserRecordDiv } from './MainSelect.styled';
import { authService, dbService } from '../../../firebase';
import FIFAData from '../../Services/FifaData';

const MainSelect = () => {
  const [slideInfo, setSlideInfo] = useState({
    heading: true,
    myRecord: false,
    positionGuide: false,
    userRecord: false,
    gameChallenge: false,
  });

  const checkScroll = () => {
    console.log(window.pageYOffset);

    if (window.pageYOffset < 200) {
      setSlideInfo({
        heading: true,
        myRecord: false,
        positionGuide: false,
        userRecord: false,
        gameChallenge: false,
      });
    }
    if (window.pageYOffset >= 200) {
      setSlideInfo((prev) => {
        return { ...prev, myRecord: true };
      });
    }
    if (window.pageYOffset >= 700) {
      setSlideInfo((prev) => {
        return { ...prev, positionGuide: true };
      });
    }

    if (window.pageYOffset >= 1200) {
      setSlideInfo((prev) => {
        return { ...prev, userRecord: true };
      });
    }
    if (window.pageYOffset >= 1400) {
      setSlideInfo((prev) => {
        return { ...prev, gameChallenge: true };
      });
    }
  };

	...

  useEffect(() => {
    window.addEventListener('scroll', checkScroll);
    return () => window.removeEventListener('scroll', checkScroll);
  }, [slideInfo]);

  return (
    <div style={{ height: '3000px' }}>
      <MainHeading> 메인 소개 문구 </MainHeading>

      <MyRecordDiv myRecord={slideInfo.myRecord}>
        <Slide triggerOnce>
          <Link to="my-record">내 기록</Link>
        </Slide>
      </MyRecordDiv>
      <PositionGuideDiv positionGuide={slideInfo.positionGuide}>
        <Slide triggerOnce>
          <Link to="position-guide">선수 포지션 추천 가이드</Link>
        </Slide>
      </PositionGuideDiv>
      <UserRecordDiv userRecord={slideInfo.userRecord}>
        <Slide triggerOnce>
          <Link to="user-record">다른 유저 검색하기</Link>
        </Slide>
      </UserRecordDiv>
      <ChallengeDiv gameChallenge={slideInfo.gameChallenge}>
        <Slide triggerOnce>
          <Link to="challenge">챌린지</Link>
        </Slide>
      </ChallengeDiv>
      <button type="button" onClick={onLogoutClick}>
        Log out
      </button>
    </div>
  );
};

export default MainSelect;
import styled from 'styled-components';

export const MainHeading = styled.h1`
  width: 100%;
  height: 600px;
  border: 1px solid black;
`;

type MyRecordSlideProps = {
  myRecord: boolean;
};
export const MyRecordDiv = styled.div<MyRecordSlideProps>`
  display: ${(props) => (props.myRecord ? 'block' : 'none')};
  width: 100%;
  height: 500px;
`;

type PositionGuideSlideProps = {
  positionGuide: boolean;
};
export const PositionGuideDiv = styled.div<PositionGuideSlideProps>`
  display: ${(props) => (props.positionGuide ? 'block' : 'none')};
  width: 100%;
  height: 500px;
`;

type UserRecordSlideProps = {
  userRecord: boolean;
};
export const UserRecordDiv = styled.div<UserRecordSlideProps>`
  display: ${(props) => (props.userRecord ? 'block' : 'none')};
  width: 100%;
  height: 500px;
`;

type ChallengeSliceProps = {
  gameChallenge: boolean;
};

export const ChallengeDiv = styled.div<ChallengeSliceProps>`
  display: ${(props) => (props.gameChallenge ? 'block' : 'none')};
  width: 100%;
  height: 500px;
`;

각 메뉴들을 보여줄지 아닐지에 대한 상태는 하나의 객체로 두고 사용한다

styled-components의 props를 와 display속성을 이용해서

값이 true면 보여주는 것이고 false면 보여주지 않게 된다

{
    heading: true, // 메인 문구
    myRecord: false, // 내 기록
    positionGuide: false, // 선수 포지션 추천 가이드
    userRecord: false, // 다른 유저 기록 검색하기
    gameChallenge: false, // 챌린지
  }
export const MyRecordDiv = styled.div<MyRecordSlideProps>`
  display: ${(props) => (props.myRecord ? 'block' : 'none')};
  width: 100%;
  height: 500px;
`;

그리고 스크롤이 되는 각 px값에 대해 조건부터

차례로 해당 상태값들을 변경해 주는 것이다

  • 200 이상부턴 myRecord를 보여주고
  • 700 이상부턴 myRecord , positionGuide를 보여주고

이렇게 하면 메인 메뉴 페이지에 들어서게 되면 맨 위에 메인 문구가 존재하고( 메인 문구는 고정 )

아래로 스크롤 할 때마다 각 메뉴들이 슬라이드 애니메이션 형태로 나타나게 되는 것이다




더 나아가기


위로 올라올때는 애니메이션 적용하지 않기

현재 상태는 스크롤을 아래로 할 때는 애니메이션이 적용이 됐지만 위로 다시 올라갈때도 애니메이션이 적용 되고 있다

아무래도 slideInfo 상태를 스크롤할 때 일정 구간에서 계속 업데이트를하고 있으니까

화면이 리렌더링 되면서 다시 Slide 애니메이션이 작동되기 때문인 것 같다

그렇지만 나는 아래로 스크롤 할 때만 애니메이션이 적용되고

올라올때는 애니메이션이 적용되지 않는 것을 원했다

내려갈 때 ,올라올 때 둘다 애니메이션이 적용되면 UX면에서 좀 피로함을 느낄 수 있을 것 같았다

그래서 React Awesome Reveal 공식문서에서 제공하는 triggerOnce옵션을 적용했다

triggerOnce
Boolean prop that determines if the animation should run only once or everytime an element enters/exits/re-enters the viewport.
Defaults to false.

(공식문서의 설명에는 정확히 언급하지 않았지만 아마 리렌더링이 될 때도 기존에 존재하는 요소의 애니메이션은 오로지 최초에 1번만 실행하는 것 같다)

 <MyRecordDiv myRecord={slideInfo.myRecord}>
        <Slide **triggerOnce**> {/* triggerOnce 적용*/}
          <Link to="my-record">내 기록</Link>
        </Slide>
</MyRecordDiv>

이렇게 하면 해당 애니메이션이 최초에 한번만 실행되므로 제대로 된 것 같았다

근데 여기서 문제가 있는게

  • 최초에 아래로 스크롤 하면 애니메이션이 제대로 적용되고
  • 다시 올릴때는 애니메이션이 적용되지 않고 그대로 있는다

그렇지만 위로 올라온 상태에서 다시 아래로 스크롤을 할 때는 애니메이션이 적용되지 않았다!

즉, 모든 애니메이션이 그냥 1회용이 되어버린 것이다

(triggerOnce 속성을 사용했으므로 당연한 결과이긴 하다)


그러므로 아래와 같이 스크롤을 거의 위로 올렸을 때

상태값들을 다시 false로 바꿔버린다

  if (window.pageYOffset < 200) {
      setSlideInfo({
        heading: true,
        myRecord: false,
        positionGuide: false,
        userRecord: false,
        gameChallenge: false,
      });
    }

그러면 styled-components에서 사용하는 props에 의해 display가 none이 되어 요소가 아예 사라져버리게 되고,

다시 스크롤을 아래로 내려서 true값이 되어서 display가 block이 되면 , 이 때는 요소가 아예 없어졌다가 완전히 새로 생성된 것이므로 slide이벤트가 제대로 다시 적용된다

export const MyRecordDiv = styled.div<MyRecordSlideProps>`
  display: ${(props) => (props.myRecord ? 'block' : 'none')};
  width: 100%;
  height: 18%;
`;
  • 즉 , 위로 올릴때는 triggerOnce를 통해 애니메이션을 다시 실행하지 않고

  • 맨 위로 올리게 되면 아예 요소를 죄다 지워버리고, 다시 내리게 될 때 요소를 다시 새로 생성해서 최초 애니메이션이 다시 적용되는 것이다


animationPoint를 픽셀값 대신 비율로 설정하기


픽셀 자체를 지정해서 특정 픽셀 이상 내려가면 동작을 수행하는 것 보다 뭔가 비율로 지정하는 것이 반응형에 적합해 보였다

그렇지만 window.pageYOffset의 값은 오로지 픽셀값만 제공한다

그래서 적용한 개념은 아래와 같다

const viewportHeight = window.innerHeight; // 뷰포트의 높이를 픽셀 단위로 가져옴
const scrollPosition = window.pageYOffset;

// 뷰포트 높이의 50% 이상으로 스크롤되었을 경우
if ((scrollPosition / viewportHeight) > 0.5) {
	...
}
import React, { useEffect, useState } from 'react';
import { signOut } from 'firebase/auth';
import { Fade, Slide } from 'react-awesome-reveal';
import { useNavigate, Link } from 'react-router-dom';
import { ChallengeDiv, MainMenuDescriptionDiv, MainSelectContainerDiv, MyRecordDiv, PositionGuideDiv, UserRecordDiv } from './MainSelect.styled';
import { authService, dbService } from '../../../firebase';
import FIFAData from '../../Services/FifaData';

const MainSelect = () => {
	
	**// 전체 높이px값 지정**
	const ELEMENT_HEIGHT = 3000;

  const [slideInfo, setSlideInfo] = useState({
    heading: true,
    myRecord: false,
    positionGuide: false,
    userRecord: false,
    gameChallenge: false,
  });

  const checkScroll = () => {

		**// 해당 페이지 전체 높이는 3000px**
    const elementHeight = ELEMENT_HEIGHT; 
    const scrollPosition = window.pageYOffset;
    const animationPoint = Number((scrollPosition / elementHeight).toFixed(2));

	  
		**// animationPoint 적용 (5% 미만으로 스크롤 됐을 때)**
    if (animationPoint < 0.05) {
      setSlideInfo({
        heading: true,
        myRecord: false,
        positionGuide: false,
        userRecord: false,
        gameChallenge: false,
      });
    }

		**// animationPoint 적용 (5% 이상 스크롤 됐을 때)**
    if (animationPoint >= 0.05) {
      setSlideInfo((prev) => {
        return { ...prev, myRecord: true };
      });
    }

		**// animationPoint 적용 (23% 이상 스크롤 됐을 때)**
    if (animationPoint >= 0.23) {
      setSlideInfo((prev) => {
        return { ...prev, positionGuide: true };
      });
    }

		**// animationPoint 적용 (41% 이상 스크롤 됐을 때)**
    if (animationPoint >= 0.41) {
      setSlideInfo((prev) => {
        return { ...prev, userRecord: true };
      });
    }

		**// animationPoint 적용 (59% 이상 스크롤 됐을 때)**
    if (animationPoint >= 0.59) {
      setSlideInfo((prev) => {
        return { ...prev, gameChallenge: true };
      });
    }
  };

	...

  useEffect(() => {
    window.addEventListener('scroll', checkScroll); // 컴포넌트 최초 렌더링 후 작동
    return () => window.removeEventListener('scroll', checkScroll); // 컴포넌트가 언마운트 될 때 작동
  }, []);

  return (
    <div style={{ height: `${ELEMENT_HEIGHT}px` }}>
      <MainMenuDescriptionDiv>
        <h1>메인 소개 문구 </h1>
      </MainMenuDescriptionDiv>

      <MyRecordDiv myRecord={slideInfo.myRecord}>
        <Slide triggerOnce>
          <Link to="my-record">내 기록</Link>
        </Slide>
      </MyRecordDiv>
      <PositionGuideDiv positionGuide={slideInfo.positionGuide}>
        <Slide triggerOnce>
          <Link to="position-guide">선수 포지션 추천 가이드</Link>
        </Slide>
      </PositionGuideDiv>
      <UserRecordDiv userRecord={slideInfo.userRecord}>
        <Slide triggerOnce>
          <Link to="user-record">다른 유저 검색하기</Link>
        </Slide>
      </UserRecordDiv>
      <ChallengeDiv gameChallenge={slideInfo.gameChallenge}>
        <Slide triggerOnce>
          <Link to="challenge">챌린지</Link>
        </Slide>
      </ChallengeDiv>
      <button type="button" onClick={onLogoutClick}>
        Log out
      </button>
    </div>
  );
};

export default MainSelect;

각 높이를 px 단위 지정해주고 사용하기


보통은 특정 페이지 높이를 설정하게 될 때 , min-height정도만 지정을 해주고

페이지 안의 컨텐츠의 높이들에 의해 늘어나는대로 사용하지만 (=딱히 높이 지정을 안 하지만)

이 페이지의 경우

  • 전체 높이 ELEMENT_HEIGHT 를 px 단위로 지정하고
  • 각 메뉴 섹션의 높이 또한 px로 사용했다

    각 메뉴섹션의 높이들의 합 + 조금의 여유 공간을 계산해서
    ELEMENT_HEIGHT 값으로 사용하는 것이다

  • 그리고 스크롤 이벤트가 발생하는 각 지점 역시 ELEMENT_HEIGHT를 기반으로 계산해서 사용한다

왜 이렇게 했을까?

스크롤 이벤트가 발생하는 지점을 계산하기 위해서는 당연히 전체 높이를 기준으로

각 지점들을 계산해서 지정해야 한다

그래서 현재 브라우저 뷰포트의 높이를 알려주는 window.innerHeight는 전체화면의 경우

951픽셀로 고정된 값만 나오게 된다


그렇지만 , 이 페이지는 스크롤 통해 훨씬 더 큰 높이를 사용하고 있다

알다시피 나머지 보여지지 않는 메뉴들은 display:none 으로 되어 있고 동적으로

각 메뉴가 생성되면서 전체 높이가 실시간으로 늘어나게 되는 구조라 window.innerHeight로

전체 높이를 가늠할 수 없다

그러므로 대신 직접 해당 페이지의 전체 높이로 설정한 값을 사용해야 했다


주의 사항

현재 div 태그에 props에 기반해서 display를

block이나 none으로 설정했다

<MyRecordDiv myRecord={slideInfo.myRecord}>
		<Slide triggerOnce>
	    <Link to="my-record">내 기록</Link>
		</Slide>
</MyRecordDiv>
export const MyRecordDiv = styled.div<MyRecordSlideProps>`
  display: ${(props) => (props.myRecord ? 'block' : 'none')};
  width: 100%;
  height: 18%;
`;

근데 만약 , 아래와 같이 react-awesome-reaveal의 애니메이션 컴포넌트를 최상단으로 빼고 , 그 아래에 MyRecordDiv 를 넣어주면 문제가 발생한다

<Slide triggerOnce>
		<MyRecordDiv myRecord={slideInfo.myRecord}>
		<Link to="my-record">내 기록</Link>
		</MyRecordDiv>
</Slide>

최초에 화면에 렌더링 될 때 , 스크롤이 최상단에 있고slideInfo.myRecord가 false임에도 불구하고 자동으로 Slide 애니메이션이 적용된 MyRecordDiv가 나타나는 문제인 것이다

  • JSX구조를 보면 Slide 컴포넌트가 상위에 있다

    그러므로 렌더링시에 react-awesome-reveal의 Slide 컴포넌트가 먼저 렌더링되므로

    애니메이션이 바로 작동이 되는 것이고

  • 그리고 이 때 MyRecordDiv는 자식 요소로 들어가 있다

    그러므로 애니메이션이 진행되면서 그 안에 있는 MyRecordDiv는

    display: none 속성에 영향을 받지 않고 애니메이션과 함께 보여지게 되는 것이다

    즉, 상위에 있는 react-awesome-reveal의 Slide 컴포넌트가 바로 실행이 되는 것이고

    이때 동시에 자식요소로 있는 MyRecordDiv 가 display:none 이더라도 이와 관계없이

    같이 렌더링이 되는 것이다

    사실, Slide가 상위에 있으므로 바로 애니메이션이 적용 되는 것은 알겠는데
    왜 자식 요소의 display 속성이 none 이더라도 같이 화면에 보여지는 것인지는 그 이유를 모르겠다…
    react-awesome-reveal 애니메이션은 요소의 렌더링과 독립적으로 동작하므로
    display: none이 설정된 요소에도 애니메이션을 적용될 수 있는 것 같긴한데…

profile
할 수 있다

1개의 댓글

comment-user-thumbnail
2023년 5월 19일

잘 보고 갑니다 ㅎ

답글 달기