회사에서 디자인시스템을 만들고 있는 어느 날...
디자인 시스템내에 Button
컴포넌트의 클릭 효과를 *MUI 처럼 만들어 달라는 요청이 들어왔다.
버튼을 클릭할 때 클릭한 마우스 커서의 좌표값에 맞춰서 물결이 퍼지는 효과를 만들어야 했다.
*MUI란?
본래의 이름은 Material-UI라고 불렸었고, React 기반의 UI 라이브러리이다.
MUI는 Google의 Material Design 가이드라인을 따르고 있으며, React를 사용하는 개발에 필요한 다양한 UI 컴포넌트를 제공해준다.
처음에는 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 컴포넌트에서는 offset
과 isClick
을 만들어 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;
}
}
Style은 animation
에 사용 할 keyframes
를 만들고 data-click='true'
일 때, 가상 요소 ::after
에 animation
을 적용시켰다.
::after
의 left
, right
값은 Button 컴포넌트에서 만들어뒀던 offset
값으로 지정해줬다.
만들어가면서 들었던 생각은 MUI 버튼의 Ripple 효과를 거의 유사하게 css
와 가상 요소(::before, ::after)로 구현하기에는 아래의 문제점들이 마음에 걸렸다.
hook
이 너무 많아진다.물론 고민해보면 개선할 수 있는 부분들도 있겠지만, 팀원들과 공유해서 사용하기에는 애매해 보였다.
두번째로 시도해보려고 했던 방법은 쉽고 가장 빠른 방법이다.
MUI 라이브러리를 설치해서 디자인 시스템의 Button 컴포넌트의 디자인으로 커스텀하고 사용하는 것이다.
하지만 Button 컴포넌트의 클릭 효과 하나 때문에 MUI를 설치해서 사용하기에는 낭비가 심한 것 같다는 생각이 들었다.
일단 MUI를 설치해서 사용하려면 위의 이미지에 있는 3개의 패키지를 설치해야하고,
MUI 패키지 하나의 용량만봐도 10.2MB나 된다.
이러한 이유 때문에 두번째로 시도했던 방법도 패스!
세번째로 시도한 방법은 useDebounce
와 useRipple
을 만들어서 훅으로 사용하는 방법이다.
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
인자의 타입을 제네릭으로 설정해서 넘어오는 타입의 값으로 알아서 맞춰서 설정할 수 있게 코드를 작성해 재사용성을 향상시켰다.
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를 비워서 만들어진 요소를 삭제시켜줬다.
// 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 되는 값을 적용 시킬 요소 자식으로 넣어주기만 하면 된다!
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)에게 말해주는 기능을 한다.
잘못된 부분이나 개선과 관련된 의견은 너무 좋아용 🥹