- 부모의 overflow 속성에 영향을 받지 않으면서, 최상단 고정 배치
- body 및 모든 엘리먼트 스크롤 방지
- 콘텐츠 외부 영역 클릭 시 닫힘
- 콘텐츠 위치 자동 변경 (콘텐츠의 남은 공간에 따라 위치 변경)
- 합성 컴포넌트로 구현
- 마운트/언마운트 시 transition 적용
이번 글에서는 DropDown의 콘텐츠가 show
상태일 경우 body
및 모든 엘리먼트 스크롤을 막는 기능을 구현해 보려고 한다.
스크롤 방지를 하기 전에 앞서 왜 스크롤을 막아야 하는지에 대해서는 아래 영상을 보자.
보다시피 스크롤을 할 경우 position: fixed
이기 때문에 화면에 고정된다.
이 문제를 해결하기 위해 body
스크롤과 DropDown의 부모 엘리먼트들의 스크롤도 불가능하게 만들 것이다.
body
스크롤을 막기 위해 가장 간단하게 해결할 수 있는 방법이다.
show = true
상태일 경우 body
스타일에 overflow: hidden
을 줘서 스크롤을 강제로 막는 방법이다.
언마운트 시 overflow: usset
으로 다시 스크롤을 가능하게 만드는 기능이다.
useEffect(() => {
if (show) document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
};
}, [show]);
하지만 이 방법으로는 한가지 문제점이 있다.
아래 영상과 같이 body
스타일에 overflow: hidden
이 적용 후 스크롤이 사라지면서
스크롤의 너비만큼 viewport 너비가 바뀌어 레이아웃이 변경된다. (은근 거슬린다.)
해당 이슈를 간단하게 해결하기 위한 scrollbar-gutter: stable
라는 CSS속성이 있다.
scrollbar-gutter
란 스크롤 너비의 공간을 미리 예약하여 스크롤바 나타나거나 사라질 때 불필요한 레이아웃 변경을 방지할 수 있다. 이것은 스크롤바 관련 불필요한 레이아웃 재계산을 방지하는 데 도움이 된다.
overflow: overlay
라는 꿀 기능이 있지만 더 이상 사용되지 않는다.
scrollbar-gutter: stable
와overflow: overlay
예시
/* viewport 스크롤바에 여백을 원하는 경우 `scrolbar-gutter:stable`를 "root"(html) 요소에 할당해야한다. */
html {
scrollbar-gutter: stable;
}
위 코드처럼 사전에 html
요소에 스타일을 적용하면 된다.
NPM으로 배포해 패키지 사용자가 html
요소에 스타일을 사전에 추가할 수 없는 경우, DropDown 컴포넌트 내에서 동적으로 해당 요소에 스타일을 추가하도록 구현할 수 있다.
useEffect(() => {
document.documentElement.style.scrollbarGutter = 'stable'; // 추가
if (show) document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
};
}, [show]);
위 영상처럼 스크롤바가 나타나거나 사라져도 레이아웃 변경이 전혀 없다.
하지만 아쉽게도 safari에서는 지원이 안된다...
safari에서 확인 결과 scrollbar-gutter: stable
이 적용 되기 전과 같이 레이아웃이 변경된다.
요즘은 safari가 IE처럼 크로스 브라우징에 머리를 아프게 한다..ㅠㅠ
현재 진행 중인 프로젝트는 Windows 환경에서만 사용될 예정이며, 따라서 safari 크로스 브라우징 이슈는 나중에 처리하기로 결정하려고 했으나, 이슈는 못참지.
body
스타일에 overflow: hidden
을 줄 경우 iOS safari 모바일에서는 스크롤을 막을 수 없다.
(모바일에서는 DropDown의 UI를 그대로 사용하는 것은 UX적으로 매우 불편하긴하다.)
body-scroll-lock
과 같은 라이브러리를 통해 문제를 쉽게 해결할 수 있지만, 라이브러리를 활용하지 않는 것이 목표이다.
검색결과 safari 크로스 브라우징을 하기 위해서 body
에 position: fixed
를 적용한 후
window.scrollY
를 활용해 사용자의 스크롤 위치를 저장/복원 할 수 있다.
const scrollPosition = window.scrollY;
useEffect(() => {
if (show) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollPosition}px`;
document.body.style.width = '100%';
}
return () => {
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('position');
document.body.style.removeProperty('top');
document.body.style.removeProperty('width');
window.scrollTo(0, scrollPosition);
};
}, [show]);
이렇게만 코드를 작성했을 경우 아까 전과 같이 스크롤이 사라지면서 레이아웃이 변경된다.
레이아웃이 변경되는 이슈를 해결하기 위해 body
의 스크롤 여부를 확인하는 변수를 만든 후
body
에 원래 스크롤이 있을 경우에는 overflow: scroll
을 줘서 스크롤이 사라지는 것을 막았다.
❓ overflow: scroll
을 주면 스크롤이 활성화되는 거 아닌가? 아니다.
overflow: scroll
은 요소 내부에서 스크롤이 필요한 경우에만 스크롤바를 표시한다.
position: fixed
상태가 된 body
는 height
값이 지정되어 있지 않아 스크롤이 필요하지 않다고 인식된다.
즉 스크롤이 되지 않는 스크롤바를 생성해 레이아웃이 변경되는 이슈를 막는 것이다.
const scrollPosition = window.scrollY;
// 스크롤 여부 확인
const hasScrollY = document.body.scrollHeight > window.innerHeight;
const hasScrollX = document.body.scrollWidth > window.innerWidth;
useEffect(() => {
if (show) {
document.body.style.position = 'fixed';
document.body.style.width = '100%';
// 스크롤이 있을 경우 overflow: 'scroll';으로 스크롤 너비만큼 레이아웃 변경 이슈 해결
document.body.style.overflowY = hasScrollY ? 'scroll' : 'hidden';
document.body.style.overflowX = hasScrollX ? 'scroll' : 'hidden';
document.body.style.top = `-${scrollPosition}px`;
}
return () => {
document.body.style.removeProperty('position');
document.body.style.removeProperty('width');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('top');
window.scrollTo(0, scrollPosition);
};
}, [show]);
아래 영상을 보면 scrollbar-gutter: stable
을 활용했을 때와는 다르게 show
상태일 경우에도 스크롤바가 사라지지 않으며 레이아웃이 변경되는 것을 방지할 수 있다.
열심히 해결은 했지만, safari 크로스 브라우징 대응을 안 해도 되는 나는 스크롤바 관련 레이아웃 계산을 최소화하기 위해 scrollbar-gutter: stable
을 써서 편하게 작업하기로 결정했다.
body
스크롤을 막더라도, DropDown의 부모 요소에 스크롤이 발생할 수 있다.
이 문제를 해결하는 간단한 방법 중 하나는 viewport 크기의 div
요소를 생성하여 스크롤을 막는 것이다.
Layer
라는 스타일 컴포넌트를 추가 후 스타일을 적용했으며,
또한 전체 영역을 가리키는 Layer
가 추가되어 show
상태일 때 버튼을 클릭할 수 없기 때문에 button
의 클릭 이벤트를 onClick={() => setShow((prev) => !prev)}
에서 onClick={() => setShow(true)}
로 변경하여 항상 열리도록 수정했다.
<S.Container ref={containerRef}>
//이벤트 변경
<button onClick={() => setShow(true)}>🍡 탕후루 과일 추가</button>
{show && (
<Portal id='dropdown'>
<S.Layer> //추가
<S.Content style={getContainerRect()}>
//...콘텐츠
</S.Content>
</S.Layer>
</Portal>
)}
</S.Container>
Layer {
position: fixed;
inset: 0;
}
💡
inset
이란? top, right, bottom, left의 축약 스타일 속성이다./* inset: 0;은 아래와 같다. */ top: 0; right: 0; bottom: 0; left: 0;
간단하게 Layer
에 background-color: #ff000050;
스타일을 추가 후 확인해 본다면 아래와 같은 모습이다.
해당 사진을 보면 Layer
(붉은색 영역)가 콘텐츠를 제외한 모든 영역을 차지하고 있어 body
외 엘리먼트는 스크롤이 불가능해진다.
scrollbar-gutter: stable
스타일 속성으로 스크롤 너비와 동일한 여백이 사전에 할당되어 있으므로, 이 여백은 Layer
가 차지하지 못하게 된다.
Layer
의 background-color
가 투명하다면, 문제될 건 없다.
Layer
가 버튼 위의 영역에 위치해 버튼을 클릭해서 닫을 수 없다.다음 글에서 위 이슈를 해결하기 위해 바깥 영역 클릭 시 닫히는 기능을 구현하면서 해결할 예정이다.
자신이 개발하는 프로젝트 환경을 고려하여 크롬에 의존하는 것은 가능하지만,
만약을 대비하여 크로스 브라우징 기술과 지식을 습득하는 것도 좋은 것 같다.
잘 알려지지 않은 scrollbar-gutter
와 같이 문제를 편리하게 해결할 수 있는 기능을 사용할 땐,
모질라 MDN, Can I Use 등 크로스 브라우징을 체크를 할 수 있는 사이트를 활용해
브라우저 호환성을 잘 보며 사용해야 한다.
래퍼런스