코린이의 개발 일지

[디자인 시스템] Carousel 컴포넌트 구현하기 본문

웹 (web)/CDS 프로젝트

[디자인 시스템] Carousel 컴포넌트 구현하기

폴라민 2023. 4. 6. 20:33
반응형

부스트 캠프 같이 했던 분들이랑 함께 디자인 시스템 개발 프로젝트를 진행 중이다.

진행하면서 맡은 컴포넌트 중에 가장 많은 고민을 했던 컴포넌트 개발 과정을 정리해보려 한다.

 

 

1. 디자인 시스템에서 컴포넌트

디자인 시스템에서 컴포넌트를 개발할 때는 정해져 있는 프로덕트 내에서 컴포넌트를 개발할 때와는 사뭇 다르다.

디자인 시스템에서의 컴포넌트는 다양한 상황에 대응 가능해야한다.

 

형태가 다양할 수도 있고, 동일 컴포넌트가 지원해야 하는 기능이 여러가지 일 수 있다.

 

동일한 디자인 컨셉을 가져가면서, 사용하는 상황에 맞춰 어느정도 유연하게 형태를 변경하여 사용할 수 있도록 하는 것이 목표이다.

Carousel의 형태를 살펴보자.

 

2. Carousel의 기능과 형태

Carousel의 형태는 여러가지가 있다.

 

왼쪽의 경우 가로 폭을 꽉 채운 슬라이딩 형태의 Carousel이고, 오른쪽의 경우 가로스크롤을 통해 카드를 한장씩 넘겨 보는 Carousel이다.

 

형태를 보았으니 Carousel의 기능을 살펴보아야한다.

 

Carousel 기능

Carousel에 반드시 들어가야 할 기능으로 나는 다음 세가지를 고려했다.

  • 마우스로 조작 가능한 네비게이션 버튼: 네비게이션 컨트롤이 있음을 캐러셀 내부에서 명확하게 보여주어야 한다.
  • 가로스크롤: 모바일에서 스크롤을 고려해 주어야한다.
  • 진행률 표시: 스크롤을 하기전에 가려지는 컨텐츠들이 있기 때문에, 총 몇개의 컨텐츠가 있는지를 명확하게 보여주어야 한다.

 

디자인 시스템을 사용하는 입장에서 Carousel 컴포넌트 하면 떠오르는 기본적인 기능들은 갖추면서 위의 두가지 형태를 갖추도록 구현해보자.

 

3. Interface

Carousel 컴포넌트 내에서 또 몇개의 컴포넌트가 필요할 지 생각해보자.

컨텐츠 각각을 담을 컴포넌트, 각각의 컨텐츠들의 layout을 잡아줄 컴포넌트.

크게 이렇게 두가지의 컴포넌트가 필요할 것이다. 그렇다면 사용하는 Interface도 비슷한 형태가 직관적으로 사용할 수 있을 것이다.

 

// Interface

<Carousel>
  <Carousel.Card>
    // 캐러셀 내부 컨텐츠
  </Carousel.Card>
  <Carousel.Card>
    // 캐러셀 내부 컨텐츠
  </Carousel.Card>
</Carousel>

 

내가 구현할 Carousel 형태는 위의 사진처럼 Sliding 형태, Card 형태 이렇게 두가지이다. 

 

위의 두가지는 컨텐츠를 담는 형태만 다르고 가로스크롤이 된다는점, 진행률을 표시할 것이라는 점에서 동일하기 때문에 외부 layout을 잡아주는 컴포넌트는 동일한 컴포넌트를 사용할 것이다.

 

내부 컨텐츠를 담는 자식 컴포넌트만 따로 Slide 와 Card로 분리해서 구현하였다.

// Card layout Interface

<Carousel>
  <Carousel.Card>
    // 캐러셀 내부 컨텐츠
  </Carousel.Card>
  <Carousel.Card>
    // 캐러셀 내부 컨텐츠
  </Carousel.Card>
</Carousel>

// Slide layout Interface

<Carousel>
  <Carousel.Slide>
    // 캐러셀 내부 컨텐츠
  </Carousel.Slide>
  <Carousel.Slide>
    // 캐러셀 내부 컨텐츠
  </Carousel.Slide>
</Carousel>

 

4. Implementation

기본 형태 잡기

기본 형태를 잡고 그저 가로스크롤만 동작하게 하는 것은 사실 그리 어렵지 않다.

// Carousel component

const CarouselContext = createContext<CarouselContextInterface | null>(null);

