최초에 로그인을 완료하게 되면 메인 메뉴로 들어가게 된다
메인 메뉴는 아래의 4개로 구성되어 있다
여기서 메인 메뉴를 어떤 디자인으로 보여줄지 고민을 하다가
최근에 스터디에서 만나게 된 팀원의 프로젝트를 보고 정말 이쁘다고 생각이 들어서
그 분의 디자인을 참고했다
(개인적으로 정말 존경하시는 분이고 프로젝트 자체의 완성도 또한 매우 높으므로 한번씩 봐주세요!)
https://github.com/soohyun-dev/Randomly
리액트에서 간단한 애니메이션을 제공해주는 라이브러리 이다
그렇다고 부트스트랩처럼 컴포넌트 전체에 대해 디자인을 제공해 주는 것이 아니라
말 그대로 애니메이션만 제공해주는 것 같다
원래 JS로 개발할 때는 React Reveal을 사용하면 되지만 이건 TS를 지원하지 않기에
React Awesome Reveal 을 사용해야 한다
공식 문서를 보게 되면 여기서 기본적으로 제공해주는 애니메이션들이 존재한다
여기서 내가 사용할 옵션은 slide 이다
사용 방법 자체는 매우매우매우 간단하다
npm install react-awesome-reveal @emotion/react
<Slide triggerOnce>
<Link to="my-record">내 기록</Link>
</Slide>
이제 Slide 가 적용된 부분은 슬라이드 형태로 애니메이션이 적용된 형태로 등장하게 된다
우선 , 리액트에서 스크롤 이벤트를 걸기 위해 기본적인 개념부터 짚고 넘어가자면
DOM요소에 접근을 해야 할 것 같았고 , 가장 먼저 생각났던 useRef를 사용할 수 없었다
addEventListender property를 찾을 수 없음
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 전역 객체에 스크롤 이벤트를 할당해 줘야 한다
해당 화면을 나가게 될 때 , 이벤트 할당을 제거해 줌으로써, 불필요한 메모리 사용을 방지하고, 여러번의 이벤트 설정을 막는 것이다컴포넌트가 삭제되기 직전(=언마운트)이나 업데이트되기 직전에 어떤 작업을 수행하고 싶다면 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값에 대해 조건부터
차례로 해당 상태값들을 변경해 주는 것이다
이렇게 하면 메인 메뉴 페이지에 들어서게 되면 맨 위에 메인 문구가 존재하고( 메인 문구는 고정 )
아래로 스크롤 할 때마다 각 메뉴들이 슬라이드 애니메이션 형태로 나타나게 되는 것이다
현재 상태는 스크롤을 아래로 할 때는 애니메이션이 적용이 됐지만 위로 다시 올라갈때도 애니메이션이 적용 되고 있다
아무래도 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를 통해 애니메이션을 다시 실행하지 않고
맨 위로 올리게 되면 아예 요소를 죄다 지워버리고, 다시 내리게 될 때 요소를 다시 새로 생성해서 최초 애니메이션이 다시 적용되는 것이다
픽셀 자체를 지정해서 특정 픽셀 이상 내려가면 동작을 수행하는 것 보다 뭔가 비율로 지정하는 것이 반응형에 적합해 보였다
그렇지만 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;
보통은 특정 페이지 높이를 설정하게 될 때 , min-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이 설정된 요소에도 애니메이션을 적용될 수 있는 것 같긴한데…
잘 보고 갑니다 ㅎ