원페이지 개념이라 스크롤이 좀 길어지다 보니 스크롤을 맨 위로 올리는 top 버튼이나 목차 컴포넌트가 필요했다.
처음에는 간단하게 top버튼을 만들까 했는데 그것보다는 목차 컴포넌트를 만드는 게 뭔가 더 포멀 한 느낌을 줄 것 같아 목차 컴포넌트를 만들기로 결정했다.
각 목차는 <a>
태그로 감싸고, 이동할 컴포넌트에는 id를 달아줘서 <a>
태그가 해당 id를 가진 섹션으로 이동할 수 있게 만들었다.
이동하는 것 까지는 완성했는데 문제는 스크롤이 머무는 해당 섹션의 스타일을 다르게 표현하고 싶었는데 도저히 어떻게 해야 할지 감이 안 왔다. 자주 보는 블로그 사이트 velog에도 ToC컴포넌트가 있어서 뜯어보려고 개발자 도구를 열어서 봤는데 그것만으로는 알 수가 없었다. 다행히도(?) 개발자이신 velopert님이 오픈소스로 velog코드를 깃에 올려주셔서 뜯어볼 수 있었다. 거기에서 아이디어를 얻어서 각 섹션이 렌더링 될 때 섹션의 위치를 저장한 다음에, ToC컴포넌트에서는 스크롤이 이동할 때마다 현재 스크롤의 위치와 각 섹션의 위치를 비교해서 현재 스크롤이 어느 섹션에 머물러있는지 active state에 넣어주는 식으로 구현했다.
섹션의 위치 값을 저장해서 ToC컴포넌트에 해서 해야했는데, 고작 이 기능 하나 쓰자고 redux를 쓰는 것도 애매해서 useContext를 써보기로 했다.
context는 src/context/toc.js파일을 만들어주고, 아래와 같이 작성하고 각 섹션의 시작 위치를 가질 pos라는 이름의 state를 만들어준다.
import React from 'react';
import { createContext, useState } from 'react';
const ToCContext = createContext([{}, () => {}]);
const ToCProvider = ({children}) => {
const [pos, setPos] = useState({
profile: 0,
project: 0,
skills: 0,
education: 0
});
return (
<ToCContext.Provider value = {[pos, setPos]}>{children}</ToCContext.Provider>
);
};
const { Consumer: ToCConsumer } = ToCContext;
export { ToCProvider, ToCConsumer };
export default ToCContext;
그리고 context를 사용하려는 컴포넌트 바깥에서 Provider로 감싸준다.
(...)
import { ToCProvider } from 'context/toc';
const cx = classNames.bind(styles);
const Template = () => {
return(
<div className = {cx('Template')}>
<ToCProvider>
<Header/>
<main>
<Info/>
<div className = {cx('sections')}>
<Project/>
<Skills/>
<Education/>
</div>
</main>
<Footer/>
</ToCProvider>
</div>
)
}
export default Template;
위치 값이 필요한 컴포넌트는 Header > ToCComponent, Project, Skills, Education 컴포넌트이다.
Project, Skills, Education 컴포넌트에서는 렌더링 될 때 시작 위치를 context의 state로 넣어준다.
아래는 Project 컴포넌트의 예시이다. 요소에 useRef를 사용하여 reference를 달아주고, getBoundingClientRect() 메서드로 top값을 얻어온 다음에 window.pageYOffset 값과 더해준다. 그러면 위치의 절댓값을 얻을 수 있다.
element.getBoundingClientRect(). top만 사용할 경우 Viewport의 시작지점을 기준으로 한 상대 좌표이기 때문에 값이 정확하지 않다. 나는 컴포넌트가 마운트 될 때 pos state에 저장되도록 했으므로, 어느 스크롤 위치 지점에서 새로고침 하느냐에 따라 좌표가 계속 달라진다. 따라서 아래 코드를 이용해서 절댓값을 구해주었다. window.pageYOffset 은 전체 페이지에서 얼만큼 scroll 됐는지 픽셀 단위를 알려주는 값이다.
const absoluteTope = window.pageYOffset + element.getBoundingClientRect(). top;
import React, { useRef, useEffect, useContext } from "react";
import styles from "./Project.scss";
import classNames from "classnames/bind";
import ProjectImg from "./ProjectImg";
import ToCContext from "context/toc";
const cx = classNames.bind(styles);
const Project = ({ projectList }) => {
const element = useRef(null);
const [pos, setPos] = useContext(ToCContext);
useEffect(() => {
const rect = element.current.getBoundingClientRect();
setPos((pos) => ({
...pos,
[element.current.id]: rect.top + window.pageYOffset,
}));
}, [setPos]);
const list = projectList.map((el, idx) => {
return (
<li
className={cx(
"project " + (idx === projectList.length - 1 ? "no-border" : "")
)}
key={idx}
>
<ProjectImg src={el.src} github={el.github} website={el.website} />
<div className={cx("detail")}>
<div className={cx("inner")}>
<h2>{el.title}</h2>
<div className={cx("content")}>{el.content}</div>
<ul className={cx("li-wrapper")}>
<li>기술스택: {el.techStack}</li>
</ul>
</div>
</div>
</li>
);
});
return (
<section id="project" ref={element} className={cx("Project")}>
<h1>개인 프로젝트</h1>
<ul className={cx("project-list")}>{list}</ul>
</section>
);
};
export default Project;
그리고 ToC컴포넌트에서는 스크롤할 때마다 window.pageYOffset으로 scrollTop위치가 섹션의 시작 포지션에 진입했는지 확인한 다음에, 진입했다면 active state에 index를 넣어줘서 바꿔준다.
import React, { useState, useEffect, useContext, useRef } from "react";
import styles from "./ToC.scss";
import classNames from "classnames/bind";
import ToCContext from "context/toc";
const cx = classNames.bind(styles);
const ToC = ({
list = [
{
name: "🙋♀️ Profile",
href: "profile",
},
{
name: "🐶 Pet Project",
href: "project",
},
{
name: "🔥 Skills",
href: "skills",
},
{
name: "🎓 Education",
href: "education",
},
],
}) => {
const [active, setActive] = useState(0);
const [pos] = useContext(ToCContext);
const element = useRef(null);
useEffect(() => {
const onScroll = () => {
const scrollTop = window.pageYOffset;
if (scrollTop < pos.project) return setActive(0);
if (pos.project <= scrollTop && scrollTop < pos.skills)
return setActive(1);
if (pos.skills <= scrollTop && scrollTop < pos.education)
return setActive(2);
if (pos.education <= scrollTop) return setActive(3);
};
if (pos.project !== 0 && pos.skills !== 0 && pos.education !== 0) {
window.addEventListener("scroll", onScroll);
}
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [pos]);
return (
<div className={cx("ToC")} ref={element}>
<div className={cx("wrapper")}>
<ul>
{list.map((el, idx) => (
<li className={cx(idx === active ? "active" : "")} key={idx}>
<a href={`#${el.href}`}>{el.name}</a>
</li>
))}
</ul>
</div>
</div>
);
};
export default ToC;
education컴포넌트 같은 경우에는 페이지의 맨 아랫부분에 위치해 있어서 scrollTop이 education 컴포넌트의 pos에 다다르지 못하기 때문에 전체 페이지 길이에서 뷰포트만큼 뺀 위치가 scrollTop과 일치한다면 스크롤이 마지막에 다다랐다고 보고 active를 education으로 바꿔주도록 했다.
useEffect (() => {
const rect = element.current.getBoundingClientRect();
setPos(pos => ({
...pos,
[element.current.id] : document.documentElement.offsetHeight - window.innerHeight
}));
}, [setPos]);
구현 모습
아쉬운 건 컴포넌트로서 list를 넘겨주는 것과 active state를 설정하는 데 있어서 현명하지 못하게 구현한 것 같다.
ToC 컴포넌트에 값을 설정하려면 ToC에 가서 list값을 넣어주고, toc 콘텍스트 파일에 가서 pos 값을 설정해줘야 한다. active state도 직접 index값으로 설정해놓은 것도 뭔가 마음에 안 들고... 콘텍스트의 pos값을 아예 object 말고 배열로 만들어서 순서대로 만드는 게 나았을 것 같기도 하다. 그런데 컴포넌트에서 context의 state를 업데이트하려면 자신이 배열에서 몇 번째 인덱스 인지도 알아야 하고... 그래서 아예 key값으로 설정해버리게끔 object로 만들었는데 아예 그냥 context에 list값을 넣어버릴 걸 그랬나? 그리고 컴포넌트에서는 context state에서 pos값 만 바꿔주는 건 그게 더 낫나ㅋㅋㅋㅋㅋprop 왔다 갔다 하는 것도 찬찬히 생각하면서 컴포넌트를 짜야하는데 구현하는데 급급하다 보니 생각의 흐름대로 짜게 되는 것 같다. 미리 습관을 들여놔야 하는데.. 일단 구현은 완료했으니 나중에 리팩터링ㅇ해봐야지
'Project Log > personal website' 카테고리의 다른 글
DarkTheme(다크모드) 적용하기 1편 (feat.useContext) (0) | 2020.03.04 |
---|