Published on

Create Native App Like Image Slider Component

Authors
  • avatar
    Name
    Kim, Dong-Wook
    Twitter
Table of Contents

Intro

오늘은 소프트웨어 마에스트로에서 진행중인 With You 프로젝트에서 사용할 이미지 슬라이더를 어떻게 제작했는지 소개하려고 합니다. 먼저 해당 프로젝트는 Next.js를 사용중에 있으며, Framer-motion을 사용하여 애니메이션을 구현하였습니다. 만약 해당 글에서 공유될 소스코드를 사용하고 싶다면 우선 framer-motion 라이브러리를 설치하시길 바랍니다 :)

구현

고민 과정

image 해당 컴포넌트의 디자인은 디자이너 분께서 위와 같이 제공해주셨습니다. 제공해주신 디자인을 보고 먼저 살짝 당황했던 것은, 한 번도 구현해본 적이 없는 디자인이라는 것이었습니다. 캐러셀이나 아코디언, 멀티아이템 슬라이더 등등 여러가지 슬라이더를 구현해본 경험이 있지만, 이런 디자인은 직접 구현해본 적이 없었습니다. 따라서 다음과 같은 고민이 있었습니다.

  1. 여러 아이템이 존재하면서, 하나의 요소만 가운데 정렬해서 보여지는 슬라이더를 어떻게 구현할 것인가?
  2. Pagination을 해야할 것 같은데 하나의 요소씩 자연스럽게 transition되는 애니메이션을 어떻게 구현할 것인가?
  3. 날짜마다 다른 Card Component를 보여주어야하는데, 이를 어떻게 최적화 할 것인가?

해결 과정 - 1

먼저 위의 문제를 해결하기 위해서 기존에 구현했던 Single Item Carousel 컴포넌트를 참조했습니다.

import React, { Children, FC, Fragment, useMemo } from 'react'
import { AnimatePresence, motion, Variants } from 'framer-motion'
import { defaultCarouselVars } from '@src/animations/carousel'
import cx from 'classnames'

const swipePower = (offset: number, velocity: number) => {
  return Math.abs(offset) * velocity
}

const Carousel: FC<{
  children: React.ReactNode
  selectedPage: number
  direction: number
  swipeConfidenceThreshold?: number
  draggable?: boolean
  removeArrow?: boolean
  animateVar?: Variants
  onPageChange: (idx: number, pageDir: number) => void
}> = ({
  children,
  selectedPage,
  direction,
  swipeConfidenceThreshold = 10000,
  removeArrow = false,
  animateVar = defaultCarouselVars,
  draggable = false,
  onPageChange,
}) => {
  const childArray = useMemo(() => Children.toArray(children), [children])

  const paginate = (newDirection: number) => {
    const nextPage = (selectedPage + newDirection) % childArray.length
    const nextPageIndex = nextPage < 0 ? childArray.length - 1 : nextPage
    onPageChange(nextPageIndex, newDirection)
  }

  return (
    <div className="relative flex h-full w-full items-center justify-center overflow-scroll">
      <AnimatePresence initial={false} custom={direction} exitBeforeEnter>
        <motion.div
          key={selectedPage}
          className="absolute h-full w-full"
          custom={direction}
          variants={animateVar}
          initial="enter"
          animate="center"
          exit="exit"
          transition={{
            x: { stiffness: 200, damping: 20 },
            opacity: { duration: 0.5 },
          }}
          {...(draggable && {
            drag: 'x',
            dragElastic: 0.5,
            dragConstraints: { left: 0, right: 0 },
            dragMomentum: false,
            onDragEnd: (event, { offset, velocity }) => {
              const swipe = swipePower(offset.x, velocity.x)
              console.log(velocity)
              if (swipe < -swipeConfidenceThreshold) {
                paginate(1)
              } else if (swipe > swipeConfidenceThreshold) {
                paginate(-1)
              }
            },
          })}
        >
          {childArray[selectedPage]}
        </motion.div>
      </AnimatePresence>
      <Fragment>
        <div
          className={cx(
            'absolute top-[calc(50%-20px)] right-3 z-[2] hidden h-10 w-10 cursor-pointer select-none items-center justify-center rounded-full bg-white text-2xl font-bold dark:bg-gray-800 dark:text-white md:flex',
            removeArrow ? 'md:hidden' : null
          )}
          onClick={() => paginate(1)}
        >
          {'‣'}
        </div>
        <div
          className={cx(
            'translate absolute top-[calc(50%-20px)] left-3 z-[2] hidden h-10 w-10 -scale-100 cursor-pointer select-none items-center justify-center rounded-full bg-white text-2xl font-bold dark:bg-gray-800 dark:text-white md:flex',
            removeArrow ? 'md:hidden' : null
          )}
          onClick={() => paginate(-1)}
        >
          {'‣'}
        </div>
      </Fragment>
    </div>
  )
}

