モバイルの横スワイプでタブを切り替えられる様にする
Next.js
フロントエンド
Framer Motion
知識
判断
framer-motionを使って実現する。 swiperなどのライブラリは、slideとしてデータがキャッシュされるが、メモリ使用率が高く重くなってしまうため採用を見送った。 以下実装
ts
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>
);
};