React에서 Material Ripple Effect를 커스텀 훅으로 만들기

건둔덕 ·2023년 9월 8일
12

Design System

목록 보기
3/3
post-thumbnail

시작하기 전에 🛫

회사에서 디자인시스템을 만들고 있는 어느 날...
디자인 시스템내에 Button 컴포넌트의 클릭 효과를 *MUI 처럼 만들어 달라는 요청이 들어왔다.

버튼을 클릭할 때 클릭한 마우스 커서의 좌표값에 맞춰서 물결이 퍼지는 효과를 만들어야 했다.

*MUI란?
본래의 이름은 Material-UI라고 불렸었고, React 기반의 UI 라이브러리이다.
MUI는 Google의 Material Design 가이드라인을 따르고 있으며, React를 사용하는 개발에 필요한 다양한 UI 컴포넌트를 제공해준다.


시작하기 🛠️

- CSS Animation & 가상 요소

처음에는 CSS의 Animation 기능과 가상 요소를 사용해서 간단하게 구현해보려고 했다.

// Button.tsx
type Offset = { y: number; x: number }

const defaultOffset = { x: 0, y: 0 }

const [offset, setOffset] = useState<Offset>(defaultOffset)
const [isClick, setIsClick] = useState<boolean>(false)

const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  setOffset({ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY })
}

useEffect(() => {
  if (offset.x && offset.y) setIsClick(true)

  const restartTimer = setTimeout(() => setIsClick(false), 500)
  return () => clearTimeout(restartTimer)
}, [offset])

return (
  <ButtonContainer
    data-click={isClick}
    offset={offset}
    onClick={handleClick}
    >
    Button
  </ButtonContainer>
)

Button 컴포넌트에서는 offsetisClick 을 만들어 state 관리를 하고 offset 값을 의존하는useEffect를 생성해서 offset값을 변경해주고 버튼 클릭 효과가 끝났을 시점에 맞춰서 isClick의 값을 다시 false로 변경시켜서 초기화 시켜줬다.


//styles.ts
position: relative;
overflow: hidden;

@keyframes clickEffect {
  to {
    transform: scale(4);
    opacity: 0;
  }
}

&::after {
  content: '';
  position: absolute;
  left: ${({ offset }) => offset.x}px;
  top: ${({ offset }) => offset.y}px;
  width: 100%;
  height: 100%;
  background-color: #fff;
  opacity: 30%;
  transform: scale(0);
  border-radius: 50%;
}

&[data-click='true'] {
  &::after {
    animation: clickEffect 0.4s linear;
  }
}

Styleanimation에 사용 할 keyframes를 만들고 data-click='true'일 때, 가상 요소 ::afteranimation을 적용시켰다.
::afterleft, right 값은 Button 컴포넌트에서 만들어뒀던 offset값으로 지정해줬다.

만들어가면서 들었던 생각은 MUI 버튼의 Ripple 효과를 거의 유사하게 css 와 가상 요소(::before, ::after)로 구현하기에는 아래의 문제점들이 마음에 걸렸다.

  • 연속으로 클릭할 때 효과가 일어나지 않는 문제점이 있었다.
  • 위의 문제로 인해 사용자가 클릭할 때, 만족스러운 UX를 제공하기 힘들다.
  • 공용으로 많이 사용되는 Button컴포넌트의 내부에 hook이 너무 많아진다.
  • 가상 요소(::before, ::after)로 만들다보니, JS로 접근이 불가하다.
  • Ripple 효과를 공용으로 사용하기 불편하다.

물론 고민해보면 개선할 수 있는 부분들도 있겠지만, 팀원들과 공유해서 사용하기에는 애매해 보였다.


- MUI

두번째로 시도해보려고 했던 방법은 쉽고 가장 빠른 방법이다.
MUI 라이브러리를 설치해서 디자인 시스템의 Button 컴포넌트의 디자인으로 커스텀하고 사용하는 것이다.