const Carousel = ({ children }: CarouselProps) => {
  // Card layout 잡아줄 상수들
  const { cardWidth, gap, cardHeight, translateX } = getCardSize({
    height,
    width,
  });

  const contextValues = {
    cardWidth,
    cardHeight,
    gap,
    translateX,
  };
  
  return (
      <CarouselContext.Provider value={contextValues}>
        <Container>
          <ItemList ref={scrollRef} onScroll={scrollEventHandler}>
              <InlineLayout ref={cardsRef}>
                {children}
              </InlineLayout>
          </ItemList>
        </Container>
      </CarouselContext.Provider>
  );
}
// Card Component

const Card = ({ children }: DefaultPropsWithChildren<HTMLDivElement>) => {
  const { cardWidth, gap, cardHeight, translateX } =
    useSafeContext(CarouselContext);
  return (
    <CardView {...{ cardWidth, gap, cardHeight, translateX }}>
      <div style={{ transform: `translateX(${translateX}px)`, width: '100%' }}>
        {children}
      </div>
    </CardView>
  );
};
    
    
// Slide Component

const Slide = ({ children }: DefaultPropsWithChildren<HTMLDivElement>) => {
  const { cardHeight } = useSafeContext(CarouselContext);
  return <SlideView {...{ cardHeight }}>{children}</SlideView>;
};

 

 

layout은 위와 같은 형태면 충분하다. (css는 굳이 넣지 않았다.)

사용자가 원하는 컨텐츠 형태에 따라서 Carousel 컴포넌트 안에 원하는 형태의 컴포넌트를 자식 컴포넌트를 넣어주면 된다.

 

이제 기능을 덧붙여 보자.

 

네비게이션 버튼

가로 스크롤과 네비게이션 버튼을 함께 사용할 예정이라 Scroll Snap API를 사용했다.

cssMode 옵션을 사용하여 swiper를 간단히 구현할 수 있는 API이다.

https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type

 

scroll-snap-type - CSS: Cascading Style Sheets | MDN

The scroll-snap-type CSS property sets how strictly snap points are enforced on the scroll container in case there is one.

developer.mozilla.org

 

가로 스크롤을 하는 element에 다음과 같이 css를 지정해준다.

const ItemList = styled.div`
  overflow-x: scroll;
  width: 100%;
  display: inline-flex;
  scroll-snap-type: x mandatory;
`;

 

그리고 ItemList 자식 컴포넌트로 들어갈 각각의 Card 혹은 Slice 컴포넌트에 scroll-snap-align을 지정해주면 된다.

const CardView = styled.div`
  ...
  scroll-snap-align: start;
`;

const SlideView = styled.div`
  ...
  scroll-snap-align: start;
`;

 

네비게이션 버튼을 추가하고 이벤트를 발생시켜보자.

// Carousel component

const Carousel = ({ children }: CarouselProps) => {
  // 생략
  
  const scrollToPage = throttle((current: number) => {
    setTimeout(() => {
      cardsRef.current?.children[current * line].scrollIntoView({
        inline: 'start',
        behavior: 'smooth',
      });
    });
  }, 500);
  
  return (
      <CarouselContext.Provider value={contextValues}>
        <Container>
          // 생략
          <NavigationContainer>
            <NavigationButton
              onClick={() => scrollToPage(currentPage - 1)}
              disabled={currentPage === 0}
            >
              <MdArrowBackIosNew />
            </NavigationButton>
            <NavigationButton
              onClick={() => scrollToPage(currentPage + 1)}
              disabled={currentPage === totalSlide - 1 || totalSlide === 0}
            >
              <MdArrowForwardIos />
            </NavigationButton>
          </NavigationContainer>
        </Container>
      </CarouselContext.Provider>
  );
}

 

throttle을 걸어준 이유는 연속으로 버튼 클릭시 page 상태가 뒤죽박죽 되는 것을 방지하기 위해서이다.

scrollIntoView에 setTimeout을 걸어준 이유는 scroll-behavior: smooth 관련해서 크롬 브라우저 버그때문이다.

 

그럼 이제 진행률을 표시해주는 Progress bar를 구현해보자

 

Progress Bar

컨텐츠를 다음으로 옮기면 진행 상태를 바꿔준다.

이때, 네비게이션 버튼으로 이동하는게 아닌, scroll event가 발생해도 진행 상태가 현재 카드에 맞춰 바뀌어야 하기 때문에 scrollEventHandler에서 현재 페이지 값을 계산해서 상태를 변경 시켜주었다.

// Carousel component

