Custom Scrollbar 잘 구현하기
2023.05.30
목차
• 서문
• 어떻게 만들어할까?
• 리펙터링
• 끝맺음
서문
왜 custom scrollbar가 필요할까요? 브라우저에서 제공하는 scrollbar를 사용하면 안 될까요? custom scrollbar를 반드시 구현해야 하는 이유에는 두가지가 있습니다. 그전에 CSS를 통해 scrollbar 스타일을 조작할 수 있는 옵션부터 알아봅시다.
scrollbar, scrollbar-track, scrollbar-thumb의 스타일을 조정할 수 있는 옵션을 제공합니다.
• scrollbar
- 전체적인 scroll영역을 지칭합니다.
• scroll-thumb - 빨간색 element를 지칭합니다. click이나 touch를 통해서 scroll 위치를 조정할 수 있습니다.
• scroll-track - scroll-thumb가 지나가는 영역을 지칭합니다.
::-webkit-scroll
,::-webkit-scrollbar-track
,::-webkit-scrollbar-thumb
옵션을 통해 scrollbar의 스타일을 어느정도 조정할 수 있습니다.
하지만 한계가 존재합니다.
첫번째
thumb의 background-color가 변경되지 않는 문제가 있습니다. 디자인 요구사항에서 thumb의 color는 #888888
이기 때문에 요구사항을 구현할 수 없는 문제가 발생합니다.
const scrollbar = css` overflow: scroll; overflow-x: hidden; background: white; width: 200px; height: 100px; ::-webkit-scrollbar { width: 5px; height: 8px; background-color: #aaa; } ::-webkit-scrollbar-thumb { background: red; border-radius: 10px; } ::-webkit-scrollbar-track { background: pink; } `;
두번째
브라우저별로 scrollbar의 스타일을 조작하는 옵션이 상이합니다. 크롬 기준으로 edge는 크롬과 동일하게 scrollbar의 style이 적용되지만 Firefox 는 해당 style이 적용되지 않는 것을 확인할 수 있습니다.
세번째
scroll-thumb
의 width (vertical scroll인 경우에는 height)를 조정할 수 없습니다.
왜냐하면 scroll-thumb
의 길이는 스크롤이 가능한 전체 길이와 화면에 보이는 element의 길이에 비례에서 결정됩니다. 그래서 디자인 요구사항에 맞는 thumb의 길이를 구현할 수 없는 문제가 발생합니다.
이런 문제들로 인해 요구사항을 지킬 수 없을뿐 아니라 사용자에게도 동일한 경험을 제공하기 힘들다는 결론이 도출되었습니다. 그래서 scrollbar를 커스텀할 수 있도록 만드는 것으로 결정하였습니다.
어떻게 만들어할까?
첫번째로 scroll할 element에 scroll event를 부착해야 합니다.
const Component = () => { return ( <> <div css={getExtColorButtonGroupStyle(paddingDetail, isShowScrollbar)} onScroll={handleScrollElement} ref={scrollElementRef} > <div css={emptyBoxStyle} /> {/* 스크롤 할 수 있는 element들 */} <div css={emptyBoxStyle} /> </div> <div css={scrollbarStyle} onTouchStart={handleTouchStartScrollbar} onTouchMove={handleTouchMoveScrollbar} > <div ref={trackRef} css={getTrackStyle(trackWidth)} /> <div ref={thumbRef} css={getThumbStyle(scrollRatio, thumbWidth)} /> </div> </> ); };
const useCustomScrollbar = () => { const padding = 60; const [scrollRatio, setScrollRatio] = useState(0); const thumbRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null); const scrollElementRef = useRef<HTMLDivElement>(null); const handleScrollElement = (e: React.UIEvent<HTMLDivElement>) => { if ( e.target instanceof HTMLDivElement && thumbRef.current instanceof HTMLDivElement && trackRef.current instanceof HTMLDivElement ) { const scrollLeft = e.target.scrollLeft; const scrollWidth = e.target.scrollWidth - e.target.clientWidth; setScrollRatio( (scrollLeft / scrollWidth) * 100 * (thumbRef.current.offsetWidth / trackRef.current.offsetWidth - 1) ); } }; return { thumbRef, trackRef, scrollElementRef, }; };
scrollRatio
에 의해 scrollbar-track
의 위치가 결정됩니다.
export const getThumbStyle = (scrollRatio: number, thumbWidth: number) => { return css` left: 30px; position: absolute; width: ${thumbWidth}px; height: 4px; transform: translateX(${scrollRatio}%); background: #888888; border-radius: 50px; `; };
scrollRatio
는 (scrollLeft / scrollWidth * 100)
를 통해 scroll한 비율을 계산합니다. 그리고 (thumbRef.current.offsetWidth / trackRef.current.offsetWidth - 1)
, thumb width와 track width는 적응형으로 동작해야 하고 custom scrollbar를 사용하는 측(컴포넌트를 가져다 쓰는 개발자)에서 커스텀을 하더라도 문제 없이 동작할 수 있게 하기 위해 추가로 계산이 필요합니다.
div element로 구현한 scrollbar는 당연하게 scrollbar를 통해서 scroll을 수정할 수 없습니다. 그리고 현재 요구사항에서 컴포넌트는 모바일 환경에서만 존재합니다. 그래서 touch 이벤트를 scrollbar에 부착하였습니다.
<div css={scrollbarStyle} onTouchStart={handleTouchStartScrollbar} onTouchMove={handleTouchMoveScrollbar} > <div ref={trackRef} css={getTrackStyle(trackWidth)} /> <div ref={thumbRef} css={getThumbStyle(scrollRatio, thumbWidth)} /> </div>
onTouchStart
이벤트를 통해 touch하는 시점에 함수를 실행시켜 x 좌표값을 저장합니다.
그리고 onTouchMove
이벤트를 통해 scroll한 길이를 구하고 scrollWidth 대비 비율을 구하고 기존의 scrollRatio 값과 더해서 상태를 갱신합니다.
touchMove 라는 행위는 touch를 하고 있는동안 onTouchMove
이벤트에 바인딩된 함수가 계속 실행됩니다. scrollbar 범위, 그 이상 위치에서 touch를 한다면 scrollRatio의 값은 정상 범주를 벗어나게 됩니다. 그러면 scrollThumb가 scrollTrack을 넘어가는 현상이 발생합니다.
const useCustomScrollbar = () => { // prev code... const handleTouchMoveScrollbar = (e: React.TouchEvent<HTMLDivElement>) => { if ( e.target instanceof HTMLDivElement && trackRef.current instanceof HTMLDivElement && thumbRef.current instanceof HTMLDivElement ) { const scrollDistance = e.changedTouches[0].pageX - touchStart; const touchScrollRatio = (scrollDistance / trackRef.current.scrollWidth) * 100 * (trackRef.current.offsetWidth / thumbRef.current.offsetWidth - 1); const totalScrollRatio = touchScrollRatio + scrollRatio; const max = (trackRef.current.offsetWidth / trackRef.current.scrollWidth) * 100 * (trackRef.current.offsetWidth / thumbRef.current.offsetWidth - 1); if (totalScrollRatio <= 0) { setScrollRatio(0); } else if (totalScrollRatio >= max) { setScrollRatio(max); } else { setScrollRatio(totalScrollRatio); } if (scrollElementRef.current instanceof HTMLDivElement) { scrollElementRef.current.scrollLeft = totalScrollRatio; } } }; return { thumbRef, trackRef, scrollElementRef, handleTouchMoveScrollbar, }; };
그래서 scrollRatio의 최소값과 최대값을 구해서 scrollRatio가 정상 범주에서만 증감할 수 있도록 해야 합니다.
그리고 scrollbar, thumb, track은 모두 반응형으로 구현해야 합니다. 모바일 환경에서 반응형이라니.. 반응형이 아니라 적응형 아닌가 라는 생각이 들 수 있습니다. 요즘 다양한 형태의 기기가 출시되면서 Galaxy Flip이나 Galaxy Fold 같은 기기들에도 그에 걸맞은 UI를 제공해야 합니다.
const useCustomScrollbar = () => { // prev code... const handleAutoScrollbar = () => { if (scrollElementRef.current instanceof HTMLDivElement) { // get button, gap total width code... const totalScrollWidth = totalButtonWidth + totalButtonGap + padding; if (trackRef.current instanceof HTMLDivElement) { const trackWidth = (Number(trackRef.current.offsetWidth) / totalScrollWidth) * Number(trackRef.current.offsetWidth); setThumbWidth(trackWidth); setScrollRatio(0); } setTrackWidth(window.innerWidth - padding); setIsShowScrollbar( scrollElementRef.current.offsetWidth + padding < totalScrollWidth ); } }; useEffect(() => { handleAutoScrollbar(); window.addEventListener("resize", handleAutoScrollbar); return () => { window.removeEventListener("resize", handleAutoScrollbar); }; }, []); return { thumbRef, trackRef, scrollElementRef, handleTouchMoveScrollbar, handleAutoScrollbar, }; };
그러기 위해서는 resize
이벤트를 활용할 수 있습니다. 그래서 Galaxy Fold를 펼치고 접을 때마다 resize 이벤트가 발생해 사용자 화면에 맞는 UI를 제공할 수 있게 되었습니다.
이렇게 custom scroll을 위한 useCustomScrollbar
이라는 custom hook을 만들었습니다. 이런 custom hook을 통해 공통 로직을 만들 때에는 항상 사용하는 개발자 입장에서 쓰기 쉽게 로직을 짜는 것이 좋습니다.
최종 결과물
export const SomeComponent = () => { const { thumbRef, trackRef, scrollElementRef, isShowScrollbar, thumbWidth, trackWidth, scrollRatio, handleScrollElement, handleTouchStartScrollbar, handleTouchMoveScrollbar, handleTouchEndScrollbar, } = useCustomScrollbar({scrollElementCount: extColors.length}); return ( <> <div css={getExtColorButtonGroupStyle(paddingDetail, isShowScrollbar)} onScroll={handleScrollElement} ref={scrollElementRef} > {/* 스크롤 할 수 있는 element들 */} </div> {isShowScrollbar ? ( <div css={scrollbarStyle} onTouchStart={handleTouchStartScrollbar} onTouchMove={handleTouchMoveScrollbar} onTouchEnd={handleTouchEndScrollbar} > <div ref={trackRef} css={getTrackStyle(trackWidth)} /> <div ref={thumbRef} css={getThumbStyle(scrollRatio, thumbWidth)} /> </div> ) : null} </> ); };
interface Props { scrollElementCount: number; } export const useCustomScrollbar = ({scrollElementCount}: Props) => { const padding = 60; const {color, paddingDetail} = useResponsive(); const [scrollRatio, setScrollRatio] = useState(0); const [isShowScrollbar, setIsShowScrollbar] = useState(true); const [isTouching, setIsTouching] = useState(false); const [trackWidth, setTrackWidth] = useState(window.innerWidth - padding); const [thumbWidth, setThumbWidth] = useState(0); const [touchStart, setTouchStart] = useState(0); const trackRef = useRef<HTMLDivElement>(null); const thumbRef = useRef<HTMLDivElement>(null); const scrollElementRef = useRef<HTMLDivElement>(null); const handleScrollElement = (e: React.UIEvent<HTMLDivElement>) => { if ( e.target instanceof HTMLDivElement && trackRef.current instanceof HTMLDivElement && thumbRef.current instanceof HTMLDivElement && !isTouching ) { const scrollLeft = e.target.scrollLeft; const scrollWidth = e.target.scrollWidth - e.target.clientWidth; setScrollRatio( (scrollLeft / scrollWidth) * 100 * (trackRef.current.offsetWidth / thumbRef.current.offsetWidth - 1) ); } }; const handleTouchStartScrollbar = (e: React.TouchEvent<HTMLDivElement>) => { // scrollbar touch 하기 위함 if (e.target instanceof HTMLDivElement) { setIsTouching(true); setTouchStart(e.changedTouches[0].pageX); } }; const handleTouchMoveScrollbar = (e: React.TouchEvent<HTMLDivElement>) => { if ( e.target instanceof HTMLDivElement && trackRef.current instanceof HTMLDivElement && thumbRef.current instanceof HTMLDivElement ) { const scrollDistance = e.changedTouches[0].pageX - touchStart; const touchScrollRatio = (scrollDistance / trackRef.current.scrollWidth) * 100 * (trackRef.current.offsetWidth / thumbRef.current.offsetWidth - 1); const totalScrollRatio = touchScrollRatio + scrollRatio; const max = (trackRef.current.offsetWidth / trackRef.current.scrollWidth) * 100 * (trackRef.current.offsetWidth / thumbRef.current.offsetWidth - 1); if (totalScrollRatio <= 0) { setScrollRatio(0); } else if (totalScrollRatio >= max) { setScrollRatio(max); } else { setScrollRatio(totalScrollRatio); } if (scrollElementRef.current instanceof HTMLDivElement) { scrollElementRef.current.scrollLeft = totalScrollRatio; } } }; const handleTouchEndScrollbar = () => { setIsTouching(false); }; const handleAutoScrollbar = () => { if (scrollElementRef.current instanceof HTMLDivElement) { const totalButtonWidth = Number(color.split("px").join("")) * scrollElementCount; const totalButtonGap = Number(paddingDetail.split("px").join("")) * scrollElementCount - 1; const totalScrollWidth = totalButtonWidth + totalButtonGap + padding; if (trackRef.current instanceof HTMLDivElement) { const trackWidth = (Number(trackRef.current.offsetWidth) / totalScrollWidth) * Number(trackRef.current.offsetWidth); setThumbWidth(trackWidth); setScrollRatio(0); } setTrackWidth(window.innerWidth - padding); setIsShowScrollbar( scrollElementRef.current.offsetWidth + padding < totalScrollWidth ); } }; useEffect(() => { handleAutoScrollbar(); window.addEventListener("resize", handleAutoScrollbar); return () => { window.removeEventListener("resize", handleAutoScrollbar); }; }, []); return { trackRef, thumbRef, scrollElementRef, isShowScrollbar, thumbWidth, trackWidth, scrollRatio, handleScrollElement, handleTouchStartScrollbar, handleTouchMoveScrollbar, handleTouchEndScrollbar, }; };
리펙터링
이렇게 정리를 하다보니 과연 이게 쓰기 편한가? 라는 의문이 들었습니다.
custom scrollbar를 사용하기 위해서는 개발자가 불필요하게 작성해야 하는 코드가 많고 만약 내가 사용하는 입장이라면 딱히 사용하고 싶지 않았습니다. 라이브러리 선택에 상당부분 차지하는 것이 DX(Developer Experience)라고 생각합니다. 이 custom scrollbar도 라이브러리와 성격이 비슷합니다. 팀내 다른 개발자가 가져다 쓰는 것이기 때문이죠.
그러기 위해서는 일단 custom scrollbar 로직과 scroll하는 element 로직을 분리해야 합니다.
export const SomeComponent = () => { return ( <ScrollBarWrapper>{/* 스크롤 할 수 있는 element들 */}</ScrollBarWrapper> ); };
interface Props { children: ReactElement[]; } export const ScrollBarWrapper = ({children}: Props) => { const { thumbRef, trackRef, scrollElementRef, isShowScrollbar, thumbWidth, trackWidth, scrollRatio, handleScrollElement, handleTouchStartScrollbar, handleTouchMoveScrollbar, handleTouchEndScrollbar, } = useCustomScrollbar(); return ( <> <div css={getExtColorButtonGroupStyle(paddingDetail, isShowScrollbar)} onScroll={handleScrollElement} ref={scrollElementRef} > {children} </div> {isShowScrollbar ? ( <div css={scrollbarStyle} onTouchStart={handleTouchStartScrollbar} onTouchMove={handleTouchMoveScrollbar} onTouchEnd={handleTouchEndScrollbar} > <div ref={trackRef} css={getTrackStyle(trackWidth)} /> <div ref={thumbRef} css={getThumbStyle(scrollRatio, thumbWidth)} /> </div> ) : null} </> ); };
ScrollBarWrapper
라는 컴포넌트를 만들고 스크롤 할 수 있는 element들을 Wrapper로 감싸서 children을 통해 컴포넌트 내부에서 렌더하도록 하였습니다. 이로 인해 커스텀 스크롤을 사용하는 사람은 list 형태의 element들을 감싸주기만 하면 쉽게 적용할 수 있고 useCustomScrollbar
같은 불필요한 코드도 작성할 필요가 없어졌습니다.
그리고 커스텀 스크롤바, 커스텀을 할 수 있어야 하기 때문에 scrollbar, thumb, track을 활용하기 위한 ref와 style을 변경할 수 있도록 css를 외부에서 주입할 수 있도록 추가하였습니다. emotion으로 스타일링 하는 경우 배열 형태로 주입하면 마지막 index에 위치한 스타일로 override 하는 특징이 있습니다.
export const SomeComponent = () => { const scrollElementRef = useRef<HTMLDivElement>(null); const thumbRef = useRef<HTMLDivElement>(null); return ( <ScrollBarWrapper scrollBarPadding={60} scrollWrapperRefInjection={scrollElementRef} thumbRefInjection={thumbRef} > {/* 스크롤 할 수 있는 element들 */} </ScrollBarWrapper> ); };
interface Props { scrollBarPadding?: number; scrollWrapperRefInjection?: React.RefObject<HTMLDivElement>; thumbRefInjection?: React.RefObject<HTMLDivElement>; trackRefInjection?: React.RefObject<HTMLDivElement>; scrollWrapperStyleInjection?: SerializedStyles; scrollbarStyleInjection?: SerializedStyles; trackStyleInjection?: SerializedStyles; thumbStyleInjection?: SerializedStyles; children: ReactElement[] | ReactElement; } export const ScrollBarWrapper = ({ scrollBarPadding, scrollWrapperRefInjection, thumbRefInjection, trackRefInjection, scrollWrapperStyleInjection, scrollbarStyleInjection, trackStyleInjection, thumbStyleInjection, children, }: Props) => { const {paddingDetail} = useResponsive(); const { thumbRef, trackRef, scrollElementRef, isShowScrollbar, thumbWidth, trackWidth, scrollRatio, handleScrollElement, handleTouchStartScrollbar, handleTouchMoveScrollbar, handleTouchEndScrollbar, } = useCustomScrollbar({ scrollBarPadding, scrollWrapperRefInjection, thumbRefInjection, trackRefInjection, }); return ( <> <div css={[ getExtColorButtonGroupStyle(paddingDetail, isShowScrollbar), scrollWrapperStyleInjection, ]} onScroll={handleScrollElement} ref={scrollElementRef} > {children} </div> {isShowScrollbar ? ( <div css={[scrollbarStyle, scrollbarStyleInjection]} onTouchStart={handleTouchStartScrollbar} onTouchMove={handleTouchMoveScrollbar} onTouchEnd={handleTouchEndScrollbar} > <div ref={trackRef} css={[getTrackStyle(trackWidth), trackStyleInjection]} /> <div ref={thumbRef} css={[getThumbStyle(scrollRatio, thumbWidth), thumbStyleInjection]} /> </div> ) : null} </> ); };
interface Props { scrollBarPadding?: number; scrollWrapperRefInjection?: React.RefObject<HTMLDivElement>; thumbRefInjection?: React.RefObject<HTMLDivElement>; trackRefInjection?: React.RefObject<HTMLDivElement>; } export const useCustomScrollbar = ({ scrollBarPadding = 0, scrollWrapperRefInjection, thumbRefInjection, trackRefInjection, }: Props) => { { /* 기존 코드... */ } const trackRef = trackRefInjection || useRef<HTMLDivElement>(null); const thumbRef = thumbRefInjection || useRef<HTMLDivElement>(null); const scrollElementRef = scrollWrapperRefInjection || useRef<HTMLDivElement>(null); { /* 기존 코드... */ } return { trackRef, thumbRef, scrollElementRef, isShowScrollbar, thumbWidth, trackWidth, scrollRatio, handleScrollElement, handleTouchStartScrollbar, handleTouchMoveScrollbar, handleTouchEndScrollbar, }; };
끝맺음
이로 인해 사용하는 개발자측에서 작성해야 하는 코드가 상당히 줄어든 것을 확인할 수 있습니다.
const { thumbRef, trackRef, scrollElementRef, isShowScrollbar, thumbWidth, trackWidth, scrollRatio, handleScrollElement, handleTouchStartScrollbar, handleTouchMoveScrollbar handleTouchEndScrollbar, } = useCustomScrollbar({ scrollElementCount: extColors.length }); return ( <> <div css={getExtColorButtonGroupStyle(paddingDetail, isShowScrollbar)} onScroll={handleScrollElement} ref={scrollElementRef}> {/* 스크롤 할 수 있는 element들 */} </div> { isShowScrollbar ? ( <div css={scrollbarStyle} onTouchStart={handleTouchStartScrollbar} onTouchMove={handleTouchMoveScrollbar} onTouchEnd={handleTouchEndScrollbar}> <div ref={trackRef} css={getTrackStyle(trackWidth)} /> <div ref={thumbRef} css={getThumbStyle(scrollRatio, thumbWidth)} /> </div> ) : null } </> );
export const SomeComponent = () => { const scrollElementRef = useRef<HTMLDivElement>(null); const thumbRef = useRef<HTMLDivElement>(null); return ( <ScrollBarWrapper scrollBarPadding={60} scrollWrapperRefInjection={scrollElementRef} thumbRefInjection={thumbRef} > {/* 스크롤 할 수 있는 element들 */} </ScrollBarWrapper> ); };
이렇게 공용으로 사용하는 컴포넌트는 마치 조그마한 라이브러리를 구현한다고 생각하고 구현하는 것이 좋습니다. 최소한의 input, 최소한의 코드만으로 컴포넌트를 쉽게 가져다 쓸 수 있도록 고민해볼 수 있는 경험이였습니다. 보기에는 간단한 scrollbar 이지만 요구사항이 추가되고 가져다 쓰기 편하게 하기위해 고도화하는 것은 하면 할수록 끝이 없는 것 같습니다. 그래서 해결해야 하는 다른 이슈와 고도화 사이의 적절함의 선에 최대한 수렴할 수 있게 개발하는 것이 중요하다고 생각합니다.