ユーザーアイコン

mizuko

約1か月前

0
0

モバイルの横スワイプでタブを切り替えられる様にする

Next.js
framer-motion

framer-motionを使って実現する。 swiperなどのライブラリは、slideとしてデータがキャッシュされるが、メモリ使用率が高く重くなってしまうため採用を見送った。 以下実装

export const SwipeableContainer = ({ children, currentIndex, totalItems, onSwipe, className = '', }: SwipeableContainerProps): JSX.Element => { const [touchStart, setTouchStart] = useState<number | null>(null); const [touchEnd, setTouchEnd] = useState<number | null>(null); const [isScrolling, setIsScrolling] = useState(false); const touchStartYRef = useRef<number | null>(null); const SWIPE_MIN_DISTANCE = 30; const SCROLL_TOLERANCE = 50; const onTouchStart = useCallback((e: React.TouchEvent) => { setTouchEnd(null); setTouchStart(e.targetTouches[0].clientX); touchStartYRef.current = e.targetTouches[0].clientY; setIsScrolling(false); }, []); const onTouchMove = useCallback( (e: React.TouchEvent) => { if (!touchStart) return; const currentX = e.targetTouches[0].clientX; const currentY = e.targetTouches[0].clientY; const diffX = Math.abs(touchStart - currentX); const diffY = Math.abs((touchStartYRef.current ?? 0) - currentY); // 横方向の移動が縦方向より大きい場合、スクロールフラグを立てない if (!isScrolling && diffY < diffX) { e.preventDefault(); } // 縦方向のスクロールを検出 if (!isScrolling && diffY > SCROLL_TOLERANCE && diffY > diffX) { setIsScrolling(true); return; } // コードブロック内での横スクロールを許可 if ( e.target instanceof HTMLElement && (e.target.closest('.mockup-code') ?? e.target.closest('pre')) ) { return; } setTouchEnd(currentX); }, [touchStart, isScrolling] ); const onTouchEnd = useCallback(() => { if (!touchStart || !touchEnd || isScrolling) { setTouchStart(null); setTouchEnd(null); return; } const distance = touchStart - touchEnd; const isLeftSwipe = distance > SWIPE_MIN_DISTANCE; const isRightSwipe = distance < -SWIPE_MIN_DISTANCE; if (isLeftSwipe && currentIndex < totalItems - 1) { onSwipe(currentIndex + 1); } else if (isRightSwipe && currentIndex > 0) { onSwipe(currentIndex - 1); } setTouchStart(null); setTouchEnd(null); }, [touchStart, touchEnd, isScrolling, currentIndex, totalItems, onSwipe]); return ( <div className={`relative overflow-hidden ${className}`}> <AnimatePresence initial={false} mode='wait'> <motion.div key={currentIndex} initial={{ opacity: 0, x: 0 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0 }} transition={{ opacity: { duration: 0.2 }, }} onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} className='relative size-full' > {children} </motion.div> </AnimatePresence> </div> ); };