멋사가 끝난지 벌써 두 달이 지났고, 7월과 8월을 뭔가 어영부영 보내다가 이대로 가다가는 아무것도 못 하겠다! 라는 생각이 정말 강하게 들어서, 8월부터 (사실 정말 하자! 라고 마음 먹은 건 7월 부터였지만) 팀을 꾸려서 사이드 프로젝트를 진행중이었습니다. 소셜 서비스 프로젝트를 진행중이었는데,이번 주차에는 다음과 같은 디자인을 구현해야 했습니다.
나는 디자인이 싫어요!
카테고리를 분류하는 탭 전환 효과를 만들어야 했는데, 전혀 감이 안 잡혔습니다.
하지만 역시 기능 구현보다는 머리가 덜 깨지겠다는 생각에, 일단 다시 차분하게 생각해 봤습니다.
근데 사실 디자인이 싫어지면 기능 구현이 하고 싶고, 기능 구현이 싫어지면 디자인이 하고 싶어지는 매직
위 탭 메뉴는 다음과 같은 요구사항을 만족해야 합니다.
CategorySwitcher
가 resize 될 경우 주황색 바의 크기도 resize 되어야 함아, CategorySwitcher
에 대한 설명을 안 했는데, 이왕 말이 나온 김에 대략적인 컴포넌트 구조가 어떻게 되어 있는지 볼까요?
먼저 이 페이지 컴포넌트가 return하는 JSX 요소는 다음과 같은데, Container 내부는 크게 SearchBar
, CategorySwitcher
와 renderComponentByCategory
로 나뉩니다.
<>
<h1 className="a11y-hidden">검색 페이지/상세</h1>
<Container>
<SearchBar className="search-bar" />
<CategorySwitcher>
<button
ref={postButtonRef}
className="category-post"
onClick={(e) => handleButtonClick('post', e)}
>
게시글
</button>
<button
className="category-folder"
onClick={(e) => handleButtonClick('folder', e)}
>
폴더
</button>
<button
className="category-account"
onClick={(e) => handleButtonClick('account', e)}
>
계정
</button>
<button
className="category-group"
onClick={(e) => handleButtonClick('group', e)}
>
그룹
</button>
<button
className="category-tag"
onClick={(e) => handleButtonClick('tag', e)}
>
태그
</button>
<IndicatorBar width={barWidth} left={barPosition} />
<BackgroundBar />
</CategorySwitcher>
{renderComponentByCategory()}
</Container>
</>
오늘 이야기 하고자 하는 컴포넌트는 바로 CategorySwitcher
로, 특히 이 컴포넌트 내부의 IndicatorBar
와 BackgroundBar
입니다. 전자가 현재 선택된 카테고리를 보여 주는 주황색 바, 후자가 배경이 되는 회색 바에요. 레이아웃 자체는 간단합니다. position 속성을 사용해서 버튼 아래로 배치하기만 하면 됐으니까요. 문제는 IndicatorBar
였어요. 어떻게 하면 이 컴포넌트의 width와 위치를 버튼에 맞출 수 있을까?
그렇다면 버튼을 클릭했을 때 해당 버튼 요소의 width와 position에 대한 정보를 불러 올 수 있으면 IndicatorBar
의 위치를 쉽게 결정할 수 있겠다! 라는 생각이 들었습니다.
요소의 크기와 뷰포트에 상대적인 위치 정보를 제공하는 DOMRect 객체를 반환합니다.
이름도 긴 이 메서드는, 엘리먼트에 접근해 DOMRect
객체를 반환한다고 합니다. 그렇다면 이제 DOMRect에 대해서 알아 볼 필요가 있겠네요!
DOMRect
인터페이스는 직사각형의 크기와 위치를 나타낸다고 하며, 해당 사각형의 유형은 DOMRect를 반환한 메서드나 속성이 지정한다고 합니다. 실제로 어떻게 생겼는지 확인해 보자면, 이렇게 생긴 것을 확인할 수 있어요. button 요소에 getBoundingClient() 메서드를 적용했을 때 나오는 결과입니다.
{
"x": 284.984375,
"y": 124,
"width": 52.484375,
"height": 16,
"top": 124,
"right": 337.46875,
"bottom": 140,
"left": 284.984375
}
이 객체의 width와 left를 활용한다면, IndicatorBar
의 위치와 크기를 조절할 수 있을 것 같은데요? 각 버튼 요소가 클릭될 때 마다 크기와 위치를 조절하면 되겠죠?
const [barWidth, setBarWidth] = useState(0);
const [barPosition, setBarPosition] = useState(0);
const handleButtonClick = (category, event) => {
setSelectedCategory(category);
selectedButtonRef.current = event.currentTarget;
const { width, left } = event.currentTarget.getBoundingClientRect();
const parentLeft =
event.currentTarget.parentElement.getBoundingClientRect().left;
setBarWidth(width);
setBarPosition(left - parentLeft);
};
먼저 IndicatorBar
의 크기를 조절하기 위해서 barWidth라는 상태와, 위치를 조절하기 위한 barPosition이라는 상태를 선언해 주었습니다. 그리고 버튼이 클릭됐을 때, width와 left 값을 받아 온 다음, setBarWidth와 setBarPostition을 사용해서 IndicatorBar
의 길이와 위치를 설정해 줬습니다.
그렇다면 parentLeft는 왜 필요한 걸까요? 조금 더 정확한 위치 지정을 위해서입니다.
버튼의 left 위치의 경우 페이지 내의 전체 위치를 가리키기 때문에, 그냥 받아온 left 값을 그대로 적용하게 된다면 IndicatorBar
는 엉뚱한 곳에 위치하게 될 겁니다. IndicatorBar가 속해 있는 부모 요소의 left 값에서 제가 받아온 left 값을 빼야 하는 것이지요.
이렇게 해서, 저희는 버튼을 클릭했을 때 IndicatorBar
의 크기와 위치를 설정해 줄 수 있게 되었습니다! 이제 브라우저 창이 resize되었을 때의 IndicatorBar
의 크기가 조절되도록 해 볼까요?
브라우저 창의 크기가 바뀌었을 때는 resize 이벤트가 발생합니다. 조금 더 정확하게 말하자면, document view의 크기가 변경될 때 발생하는 이벤트입니다. 그러니까, resize 이벤트가 발생했을 때 이를 감지하고, IndicatorBar
의 크기를 변경할 수 있다면 좋겠죠?
useEffect(() => {
const setIndicatorPosition = (ref) => {
if (ref.current) {
const { width, left } = ref.current.getBoundingClientRect();
const parentLeft =
ref.current.parentElement.getBoundingClientRect().left;
setBarWidth(width);
setBarPosition(left - parentLeft);
}
};
const handleResize = () => {
setIndicatorPosition(selectedButtonRef);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
구현된 코드는 resize 이벤트가 발생했을 때 이를 감지하고, setIndicatorPosition을 통해서 버튼의 위치와 크기를 줄어든 브라우저 창에 맞도록 다시 조절합니다.
그런데 못 보던 코드가 보입니다. ref
...어떤 역할을 하는 친구일까요? 바로 React의 useRef
입니다. 조금 더 알아볼까요?
몇 번의 구글링과 ChatGPT와의 대화 끝에, 저는 useRef
를 사용하는 방법이 있다는 것을 알게 됐습니다. useRef
는 무엇일까요?
useRef는 렌더링에 필요하지 않은 값을 참조할 수 있는 React 훅입니다.
리액트 공식 문서에서는 useRef에 대해서 위와 같이 설명하고 있습니다. 그리고 이 useRef를 사용하면, 저희는 DOM에 접근할 수 있어요. 따라서 버튼에 접근해서, 버튼에 대한 정보를 얻어올 수 있습니다.
const selectedButtonRef = useRef(null);
const postButtonRef = useRef(null);
이렇게 ref를 선언하면, 이제부터 해당 값을 참조할 수 있게 되는 것이죠!
굳이 따로 postButtonRef를 선언 한 이유는, 페이지가 렌더링될 때 IndicatorBar
의 초기 위치가 게시글 버튼에 있도록 하기 위함입니다.
아까 handleButtonClick에서, 제가 잠깐 언급하지 않고 넘어갔던 코드가 하나 있습니다.
selectedButtonRef.current = event.currentTarget;
바로 이 코드인데, 이 코드는 버튼이 클릭되었을 때 해당 버튼의 참조를 저장하게 됩니다.
<button class="category-group">그룹</button>
selectedButton.current
는 이런 식으로 저장되어 있군요. 이렇게 버튼을 참조해서 resize 이벤트가 발생했을 때, IndicatorBar
의 크기가 조절되도록 할 수 있었습니다!
그래서 카테고리를 클릭했을 때는 이런 식으로,
브라우저 창의 크기를 조절 할 때에는 이런 식으로 변화가 생기게 됩니다.
하지만, 과연 useRef를 사용하는 방법이 정말 최선의 방법이었을까요? 다른 방법은 없었을까요?
다음 편에서는, useRef를 사용하지 않고 구현하는 방법과, 그걸 넘어서서 CSS만으로 구현하는 방법에 대해서 알아볼게요!