Ripple 효과(트랜지션)를 선언적으로 리팩토링하기

Ripple 효과(트랜지션)를 선언적으로 리팩토링하기

Tags
설계
Published
February 16, 2024
Author
Seongbin Kim
 

1. 결론: 화면을 선언적으로 만들기

React 코드에 포함되면 근본적으로 어색한 코드가 있어요.
선언한 상태에 따라 JSX가 자동으로 생성되는 코드가 아닐 때에요.
JS로 직접 Element를 생성하거나, Style을 생성, 할당하는 경우에요.
 
Vanilla로 구현된 코드 조각들은 대부분 명령형 패러다임을 따라요.
React 코드로 옮겼을 때 어색하지 않으려면 리팩토링이 필요해요.
화면이 상태를 반영하도록 상태를 식별하고, 선언적으로 JSX를 구성해야 돼요.
 

2. Vanilla로 구현된 Ripple 트랜지션

Ripple 트랜지션 구현 코드는 아래 블로그를 참고했어요.
 
Ripple 효과는 간단한 트랜지션과는 다르게 JS가 필요해요.
클릭 지점으로부터 원형으로 퍼지기 때문에요.
Array.from(document.querySelectorAll(".material-ripple")).forEach(a => { a.addEventListener("click", function (e) { const ripple = document.createElement("div"), rect = a.getBoundingClientRect(); ripple.className = "animate", ripple.style.left = `${e.x - rect.left}px`, ripple.style.top = `${e.y - rect.top}px`, ripple.style.background = "rgba(0, 0, 0, 0.2)", // 컬러 설정은 변경했어요. ripple.style.setProperty("--material-scale", a.offsetWidth), a.append(ripple), setTimeout(function () { ripple.parentNode.removeChild(ripple) }, 500) }) })
@keyframes materialRipple { 0% { transform: translate(-50%, -50%) scale(1) } 100% { transform: translate(-50%, -50%) scale(var(--material-scale)); opacity: 0 } } .material-ripple { position: relative; overflow: hidden; } .material-ripple .animate { width: 2px; height: 2px; position: absolute; border-radius: 50%; animation: materialRipple 0.5s linear }
 

3. React로 단순히 가져오기

최대한 단순히 가져오기만 했어요.
 
Ripple 효과를 줄 대상 요소를 가져오는 코드는 어쩔 수 없이 변환했어요.
  • document.querySelectorAll(".material-ripple")) 코드를
  • useRef로 바꿔줬어요.
 
// import, Props 선언 생략 const Button = ({ onClick, ...props }: ButtonProps) => { const ref = useRef<HTMLButtonElement>(null); const showRippleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => { onClick?.(e); if (!ref.current) { return; } const element = ref.current; const elementRect = element.getBoundingClientRect(); const ripple= document.createElement('div'); ripple.className = 'animate'; ripple.style.left = `${e.clientX - elementRect.left}px`; ripple.style.top = `${e.clientY - elementRect.top}px`; ripple.style.background = `rgba(0, 0, 0, 0.2)`; ripple.style.setProperty( '--material-scale', `${element.offsetWidth}`, ); element.append(ripple); setTimeout(() => { if (!elementRipple.parentNode) { return; } ripple.parentNode.removeChild(ripple); }, 400); }; return ( <div onClick={showRippleOnClick} ref={ref} className='material-ripple' {...props} /> ); };
 
아직은 명령 패러다임을 따르는 코드에요.
상태를 식별하고 JSX가 이를 반영토록 해서 선언적으로 바꿔줘야 해요.
 
구체적으로는 아래 함수들이 명령형 패러다임을 나타내요:
  1. document.createElement('div')
  1. style 직접 추가
  1. element.append(ripple);
  1. ripple.parentNode.removeChild(ripple);
 
이 코드들을 상태로 식별하고 JSX로 변환해주면 리팩토링이 끝나요.
 

4. React 방식으로 리팩토링하기

 

4-1. CSS, Style → JSX 리팩토링

style은 JSX로 쉽게 리팩토링할 수 있어요.
 

4-1-1. 단순 CSS, Style → Tailwind

 
리팩토링 전
// 정적인 값 ripple.className = "animate", ripple.style.background = "rgba(0, 0, 0, 0.2)",
.material-ripple { position: relative; overflow: hidden; } .material-ripple .animate { width: 2px; height: 2px; position: absolute; border-radius: 50%; animation: materialRipple 0.5s linear }
 
리팩토링 후
className='ripple-effect absolute h-[2px] w-[2px] rounded-full bg-black bg-opacity-20'
 

4-1-2. 동적 Style

 
위치 정보를 지정하는 코드는 JSX의 style 태그가 최선인 것 같아요.
 
리팩토링 전
// 동적인 값 ripple.style.left = `${e.x - rect.left}px`, ripple.style.top = `${e.y - rect.top}px`, ripple.style.setProperty("--material-scale", a.offsetWidth),
 
리팩토링 후
style={ { '--ripple-element-width': elementWidth, left: `${cursorLeft}px`, top: `${cursorTop}px`, } as React.CSSProperties }
 
컴포넌트로 빼면 아래처럼 될 수 있어요.
const RippleEffect = ({ elementWidth, cursorLeft, cursorTop, }: RippleEffectProps) => ( // Props 생략 <div className='ripple-effect absolute h-[2px] w-[2px] rounded-full bg-black bg-opacity-20' style={ { '--ripple-element-width': elementWidth, left: `${cursorLeft}px`, top: `${cursorTop}px`, } as React.CSSProperties } /> );
 

4-2. 상태를 추출하기

 

4-2-1. 좌표 값 할당 코드를 상태로 추출

Ripple 효과에는 <클릭 시 커서의 위치>와 <Ripple 효과를 보여줄 요소의 width>가 필요해요.
 
이 위치 정보는 클릭 시마다 바뀌기 때문에 매번 새로운 위치로 렌더링해줘야 해요.
 
매번 바뀌기 때문에 상태로 추출하고 이후 JSX로 선언적으로 렌더링할 수 있어요.
 
리팩토링 전
const elementRect = element.getBoundingClientRect(); const ripple= document.createElement('div'); ripple.style.left = `${e.clientX - elementRect.left}px`; ripple.style.top = `${e.clientY - elementRect.top}px`; ripple.style.setProperty( '--material-scale', `${element.offsetWidth}`, );
 
리팩토링 후
const RIPPLE_DEFAULT_OFFSET = { elementWidth: 0, cursorLeft: 0, cursorTop: 0, }; const [offset, setOffset] = useState(RIPPLE_DEFAULT_OFFSET);
  • 클릭 시에는 아래처럼 상태를 변경해요.
const offset = getOffset(e, element); setOffset(offset); setIsAnimating(true);
 

4-2-2. 애니메이션 여부를 상태로 추출

클릭 시 Ripple을 보여주는 div 요소를 생성해야 하고,
Ripple 지속시간 이후 Ripple을 보여주는 div 요소를 제거해야 돼요.
 
이를 위해 애니메이션 여부를 상태로 관리하고, 표시 여부를 토글해줘야 해요.
const [isAnimating, setIsAnimating] = useState(false); setIsAnimating(true); setTimeout(() => { setIsAnimating(false); }, RIPPLE_DURATION);
 

4-2-3. 추출한 상태를 JSX로 렌더링하기

 
위치 정보와 애니메이션 여부를 JSX로 렌더링하면 아래와 같아요.
const Ripple = (/* ... */) => { const ref = useRef<HTMLDivElement>(null); const [isAnimating, setIsAnimating] = useState(false); const [offset, setOffset] = useState(RIPPLE_DEFAULT_OFFSET); const showRipple = (e: React.MouseEvent<HTMLDivElement>) => { /* ... */ }; return ( <div className='...' ref={ref} onClick={showRipple}> {isAnimating && <RippleEffect {...offset} />} {children} </div> ); };
 

5. 리팩토링 완료된 코드

 

5-1. 사용 예시

완성된 코드는 아래처럼 쉽게 사용할 수 있어요.
 
버튼에 Ripple 효과를 주고 싶은 경우, <Ripple><button /></Ripple>로 감싸주기만 하면 돼요.
const Button = ({ variant, ...props }: ButtonProps) => { return ( <Ripple> <button disabled={variant === 'inactive'} className={buttonCSS({ variant })} {...props} /> </Ripple> ); };
 

5-2. 소스 코드

 
Ripple.tsx
import React, { PropsWithChildren, useRef, useState } from 'react'; import RippleEffect from './RippleEffect'; const RIPPLE_DURATION = 500; const RIPPLE_DEFAULT_OFFSET = { elementWidth: 0, cursorLeft: 0, cursorTop: 0, }; const getOffset = (e: React.MouseEvent<HTMLDivElement>, el: HTMLDivElement) => { const { left, top } = el.getBoundingClientRect(); return { elementWidth: el.offsetWidth, cursorLeft: e.clientX - left, cursorTop: e.clientY - top, }; }; const Ripple = ({ children }: PropsWithChildren) => { const ref = useRef<HTMLDivElement>(null); const [isAnimating, setIsAnimating] = useState(false); const [offset, setOffset] = useState(RIPPLE_DEFAULT_OFFSET); const showRipple = (e: React.MouseEvent<HTMLDivElement>) => { if (!ref.current) { return; } const element = ref.current; const offset = getOffset(e, element); setOffset(offset); setIsAnimating(true); setTimeout(() => { setIsAnimating(false); }, RIPPLE_DURATION); }; return ( <div className='relative overflow-hidden' ref={ref} onClick={showRipple}> {isAnimating && <RippleEffect {...offset} />} {children} </div> ); }; export default Ripple;
 
RippleEffect.tsx
export interface RippleEffectProps { elementWidth: number; cursorLeft: number; cursorTop: number; } const RippleEffect = ({ elementWidth, cursorLeft, cursorTop, }: RippleEffectProps) => ( <div className='ripple-effect absolute h-[2px] w-[2px] rounded-full bg-black bg-opacity-20' style={ { '--ripple-element-width': elementWidth, left: `${cursorLeft}px`, top: `${cursorTop}px`, } as React.CSSProperties } /> ); export default RippleEffect;
 
index.css (Tailwind.css)
@layer utilities { @keyframes RippleEffect { 0% { transform: translate(-50%, -50%) scale(1); } 100% { transform: translate(-50%, -50%) scale(var(--ripple-element-width)); opacity: 0; } } .ripple-effect { animation: RippleEffect 0.5s linear; } }