const Carousel = ({ children }: CarouselProps) => {
  // 생략
  const [currentPage, setCurrentPage] = useState(0);
  
  const scrollEventHandler = debounce(() => {
    const currentLeft = scrollRef.current ? scrollRef.current.scrollLeft : 0;
    setCurrentPage(Math.floor(currentLeft / (cardWidth + gap)));
  }, 200);
  
  return (
      <CarouselContext.Provider value={contextValues}>
        // 생략
        <Center>
            <Progress isSlide={cardWidth === window.innerWidth}>
              {Array.from({ length: totalSlide }).map((item, index) => (
                <Dot
                  key={index}
                  current={index === currentPage}
                />
              ))}
            </Progress>
         </Center>
      </CarouselContext.Provider>
  );
}

 

 

Dummy slide 추가

 

애플 공식 홈페이지를 보면 위처럼 마지막 카드는 앞까지 가지 않는다.

팀원들과 얘기할 때, 모든 카드가 맨 앞에 정렬되도록 맞췄으니 마지막 카드도 맨 앞까지 가도록 하는 것이 통일성 있겠다는 결론이 나와서 뒤에 dummy slide 를 추가해주기로 했다.

 

scroll snap api로 구현하게 되면 기본적으로 최대 가로스크롤 가능한 영역까지만 이동되기 때문에, 임의로 공간을 만들어 주어야 한다.

 

여기서 sliderWidth는 현재 viewport에 보이는 캐러셀의 가로 폭이다.

viewport에 보이는 캐러셀의 가로 폭만큼의 공간을 만들어 주었다.

const Carousel = ({ children, width, height }: CarouselProps) => {
  const [currentPage, setCurrentPage] = useState(0);
  const [sliderWidth, setSliderWidth] = useState(0);
  const scrollRef = useRef<HTMLDivElement>(null);
  const cardsRef = useRef<HTMLDivElement>(null);
  const totalChildren = Children.count(children);
  
  // 생략 
  
  return (
    <CarouselContext.Provider value={contextValues}>
      <Container css={{ display: 'flex', flexDirection: 'column' }}>
        <Container
          css={{
            position: 'relative',
            display: 'flex',
          }}
        >
          <ItemList ref={scrollRef} onScroll={scrollEventHandler}>
              <InlineLayout ref={cardsRef}>
                {children}
                <DummySlide cardWidth={sliderWidth} />
              </InlineLayout>
          </ItemList>
          // 네비게이션 버튼
        </Container>
        // progress bar
      </Container>
    </CarouselContext.Provider>
  );
};

 

 

5. 결과

 

 

6. 회고

구현하면서 사용자에게 어디까지 자율성을 맡길것인가? 를 많이 고민했다.

아래 예시를 보자

각각의 카드는 사이즈 커스텀이 가능하도록 width랑 height를 props로 받는다

아래는 커스텀 사이즈인 캐러셀이다.

 

 

데스크탑에서 볼때는 UI가 괜찮다. 모바일에서 봐보자

애매하게 컨텐츠들이 짤려서 나온다. 이 디자인 컴포넌트를 사용하면서 예상했던 형태가 아니었을 것이다.

이 부분을 해결하기 위해 현재 viewport와 각각 Card 의 width를 고려하여 Card width가 viewport width의 일정 퍼센트 이상을 차지하면 Card가 중앙정렬되도록 예외처리를 해주었다.

 

 

보기에도 훨씬 깔끔하고 좋아졌다.

 

한가지 고민이 되었던 부분은

  • 사용자가 임의로 커스텀해서 넣은 사이즈를 이정도는 알아서 조정해서 배치해도 되는 걸까?

UI가 깨지는걸 의도하는 사용자는 없겠지만 사실 위와같이 사용하는 경우는 그냥 Card 컴포넌트 대신 Slide 컴포넌트 사용해서 Width를 꽉 채워 사용하는 편이 더 좋을 것이다.

 

추상화 레벨을 어디까지 가져갈 것인지, 컴포넌트 커스텀이 어디까지 가능하게 할 것인지는 프로젝트 초반부터 팀원들끼 많이 논의했던 부분인데, 아직 스스로가 명확한 답을 못낸 것 같다.

 

 

7. 링크

배포 링크

  • 아래 링크에 들어가면 스토리북을 통해 구현해놓은 Carousel 예시들을 볼 수 있다.

https://640054c53834f08f15bbad68-vfrhgmjiak.chromatic.com/?path=/story/design-system-components-carousel--default 

 

Webpack App

 

640054c53834f08f15bbad68-vfrhgmjiak.chromatic.com

 

Repository 링크

https://github.com/c-h-w-h/cds

 

GitHub - c-h-w-h/cds: 🧊 차가운 디자인 시스템

🧊 차가운 디자인 시스템. Contribute to c-h-w-h/cds development by creating an account on GitHub.

github.com

 

반응형
Comments