export default Carousel

기존에도 애니메이션을 코드를 css가 아닌 코드단위로 관리하기 위해서 Framer-motion을 사용했었는데, 이번에도 동일하게 사용했습니다. (단 성능이 높이 필요한 프로젝트에서는 권장하지 않습니다).

위의 코드를 간단히 설명드리자면, 부모로부터 넘겨받은 children을 Children API를 통해 배열로 변환한 후, 해당 배열의 인덱스를 기준으로 애니메이션을 구현했습니다. AnimatePresence라는 Framer-motion에서 제공된 Wrapper를 사용하면 exit 애니메이션을 쉽게 구현할 수 있습니다. 따라서 paginate할때 선택된 child가 exit되고, 다음 child가 enter되는 애니메이션을 쉽게 구현할 수 있습니다. 위의 코드를 통해 구현된 애니메이션은 아래와 같습니다.

carousel-anim

해결 과정 - 2

하지만 위의 Carousel 컴포넌트는 animation은 잘 작동하지만, paginate에 해당하는 children 배열 중 하나를 단순히 보여주고, 다음 child가 선택되면 기존 child를 제거하는 방식이기 때문에, 저희 앱에서 제작하려는 양 옆에 아이템이 보이는 형태의 Carousel을 구현하기에는 부족했습니다. 따라서 우선 위의 코드를 통해 전통적인 Slider를 구현하고, 여기에 paginate를 구현했습니다.

function Carousel() {
  const [width, setWidth] = useState(0)
  const carousel = useRef()

  useEffect(() => {
    setWidth(carousel.current.scrollWidth - carousel.current.offsetWidth)
  }, [])

  return (
    <div>
      <motion.div
        ref={carousel}
        className="cursor-grab overflow-hidden"
        whileTap={{ cursor: 'grabbing' }}
      >
        <motion.div drag="x" dragConstrains={{ right: 0, left: -width }} className="flex bg-white">
          {images.map((image) => {
            return (
              <motion.div className="min-h-[40rem] min-w-[30rem] p-2.5" key={image}>
                <img className="pointer-events-none h-full w-full rounded-lg" src={image} alt="" />
              </motion.div>
            )
          })}
        </motion.div>
      </motion.div>
    </div>
  )
}

코드를 간단히 설명하자면, props로 받은 images 배열을 map을 통해 순환하며 각각의 이미지를 Slider의 child로 넣어주고, 부모 태그의 ref를 통해 부모의 width를 구한 후, dragConstrains를 통해 drag가 가능한 범위를 제한해주었습니다. 원래 drag 기능을 구현하려면 매우 복잡한 코드를 작성해야하지만, Framer-motion에서 제공하는 drag 기능을 사용하면 매우 간단하게 구현할 수 있습니다.

framer-slider

해결 과정 - 3

하지만 위의 Slider는 저희가 원하는 paginate가 가능한 Slider가 아닙니다. child로 넘겨진 모든 이미지가 한번에 보여지고, 이를 단순히 drag하여 이동시키는 방식이기 때문입니다. 먼저 Slider를 기반으로 paginate를 구현하기 위해, 다음과 같은 시행착오를 겪었습니다

  1. 각 child array의 요소들에 대한 useRef([])를 만들고 paginate useState가 변경될 때마다 scrollTo를 통해 부모 태그를 이동시키는 방식 -> 이 방식은 요소가 가운데 정렬되지 않아서 사용하지 않았습니다.
  2. Single Item Carousel를 기본적으로 사용하고, 양 옆 요소들에 대해 absolute position을 주어서 보여주는 방식 -> 매우 복잡하고, 코드 관리에 어려움이 있어서 시도조차 하지 않았습니다.

각자 문제가 있었고 결과적으로 다음과 같은 방식으로 해결했습니다.

  1. 부모 태그를 overflow-visible로 설정, item들을 wrapping하는 자식 태그의 width를 부모 태그의 (5/6)%만 사용하도록 설정
  2. paginate useState에 의해 선택된 item은 "mx-auto w-full h-full" 태그를 통해 가운데 정렬되도록 설정

이렇게 되면 선택된 아이템이 본인이 차지할 수 있는 공간을 모두 차지하게 되고, 남은 (1/6)%의 공간에 양끝 요소들이 나타나게 됩니다. 단, 좀 더 명확하게 보여주기 위해서 framer-motion의 scale속성을 이용해서 선택된 아이템을 조금 더 크게 보여주도록 했습니다. 코드는 다음과 같습니다.

