ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Portfolly] 직접 반응형 카테고리 Slider 만들기
    Studying/Proj 과정 2023. 12. 24. 14:31

    문제 상황

    MainPage에 필요한 카테고리 슬라이더를 구현 중 문제가 발생했습니다.

     

    구현하고자 하는 카테고리 슬라이더는 아래와 같이 생겼습니다.(mobbin 홈페이지 캡쳐)

     

    반응형 지원 카테고리 슬라이더 by mobbin

    PortfolioSlider 를 구현했을 때 react-slick을 사용했기 때문에

    카테고리 슬라이더 또한 slick slider를 사용하려고 했으나 몇가지 문제들이 있었습니다.

     

    일단 구현하고자 하는 카테고리 슬라이더는 한 페이지 당 보여주는 슬라이드 개수가 정해져 있지 않습니다.

     

    카테고리 버튼 길이에 따라 가로 길이를 벗어난 만큼 화살표로 넘겨 볼 수 있으며, 마지막 버튼에 도달하면 화살표가 사라집니다.

     

    react-slick의 옵션에서 variableWidth: ture 로 설정하면 한 페이지당 슬라이드를 딱 정하지 않고 유연하게 슬라이드 할 수 있긴 합니다.

     

    그런데 그 대신 페이지 당 넘어가는 슬라이드 개수인 slidesToScroll 값이 정해져있지 않아서

     

    다음 그림과 같이 처음/마지막 버튼에 딱 떨어지게 도달하지 않고 애매하게 꼬투리를 남깁니다.

     

    만약 무한 스크롤로 돌아가게 할 거라면 상관 없겠지만, 저는 좌우로만 움직이게 하고싶었습니다.

     

    react-slick variableWdith: true일 때

     

    VariableWidth | React Slick

     

    react-slick.neostack.com

     

     

    해결 방법

    결국 슬라이더를 직접 만들기로 했습니다.

     

    다른 캐러셀 라이브러리를 찾아보는 것도 방법이지만, 직접 구현하는 것도 제 스스로에게 도움이 될 것 같아서 그냥 구현하기로 했습니다.

     

    고려해야할 점은 다음과 같습니다.

     

    1. 시작과 끝에선 Prev, Next 화살표 버튼이 없어야 한다.

     

    2. 반응형을 지원한다

     

    2-1. 브라우저 크기가 줄어들어 가로 길이가 짧아지면 화살표 버튼이 생긴다.

     

    2-2. 브라우저 크기가 다시 커지면 슬라이더가 가로 폭에 딱 맞게 늘어나야한다.

     

    3. 시작과 끝 버튼 길이에 딱 맞게 슬라이더가 끝나야 한다.

     

     

    차근차근 정리해보겠습니다.

     

    CategorySlider.tsx는 다음과 같은 구조로 이루어져 있습니다. 

     

    CategoryBox 안에 Prev, Next 버튼이 있고, Slider 내부에 map 반복문을 통해 카테고리 버튼을 생성합니다.

    export default function CategorySlider() {
    	...
     return(
    	<S.Wrapper>
    		...
            <S.CategoryBox ref={categoryBoxRef}>
                <S.PrevArrow 
                    color='white' 
                    shape='round' 
                    $showPrevArrow={showPrevArrow} 
                    onClick={handlePrev}
                >
                    <ArrowLeftIcon size={16}/>
                </S.PrevArrow>
                
                <S.NextArrow 
                    color='white' 
                    shape='round' 
                    $showNextArrow={showNextArrow}
                    onClick={handleNext}
                >
                    <ArrowRightIcon size={16}/>
                </S.NextArrow>
    
                <S.Slider ref={sliderRef}>
                    { ['전체', ...categories[currentSection]].map((category, index)=>{
                        return (
                            <Button
                                className={(index === lastIndex) ? 'last-category' : ''}
                                key={index}
                                color='transparent'
                                shape='round'
                                onClick={handleCategory}
                                $active={(category === currentCategory) ? true : false}>
                            {category}
                            </Button>
                        )
                    }) }
                </S.Slider>
            </S.CategoryBox>
        </S.Wrapper>
     )
    }

     

     

    Prev, Next 버튼은 showPrevArrow, showNextArrow 매개변수를 각각 받아 visibility 여부를 결정합니다.

    export const PrevArrow = styled(ButtonStyle)<{$showPrevArrow: boolean}>`
    	position: absolute;
    	z-index: 100;
    	top: 0;
    	left: 0;
    
    	cursor: pointer;
    	visibility: ${(props) => props.$showPrevArrow ? 'visible' : 'hidden'};
    `;
    
    export const NextArrow = styled(ButtonStyle)<{$showNextArrow: boolean}>`
    	position: absolute;
    	z-index: 100;
    	top: 0;
    	right: 0;
    
    	cursor: pointer;
    	visibility: ${(props) => props.$showNextArrow ? 'visible' : 'hidden'};
    `;

     

     

    showPrevArrow, showNextArrow 값은 useCategorySlider 훅의 반환값으로 받아옵니다.

     

    그럼 useCategorySlider 에서 어떻게 두 값을 정하는지 정리해보겠습니다.

     

    useCategorySlider 훅에서 관리하는 상태값들을 먼저 알아보겠습니다.

    const [slider, setSlider] = useState<HTMLElement>();
    const [categoryBox, setCategoryBox] = useState<HTMLElement>();
    
    const [showPrevArrow, setShowPrevArrow] = useState(false);
    const [showNextArrow, setShowNextArrow] = useState(false);
    
    const [sliderLeft, setSliderLeft] = useState(0);

     

    slider는 뜻 그대로 Slider 엘리먼트입니다. 훅을 호출한 CategorySlider.tsx에서 화면이 렌더링 되었을 때 setSlider(sliderRef.current) 해서 훅에 전달합니다.

     

    categoryBox는 Slider의 부모 컴포넌트입니다. CategoryBox 너비보다 Slider가 길면 화살표 버튼으로 넘겨볼 수 있는 원리입니다.

     

    sliderLeft는 Slider가 시작하는 x 좌표 값입니다.

     

    카테고리 슬라이더는 다음과 같이 CategoryBox보다 길게 쭉 뻗어있습니다.

    단지 overflow: hidden 되어있어서 눈에 보이지 않을 뿐입니다.

     

    CategorySlider 구조

    이때 Slider(초록색) 총 길이는 width: fit-content로 하되, 다음과 같이 가로 길이가 축소됐을 때 밀려나지 않도록 flex-wrap: nowrap 속성을 꼭 추가해야 합니다.

     

    카테고리 버튼이 다음 줄로 밀리는 모습

     

    이제 화살표 표시 여부를 계산하겠습니다.

     

    우측에 가려진 카테고리 버튼이 1개 이상 존재할 때 showPrevArrow가 true이고

    좌측에 가려진 카테고리 버튼이 1개 이상 존재할 때 showNextArrow가 true가 되어야 합니다.

     

    화면이 처음 렌더링될 때 화살표 여부를 결정하는 코드를 확인하겠습니다.

     

    브라우저 크기가 클 때는 Next 화살표가 없다가, 브라우저 화면을 슬라이더 길이보다 더 줄였을 땐 화살표가 표시되어야 합니다. 따라서 useEffect() 훅으로 resize 핸들러를 발동시켜 크기가 바뀔 때마다 화살표 표시 여부를 다시 확인합니다.

     

    메모리를 낭비하지 않게 언마운트 시 핸들러를 제거하는 것도 잊으면 안됩니다.

    useEffect(() => {
        setArrow();
    }, [slider, section]);
    
    useEffect(() => {
        window.addEventListener("resize", setArrow);
        return () => {
            window.removeEventListener("resize", setArrow);
        }
    });

     

    또한 CategorySlider.tsx에서 sliderRef.current를 등록했을 때, Section 메뉴가 바뀌었을 때도 다시 화살표 표시 여부를 설정합니다.

     

    화살표 표시 여부를 정하는 setArrow() 함수입니다.

    브라우저 크기가 변할 때마다 연속적으로 실행시키기 부담스러워 throttle로 300ms에 1번씩 실행하도록 만들었습니다.

    const setArrow = throttle(() => {
        if(!slider) return;
    
    	// 마지막 카테고리 버튼
        const lastSlideItem = document.querySelector('.last-category') as HTMLElement;
        // 슬라이더 맨 끝 x좌표
        const endOfSlide = lastSlideItem.getBoundingClientRect().right;
        // 슬라이더 맨 처음 x좌표
        const startOfSlide = slider!.getBoundingClientRect().left;
        // 슬라이더 길이
        const sliderWidth = endOfSlide - startOfSlide;
        // categoryBox 길이
        const categoryBoxWidth = categoryBox!.offsetWidth;
    
    	// 슬라이더 첫 시작 부분이 0일때(기본을 0으로 설정) Prev 버튼 미표시
        if(sliderLeft >= 0) {
            setShowPrevArrow(false);
        } else setShowPrevArrow(true);
    
    	// 슬라이더 길이가 카테고리 박스보다 길면 Next 버튼 표시
        if(sliderWidth > categoryBoxWidth) {
            setShowNextArrow(true);
        } else setShowNextArrow(false);
    }, 300);

     

     

    슬라이더 총 길이 = 마지막 카테고리 버튼 right 좌표 - 슬라이더 첫 시작부분 x 좌표 입니다.

     

    clientWidth, offsetWidth, getBoundingClientRect.width 등은 모두 렌더링된 길이기 때문에 overflow: hidden된 부분까지 포함한 길이를 알려면 저렇게 계산을 해줘야 했습니다.

     

     

     

    브라우저 크기에 맞게 슬라이더 늘이기

    그런데 저기서 구현을 끝내기엔 문제가 하나 더 있습니다.

     

    브라우저 가로 길이가 축소된 채 슬라이더 맨 끝까지 도달했다가, 다시 전체 화면으로 늘어나면 어떻게 될까요?

     

    브라우저가 축소되었다가 다시 확장됐을 때

     

    바로 위 그림과 같이 확장된 공간이 있음에도 슬라이더는 자신이 이동한 좌표에서 꼼짝 않고 멈춰 우측에 여백이 생기고 맙니다.

     

    따라서 브라우저가 확장된 만큼 슬라이더도 확장되도록 만들어야 합니다.

     

    저는 expandSliderToFitCategoryBox(네이밍 센스가 심각하게 안좋습니다) 함수를 만들어 setArrow 함수 맨 하단에 호출했습니다.

    const expandSliderToFitCategoryBox = () => {
        if(!slider) return;
    
        const endOfSlide = slider!.getBoundingClientRect().right;
        const endOfCategoryBox = categoryBox!.getBoundingClientRect().right;
    
    	// 슬라이더 확장 여부
        // (슬라이더가 시작점으로부터 왼쪽으로 이동해 있고, 슬라이더 끝부분이 카테고리 박스 끝부분보다 작을 때)
        const haveToExpand = sliderLeft < 0 && endOfSlide < endOfCategoryBox;
        // 여백 길이
        const emptyWidth = endOfCategoryBox - endOfSlide;
        // 슬라이더를 여백 길이만큼 오른쪽으로 이동시켰을 때 시작점 좌표
        // 여백 길이만큼 오른쪽으로 이동한다. = 카테고리 마지막 버튼을 CategoryBox 맨 끝에 맞춘다.
        const expandedSliderLeft = sliderLeft + emptyWidth;
        
        // 여백만큼 오른쪽 이동했을 때 딱 시작점(0)이면 슬라이드가 1페이지 뿐인 슬라이더
        const isOnePageSlider = expandedSliderLeft >= 0;
    
    	// 슬라이드 전체 길이 <= 카테고리 박스 길이 이므로 Prev, Next 버튼 둘 다 필요 없다
        if(haveToExpand && isOnePageSlider) {
            slider!.style.left = `0px`;
            setSliderLeft(0);
            setShowPrevArrow(false);
            setShowNextArrow(false);
            return;
        }
    
    	// 여백 길이만큼 당기고도 남은 슬라이더 길이가 있다는 건 1페이지 이상에다가 이전 페이지가 존재한다는 것.
        if(haveToExpand && !isOnePageSlider) {
            slider!.style.left = `${expandedSliderLeft}px`;
            setSliderLeft(expandedSliderLeft);
            setShowPrevArrow(true);
            setShowNextArrow(false);
        }
    };

     

     

     

    시작과 끝 버튼에 딱 맞게 슬라이더 끝내기

    이제 Prev, Next 버튼에 대한 핸들러만 작성하면 됩니다.

     

    이 또한 useCategorySlider 훅에서 정의한 뒤 객체에 담아 반환합니다.

     

    다음은 Prev 버튼 클릭 시 발동하는 handlePrev 핸들러입니다.

    const handlePrev = () => {
        const isStartOfSlider = sliderLeft + SLIDE_WIDTH * 2 >= 0;
        setShowNextArrow(true);
    
        if(isStartOfSlider){
            slider!.style.left = `0px`;
            setSliderLeft(0);
            setShowPrevArrow(false);
            return;
        }
    
        slider!.style.left = `${sliderLeft + SLIDE_WIDTH}px`;
        setSliderLeft(prev => prev + SLIDE_WIDTH);
    };

     

    첫 번째 카테고리 버튼에 얼추 다다랐을 때 자석이 당기는 것처럼 착 끝에 감기게 만들고 싶었습니다.

     

    따라서 sliderLeftSLIDE_WIDTH(슬라이드 넘어가는 폭)의 2배 더한 값이 0 이상이면 곧바로 첫 번째 버튼에 도달하게 만들었습니다.

     

    '오른쪽으로 민다'는 말의 뜻

    그렇지 않다면 평범하게 SLIDE_WIDTH를 한 번 더해줘서 슬라이더를 오른쪽으로 밀어줍니다.

     

     

    다음은 Next 버튼 클릭 시 발동하는 handleNext 핸들러입니다.

    const handleNext = () => {
        const lastSlideItem = document.querySelector('.last-category') as HTMLElement;
        const endOfSlide = lastSlideItem.getBoundingClientRect().right;
        const endOfCategoryBox = categoryBox!.getBoundingClientRect().right;
    
        const isEndOfSlide = endOfSlide - SLIDE_WIDTH * 2 <= endOfCategoryBox;
    
        setShowPrevArrow(true);
    
        if(isEndOfSlide){
            slider!.style.left = `${sliderLeft - (endOfSlide - endOfCategoryBox)}px`;
            setSliderLeft(prev => prev - (endOfSlide - endOfCategoryBox));
            setShowNextArrow(false);
            return;
        }
    
        slider!.style.left = `${sliderLeft - SLIDE_WIDTH}px`;
        setSliderLeft(prev => prev - SLIDE_WIDTH);
    };

    마찬가지로 슬라이더 끝 부분에 SLIDE_WIDTH 2배를 뺀 값이 카테고리 박스 끝부분보다 작다면 슬라이더를 바로 맨 끝부분으로 당깁니다.

     

    '왼쪽으로 민다'는 말의 예시

     

    아직 끝부분에 도달하기에 멀었다면 SLIDE_WIDTH 값을 빼 슬라이더를 왼쪽으로 밀어줍니다. 

     

     

    결과

    결과는 만족스럽습니다! :D 예상한대로 반응형 화면까지 정상 동작합니다.

     

     

Designed by Tistory.