하지만 Button 컴포넌트의 클릭 효과 하나 때문에 MUI를 설치해서 사용하기에는 낭비가 심한 것 같다는 생각이 들었다.

일단 MUI를 설치해서 사용하려면 위의 이미지에 있는 3개의 패키지를 설치해야하고,

MUI 패키지 하나의 용량만봐도 10.2MB나 된다.

이러한 이유 때문에 두번째로 시도했던 방법도 패스!


- Custom Hook

세번째로 시도한 방법은 useDebounceuseRipple 을 만들어서 훅으로 사용하는 방법이다.

useDebounce

Debounce란?

많은 양의 이벤트가 일어나는 부분에서 마지막으로 발생한 이벤트를 처리하거나, 주어진 시간 동안 발생한 이벤트 중 마지막 이벤트만 처리하는 방식이다.

이해를 돕기위해 Debounce의 예시를 하나 들어보자.

사용자가 검색어를 입력하는 상황에서 실시간으로 결과를 불러오는 것은 네트워크 리소스를 낭비할 수 있는데 Debounce 처리를 하게되면 사용자가 글자를 모두 입력한 후 일정 시간동안 추가 입력이 없을 때 한 번만 네트워크에 요청을 보내므로, 서버에 부담을 최소화 할 수 있다.


// hooks/useDebounce.ts
const useDebounce = <T>(value: T, delay?: number): T => {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500)
    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

export default useDebounce

위의 코드는 내가 커스텀 훅으로 만든 useDebounce의 소스인데, value 인자의 타입을 제네릭으로 설정해서 넘어오는 타입의 값으로 알아서 맞춰서 설정할 수 있게 코드를 작성해 재사용성을 향상시켰다.


useRipple

Material Ripple이란?

사용자의 상호작용에서 시각적인 피드백을 명확하게 제공해주는 디자인 요소이다.
사용자가 Ripple 효과가 적용되어 있는 요소를 터치하거나 클릭했을 때, 클릭 지점으로부터 원형의 물결(Ripple)이 확산되는 것처럼 보이는 애니메이션 효과가 실행된다.

간단하게 설명하자면,
사용자가 버튼을 클릭 했을 때 그 버튼을 눌렀다는 것을 명확하게 알려줘야 하는데, Ripple효과는 이를 위한 하나의 방법이라고 볼 수 있다.

Ripple 효과는 결국 사용자에게 더 나은 UX를 제공하기 위해 사용한다.


// hooks/useRipple.tsx
const useRipple = <T extends HTMLElement>(ref: React.RefObject<T>, bgColor?: string) => {
  const [ripples, setRipples] = useState<React.CSSProperties[]>([])
  useEffect(() => {
    if (ref.current) {
      const elem = ref.current

      const clickHandler = (e: MouseEvent) => {
        let rect = elem.getBoundingClientRect()
        let left = e.clientX - rect.left
        let top = e.clientY - rect.top

        const width = elem.clientWidth
        const height = elem.clientHeight
        const maxSize = Math.max(width, height)

        setRipples([
          ...ripples,
          {
            left: left - maxSize / 2,
            top: top - maxSize / 2,
            width: maxSize,
            height: maxSize,
          },
        ])
      }

      elem.addEventListener('click', clickHandler)

      return () => {
        elem.removeEventListener('click', clickHandler)
      }
    }
  }, [ref, ripples])

  const _debounced = useDebounce(ripples, 1000)

  useEffect(() => {
    if (_debounced.length) setRipples([])
  }, [_debounced.length])

  return ripples.map((style, index) => (
    <RippleStyled key={index} style={{ ...style, backgroundColor: bgColor ?? 'white' }} />
  ))
}

export default useRipple

const RippleStyled = styled.span`
  @keyframes rippleEffect {
    to {
      transform: scale(4);
      opacity: 0;
    }
  }

  position: absolute;
  opacity: 30%;
  transform: scale(0);
  animation: rippleEffect 0.6s linear;
  border-radius: 50%;
`