import { swipePower } from '@src/utils/framerUtil'
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react'

import InfiniteSliderItem from './InfiniteSliderItem'
import InfiniteSliderItemWrapper from './InfiniteSliderItemWrapper'

const InfiniteSlider: FunctionComponent<{
  enableInfinite?: boolean
  enableDot?: boolean
}> = ({ enableInfinite, enableDot }) => {
  const controls = useAnimation()
  const infiniteSliderRef = useRef<HTMLDivElement>(null)
  const sliderItemnRefs = useRef<HTMLDivElement[]>([])
  const [selectedPage, setSelectedPage] = useState(0)

  const swipeConfidenceThreshold = useMemo(() => 10000, [])
  const numOfItems = useMemo(() => 5, [])

  const paginate = (newDirection: number) => {
    let nextPageIndex: number
    let nextPage = selectedPage + newDirection
    if (enableInfinite) {
      nextPage %= numOfItems
      nextPageIndex = nextPage < 0 ? numOfItems - 1 : nextPage
    } else {
      nextPage = nextPage > numOfItems - 1 ? numOfItems - 1 : nextPage
      nextPageIndex = nextPage < 0 ? 0 : nextPage
    }
    setSelectedPage(nextPageIndex)
  }

  useEffect(() => {
    controls.start('next')
  }, [controls, selectedPage])

  return (
    <AnimatePresence initial={false} exitBeforeEnter>
      <motion.div className="flex cursor-grab select-none justify-center overflow-visible">
        <motion.div
          variants={{
            next: {
              x: -sliderItemnRefs.current[0]?.offsetWidth * selectedPage,
            },
          }}
          transition={{ duration: 0.5, stiffness: 100 }}
          animate={controls}
          ref={infiniteSliderRef}
          drag="x"
          dragConstraints={{
            left: -sliderItemnRefs.current[0]?.offsetWidth * selectedPage,
            right: -sliderItemnRefs.current[0]?.offsetWidth * selectedPage,
          }}
          dragElastic={0.5}
          dragMomentum={false}
          onDragEnd={(event, { offset, velocity }) => {
            const swipe = swipePower(offset.x, velocity.x)
            if (swipe < -swipeConfidenceThreshold) {
              paginate(1)
            } else if (swipe > swipeConfidenceThreshold) {
              paginate(-1)
            }
          }}
          className="flex w-5/6"
        >
          {Array(numOfItems)
            .fill(0)
            .map((_, idx) => {
              return (
                <InfiniteSliderItemWrapper
                  key={`infinite-slider-${idx}`}
                  ref={(el) => {
                    sliderItemnRefs.current[idx] = el
                  }}
                  selected={selectedPage === idx}
                >
                  <InfiniteSliderItem />
                </InfiniteSliderItemWrapper>
              )
            })}
        </motion.div>
      </motion.div>
      {enableDot && (
        <div className="flex justify-center space-x-2 pt-2">
          {Array(numOfItems)
            .fill(0)
            .map((_, idx) => {
              return (
                <div
                  key={`infinite-slider-dot-${idx}`}
                  className={`h-2 w-2 rounded-full ${
                    selectedPage === idx ? 'bg-black' : 'bg-gray-300'
                  }`}
                />
              )
            })}
        </div>
      )}
    </AnimatePresence>
  )
}

export default InfiniteSlider

아직 목업코드만 완성된 상태여서 임의의 데이터를 넣어서 만든 목업 코드입니다. 여기서 중요한 점은, "dragConstraints" 속성과 "variants" 속성입니다.

자연스러운 Drag Animation을 위해 paginate가 될때마다 dragConstraints를 선택된 item의 offsetWidth만큼만 움직일 수 있도록 설정해주었습니다. 또한 paginate useState가 변경될 때마다 controls Hooks를 통해 variants에 정의된 next 애니메이션을 실행시켜서 선택된 item의 offset으로 이동하도록 설정해주었습니다. 이렇게 되면 자연스러운 Drag Animation을 구현할 수 있습니다. 가장 핵심은 controls Hooks를 이용해서 variants에 정의된 애니메이션을 실행시키는 것입니다.

drag-anim

결론

Web의 HTML, CSS, Javascript만 이용해서 Animation을 구현하는 것은 쉽지 않습니다. 또한 Web에서는 어려운 컴포넌트가 Native에서는 쉽게 구현되는 경우가 많습니다. 하지만 배포, 접근성 등의 이유로 WebView를 이용해서 Native와 같은 UX를 구현하고자 할 때가 있습니다. 이럴 때는 상대적으로 쉽게 Animation을 구현하기 위해 라이브러리를 사용하는걸 추천드립니다.