
프로젝트 개요
- 프로젝트명: React Bits
- GitHub 저장소: DavidHDev/react-bits
- 프로젝트 규모: 28k+ Stars, 900+ Forks
- 프로젝트 성격: 오픈소스 React 컴포넌트 라이브러리
- 기술 스택: React, TypeScript, Three.js, GSAP, Tailwind CSS
- 공식 웹사이트: https://reactbits.dev
프로젝트 소개
React-bits는 현대적이고 재사용 가능한 React 컴포넌트 라이브러리로,
다양한 인터랙티브 애니메이션과 UI 요소들을 제공하는 오픈소스 프로젝트
개발자들이 쉽게 사용할 수 있는 고품질 컴포넌트들을 모아둔 저장소
기여 목표 및 배경
모바일 기기 사용자가 급증하면서, 웹 애플리케이션의 크로스 플랫폼 호환성이 중요해졌습니다.
React-bits의 인터랙티브 컴포넌트들이 데스크톱에서는 잘 동작하지만, 모바일 터치 이벤트를 지원하지 않는 문제를 발견했습니다. 이를 해결하여 더 나은 사용자 경험을 제공하고자 기여하게 되었습니다.
기여 내용 상세
1. Cubes 컴포넌트 모바일 터치 지원 추가
핵심 코드 변경사항
- 터치 이벤트 핸들러 구현
// 터치 이벤트 핸들러 추가 const onTouchMove = useCallback( (e) => { e.preventDefault(); // 기본 스크롤 동작 방지 userActiveRef.current = true; if (idleTimerRef.current) clearTimeout(idleTimerRef.current); const rect = sceneRef.current.getBoundingClientRect(); const cellW = rect.width / gridSize; const cellH = rect.height / gridSize; const touch = e.touches[0]; // 첫 번째 터치 포인트 사용 const colCenter = (touch.clientX - rect.left) / cellW; const rowCenter = (touch.clientY - rect.top) / cellH; // requestAnimationFrame으로 성능 최적화 if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => tiltAt(rowCenter, colCenter) ); idleTimerRef.current = setTimeout(() => { userActiveRef.current = false; }, 3000); }, [gridSize, tiltAt] ); const onTouchStart = useCallback(() => { userActiveRef.current = true; }, []); const onTouchEnd = useCallback(() => { if (!sceneRef.current) return; resetAll(); // 터치 종료 시 애니메이션 리셋 }, [resetAll]);
- 클릭 이벤트 개선
// 터치와 마우스 이벤트 모두 지원하도록 개선 const onClick = useCallback( (e) => { if (!rippleOnClick || !sceneRef.current) return; const rect = sceneRef.current.getBoundingClientRect(); const cellW = rect.width / gridSize; const cellH = rect.height / gridSize; // 터치 이벤트와 마우스 이벤트 모두 처리 const clientX = e.clientX || (e.touches && e.touches[0].clientX); const clientY = e.clientY || (e.touches && e.touches[0].clientY); const colHit = Math.floor((clientX - rect.left) / cellW); const rowHit = Math.floor((clientY - rect.top) / cellH); // ... 리플 효과 로직 }, [gridSize, rippleOnClick, /* ... */] );
- 이벤트 리스너 등록
useEffect(() => { const el = sceneRef.current; if (!el) return; // 기존 마우스 이벤트 el.addEventListener("pointermove", onPointerMove); el.addEventListener("pointerleave", resetAll); el.addEventListener("click", onClick); // 새로 추가된 터치 이벤트 el.addEventListener("touchmove", onTouchMove, { passive: false }); el.addEventListener("touchstart", onTouchStart, { passive: true }); el.addEventListener("touchend", onTouchEnd, { passive: true }); return () => { // 클린업 el.removeEventListener("pointermove", onPointerMove); el.removeEventListener("pointerleave", resetAll); el.removeEventListener("click", onClick); el.removeEventListener("touchmove", onTouchMove); el.removeEventListener("touchstart", onTouchStart); el.removeEventListener("touchend", onTouchEnd); }; }, [onPointerMove, resetAll, onClick, onTouchMove, onTouchStart, onTouchEnd]);
- CSS 클래스 개선
// 모바일 접근성 향상 return ( <div className="default-animation" style={wrapperStyle}> {/* "desktop-only" 클래스 제거로 모바일에서도 사용 가능 */}
- 성능 최적화
// passive 리스너 설정으로 스크롤 성능 향상 el.addEventListener("touchstart", onTouchStart, { passive: true }); el.addEventListener("touchend", onTouchEnd, { passive: true }); // touchmove는 preventDefault() 때문에 passive: false 유지
해결한 문제
- 모바일 미지원:
desktop-only클래스 제거로 모바일에서도 접근 가능
- 터치 인터랙션 부재: 터치 시 큐브가 반응하도록 구현
- 성능 이슈:
requestAnimationFrame과passive리스너로 최적화
2. Hyperspeed 컴포넌트 모바일 터치 지원 추가
PR #390: Feat/hyperspeed mobile touch support
핵심 코드 변경사항
- 터치 이벤트 핸들러 바인딩
// 생성자에서 메서드 바인딩 constructor() { // 기존 마우스 이벤트 바인딩 this.onMouseDown = this.onMouseDown.bind(this); this.onMouseUp = this.onMouseUp.bind(this); // 새로 추가된 터치 이벤트 바인딩 this.onTouchStart = this.onTouchStart.bind(this); this.onTouchEnd = this.onTouchEnd.bind(this); }
- 터치 이벤트 핸들러 구현
// 터치 시작 이벤트 - 마우스다운과 동일한 동작 onTouchStart(ev) { if (this.options.onSpeedUp) this.options.onSpeedUp(ev); this.fovTarget = this.options.fovSpeedUp; // 시야각 확대 this.speedUpTarget = this.options.speedUp; // 속도 증가 } // 터치 종료 이벤트 - 마우스업과 동일한 동작 onTouchEnd(ev) { if (this.options.onSlowDown) this.options.onSlowDown(ev); this.fovTarget = this.options.fov; // 시야각 원복 this.speedUpTarget = 0; // 속도 원복 }
- 이벤트 리스너 등록
// init() 메서드에서 터치 이벤트 등록 init() { // 기존 마우스 이벤트 this.container.addEventListener("mousedown", this.onMouseDown); this.container.addEventListener("mouseup", this.onMouseUp); this.container.addEventListener("mouseout", this.onMouseUp); // 새로 추가된 터치 이벤트 this.container.addEventListener("touchstart", this.onTouchStart, { passive: true }); this.container.addEventListener("touchend", this.onTouchEnd, { passive: true }); this.container.addEventListener("touchcancel", this.onTouchEnd, { passive: true }); } // destroy() 메서드에서 터치 이벤트 해제 destroy() { this.container.removeEventListener("touchstart", this.onTouchStart); this.container.removeEventListener("touchend", this.onTouchEnd); this.container.removeEventListener("touchcancel", this.onTouchEnd); }
- UX 개선
// 컨텍스트 메뉴 방지 핸들러 추가 constructor() { // ... this.onContextMenu = this.onContextMenu.bind(this); } // 길게 누르기로 나타나는 컨텍스트 메뉴 방지 onContextMenu(ev) { ev.preventDefault(); // 컨텍스트 메뉴 차단 } // 이벤트 등록/해제 init() { // ... this.container.addEventListener("contextmenu", this.onContextMenu); } destroy() { // ... this.container.removeEventListener("contextmenu", this.onContextMenu); }
해결한 문제
- 터치 미지원: 터치로 Hyperspeed 효과 트리거 가능
- 컨텍스트 메뉴 간섭: 길게 누르기 시 나타나는 메뉴 방지
- 일관성: 마우스와 터치의 동일한 동작 보장
3. Ballpit 컴포넌트 모바일 터치 지원 추가
핵심 코드 변경사항
- 터치 상태 관리
// 각 요소에 터치 상태 추가 function S(e) { const t = { position: new r(), nPosition: new r(), hover: false, touching: false, // 새로 추가된 터치 상태 onEnter() { }, onMove() { }, onClick() { }, onLeave() { } }; }
- 터치 이벤트 리스너 등록
// 전역 터치 이벤트 리스너 추가 function addEventListeners() { if (b.size === 1) { // 기존 포인터 이벤트 document.body.addEventListener("pointermove", M); document.body.addEventListener("pointerleave", L); document.body.addEventListener("click", C); // 새로 추가된 터치 이벤트 document.body.addEventListener("touchstart", TouchStart, { passive: false }); document.body.addEventListener("touchmove", TouchMove, { passive: false }); document.body.addEventListener("touchend", TouchEnd, { passive: false }); document.body.addEventListener("touchcancel", TouchEnd, { passive: false }); } } // 이벤트 리스너 제거 function removeEventListeners() { if (b.size === 0) { // 기존 이벤트 제거 document.body.removeEventListener("pointermove", M); document.body.removeEventListener("pointerleave", L); document.body.removeEventListener("click", C); // 터치 이벤트 제거 document.body.removeEventListener("touchstart", TouchStart); document.body.removeEventListener("touchmove", TouchMove); document.body.removeEventListener("touchend", TouchEnd); document.body.removeEventListener("touchcancel", TouchEnd); } }
- 터치 이벤트 핸들러 구현
// 터치 시작 이벤트 function TouchStart(e) { if (e.touches.length > 0) { e.preventDefault(); // 기본 스크롤 방지 A.x = e.touches[0].clientX; A.y = e.touches[0].clientY; for (const [elem, t] of b) { const rect = elem.getBoundingClientRect(); if (D(rect)) { // 터치 영역 확인 t.touching = true; // 터치 상태 활성화 P(t, rect); // 위치 업데이트 if (!t.hover) { t.hover = true; t.onEnter(t); // 진입 애니메이션 } t.onMove(t); // 이동 애니메이션 } } } } // 터치 이동 이벤트 function TouchMove(e) { if (e.touches.length > 0) { e.preventDefault(); A.x = e.touches[0].clientX; A.y = e.touches[0].clientY; for (const [elem, t] of b) { const rect = elem.getBoundingClientRect(); P(t, rect); // 위치 지속적 업데이트 if (D(rect)) { if (!t.hover) { t.hover = true; t.touching = true; t.onEnter(t); } t.onMove(t); } else if (t.hover && t.touching) { // 터치 중이면 영역 밖에서도 계속 추적 t.onMove(t); } } } } // 터치 종료 이벤트 function TouchEnd() { for (const [, t] of b) { if (t.touching) { t.touching = false; // 터치 상태 해제 if (t.hover) { t.hover = false; t.onLeave(t); // 종료 애니메이션 } } } }
- 마우스 이벤트와의 상호작용 개선
// processInteraction 함수로 공통 로직 분리 function M(e) { A.x = e.clientX; A.y = e.clientY; processInteraction(); // 공통 처리 함수 호출 } function processInteraction() { for (const [elem, t] of b) { const i = elem.getBoundingClientRect(); if (D(i)) { P(t, i); if (!t.hover) { t.hover = true; t.onEnter(t); } t.onMove(t); } else if (t.hover && !t.touching) { // 터치 중이 아닐 때만 hover 해제 t.hover = false; t.onLeave(t); } } }
- CSS 스타일 설정
// Ballpit 생성 시 터치 관련 스타일 적용 function createBallpit(e, t = {}) { // ... // 터치 동작 최적화 e.style.touchAction = 'none'; // 기본 터치 동작 비활성화 e.style.userSelect = 'none'; // 텍스트 선택 방지 e.style.webkitUserSelect = 'none'; // 웹킷 브라우저 텍스트 선택 방지 // ... }
해결한 문제
- 복잡한 상태 관리:
touching상태로 터치와 마우스 이벤트 구분
- 터치 추적: 터치 영역 밖에서도 드래그 동작 지속
- 성능 최적화: 불필요한 브라우저 기본 동작 방지
사용된 기술 스택 및 기법
이벤트 처리 최적화
// Passive 리스너로 성능 향상 { passive: true } // 스크롤 성능 향상 { passive: false } // preventDefault() 필요한 경우 // RequestAnimationFrame으로 애니메이션 최적화 if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => tiltAt(rowCenter, colCenter)); // 3. 이벤트 디바운싱 idleTimerRef.current = setTimeout(() => { userActiveRef.current = false; }, 3000);
크로스 플랫폼 호환성
// 터치와 마우스 이벤트 통합 처리 const clientX = e.clientX || (e.touches && e.touches[0].clientX); const clientY = e.clientY || (e.touches && e.touches[0].clientY); // 다중 이벤트 타입 지원 el.addEventListener("touchcancel", TouchEnd, { passive: false });
메모리 누수 방지
// useEffect cleanup 함수로 이벤트 리스너 해제 return () => { el.removeEventListener("touchmove", onTouchMove); el.removeEventListener("touchstart", onTouchStart); el.removeEventListener("touchend", onTouchEnd); rafRef.current != null && cancelAnimationFrame(rafRef.current); idleTimerRef.current && clearTimeout(idleTimerRef.current); };