useRipple 을 사용할 때 편리하게 사용하기위해 넘겨 받는 인자는 최소화 했다.

Ripple 효과를 적용시킬 Element를 참조해야하기 때문에 필수 인자 ref와 Ripple 효과의 색상을 커스텀하기 위해 받는 선택 인자 bgColor를 선언해 두었다.

그 후 넘겨 받은 ref*getBoundingClientRect() 메소드를 사용해서 Ripple 효과를 적용시킬 요소의 상대적인 좌표값(rect.left, rect.top)을 구하고, 클릭된 마우스 포인터의 좌표값(e.clientX, e.clientY)을 빼서 해당 요소 범위 안에서의 좌표값을 계산해주고 해당 요소의 크기를 구해서 ripples의 state값을 업데이트 시켜줬다.

그리고 미리 만들어둔 useDebounce 를 사용해 ripple 효과가 끝나는 시점에 맞춰 ripples의 state를 비워서 만들어진 요소를 삭제시켜줬다.



사용하기 🛬

Ripple 효과 적용

// Button.tsx
const Button = (
  ({ label, ...props }: MaterialButtonProps) => {
    const buttonRef = useRef<HTMLButtonElement>(null)
    const ripples = useRipple(buttonRef)

    return (
      <ButtonContainer
        ref={buttonRef}
        label={label}
        {...props}
      >
        <span>{label}</span>
        {ripples}
      </ButtonContainer>
    )
  },

이제 ripple 효과를 적용 시킬 때 간편하게 사용할 수 있다.

ripple 효과를 적용 할 컴포넌트에 만들어둔 useRipple을 가져와서 ref 를 넘기고, return 되는 값을 적용 시킬 요소 자식으로 넣어주기만 하면 된다!


🚨 ref 동기화

Input이나 Button 컴포넌트를 만들어두고 사용하는 경우에는 외부에서 ref를 참조하는 상황이 생길수도 있기 때문에,
forwardRef로 감싸주는 경우가 많다. 하지만 그렇게되면 ref가 두 개가 존재하게 되면서 위의 방식으로는 문제가 생기게 된다.

// Button.tsx
const Button = forwardRef<HTMLButtonElement, MaterialButtonProps>(
  ({ label, ...props }, forwardRef) => {
    const internalRef = useRef<HTMLButtonElement>(null)
    const ripples = useRipple(internalRef)

    useImperativeHandle(forwardRef, () => internalRef.current!)

    return (
      <ButtonContainer
        ref={internalRef}
        label={label}
        {...props}
      >
        <span>{label}</span>
        {ripples}
      </ButtonContainer>
    )
  },
)

해결방법으로는 외부에서 들어오는 ref와 내부의 ref를 서로 동기화를 시켜주면된다.
이 때 ref를 동기화 시켜주기 위해 사용하는 훅이 *useImperativeHandle()이다.

useImperativeHandle이란?
React의 Hook 중 하나로, 부모 컴포넌트에서 ref를 통해 자식 컴포넌트에 접근할 때 사용할 수 있는 메서드들과 값들을 노출할 수 있게 해줍니다. 하지만 React의 선언적 패러다임과는 상반되는 명령형 훅 이기 때문에 특별한 경우가 아니고서는 사용을 자제하는 것이 좋다.

useImperativeHandle의 첫번째 인자로 외부에서 들어오는 ref 를 넣어주고, 두번째 인자로는 return 값이 있는 함수를 넣어줘야 하는데, 이 return 값에 내부에서 만들어둔 ref 의 current 값을 넘겨주면 동기화를 시켜줄 수 있다.

💡 internalRef.current! 이 부분에서 !internalRef.current의 값이 null 또는 undefined가 아니라고 TSC(TypeScript Compiller)에게 말해주는 기능을 한다.


잘못된 부분이나 개선과 관련된 의견은 너무 좋아용 🥹

profile
건데브

0개의 댓글