🃏

[React] Framer Motion を用いたシャッフルカードの実装

2023/11/08に公開

はじめに 🚩

この記事では、React と Framer Motion を用いて、直感的なドラッグ操作によるカードシャッフル機能の実装手法をご紹介します。

本記事で説明する実装に関する動作は以下の X の埋め込みからご覧いただけます。

実装例 📝

ShuffleCards.tsx の全コード
'use client';

import {
  MotionValue,
  motion,
  useMotionValue,
  useMotionValueEvent,
} from 'framer-motion';
import { useState, useEffect, useCallback } from 'react';
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from './ui/card';
import { cn } from '@/lib/utils';

type ListOrderItem = number;

const SHUFFLE_THRESHOLD = -50;
const CARD_OFFSET_PERCENTAGE = 33;
const ROTATION_DEGREE = 3;
const AUTO_SHUFFLE_INTERVAL_MS = 5000;

const DraggableCardShuffler = () => {
  const dragProgress = useMotionValue(0);
  const [order, setOrder] = useState<number[]>([0, 1, 2]);
  const [dragging, setDragging] = useState(false);

  const shuffleOrder = useCallback((currentOrder: ListOrderItem[]) => {
    return [...currentOrder.slice(1), currentOrder[0]];
  }, []);

  const handleDragEnd = useCallback(() => {
    const x = dragProgress.get();
    if (x <= SHUFFLE_THRESHOLD) {
      setOrder(shuffleOrder);
    }
    dragProgress.set(0);
  }, [dragProgress, shuffleOrder]);

  useEffect(() => {
    const intervalRef = setInterval(() => {
      if (!dragging) {
        setOrder(shuffleOrder);
      }
    }, AUTO_SHUFFLE_INTERVAL_MS);

    return () => clearInterval(intervalRef);
  }, [dragging, shuffleOrder]);

  return (
    <section
      style={{ pointerEvents: dragging ? 'none' : undefined }}
      className='overflow-hidden px-8 py-24 text-slate-50 flex justify-center'
    >
      <motion.div
        whileTap={{ scale: 0.985 }}
        className='relative h-[400px] w-[320px]'
      >
        {order.map((position) => (
          <ShuffleCard
            key={position}
            description={`Description ${position + 1}`}
            title={`Title ${position + 1}`}
            handleDragEnd={handleDragEnd}
            dragProgress={dragProgress}
            position={order.indexOf(position)}
            dragging={dragging}
            setDragging={setDragging}
          />
        ))}
      </motion.div>
    </section>
  );
};

interface ShuffleCardProps {
  handleDragEnd: () => void;
  dragProgress: MotionValue<number>;
  position: ListOrderItem;
  title: string;
  setDragging: (isDragging: boolean) => void;
  description: string;
  dragging: boolean;
}

const ShuffleCard = ({
  handleDragEnd,
  dragProgress,
  position,
  title,
  description,
  setDragging,
  dragging,
}: ShuffleCardProps) => {
  const dragX = useMotionValue(0);

  useMotionValueEvent(dragX, 'change', (latest) => {
    if (typeof latest === 'number' && dragging) {
      dragProgress.set(latest);
    } else {
      dragProgress.set(0);
    }
  });

  const x = `${position * CARD_OFFSET_PERCENTAGE}%`;
  const rotateZ = `${position * ROTATION_DEGREE - 4}deg`;
  const zIndex = 100 - position;

  const draggable = position === 0;

  return (
    <motion.div
      style={{ zIndex, x: dragX }}
      animate={{ rotate: rotateZ, x }}
      drag
      dragElastic={0.35}
      dragListener={draggable}
      dragConstraints={{
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
      }}
      onDragStart={() => setDragging(true)}
      onDragEnd={() => {
        setDragging(false);
        handleDragEnd();
      }}
      transition={{ duration: 0.35 }}
      className={cn(
        'absolute left-0 top-0 grid place-content-center',
        draggable && 'cursor-grab active:cursor-grabbing'
      )}
    >
      <Card className='h-[450px] w-[350px] flex flex-col justify-between'>
        <CardHeader>
          <h1>{title}</h1>
        </CardHeader>
        <CardContent>
          <CardTitle>{title}</CardTitle>
          <CardDescription>{description}</CardDescription>
        </CardContent>
      </Card>
    </motion.div>
  );
};

export default DraggableCardShuffler;

マジックナンバー定数

以下は、カードシャッフル機能における定数の定義とその説明です。

const SHUFFLE_THRESHOLD = -50;
const CARD_OFFSET_PERCENTAGE = 33;
const ROTATION_DEGREE = 3;
定数名 説明
SHUFFLE_THRESHOLD カードをシャッフルするためにユーザーがドラッグする必要がある距離のしきい値です。この例では、ユーザーがカードを左に50ピクセル以上ドラッグした場合にシャッフルが発生します。負の値は左方向へのドラッグを示唆しています。
CARD_OFFSET_PERCENTAGE スタックされたカードがどれだけずれて表示されるかをパーセンテージで指定します。この値により、各カードは前のカードから33%ずれて配置され、カードが重なっているように見えるため、ユーザーが個々のカードを容易に識別できるようになります。
ROTATION_DEGREE 各カードがどの程度傾くかを度数で定義します。この値により、カードは順番に3度ずつ傾けられ、スタックに動きと視覚的な要素を加えます。

DraggableCardShuffler の実装

ユーザーがドラッグ操作を通じてカードの順序をシャッフルできるインタラクティブなUIコンポーネント DraggableCardShuffler の実装について説明します。

const DraggableCardShuffler = () => {
  const dragProgress = useMotionValue(0);
  const [order, setOrder] = useState<number[]>([0, 1, 2]);
  const [dragging, setDragging] = useState(false);

  const shuffleOrder = useCallback((currentOrder: ListOrderItem[]) => {
    return [...currentOrder.slice(1), currentOrder[0]];
  }, []);

  const handleDragEnd = useCallback(() => {
    const x = dragProgress.get();
    if (x <= SHUFFLE_THRESHOLD) {
      setOrder(shuffleOrder);
    }
    dragProgress.set(0);
  }, [dragProgress, shuffleOrder]);

  useEffect(() => {
    const intervalRef = setInterval(() => {
      if (!dragging) {
        setOrder(shuffleOrder);
      }
    }, AUTO_SHUFFLE_INTERVAL_MS);

    return () => clearInterval(intervalRef);
  }, [dragging, shuffleOrder]);

  return (
    <section
      style={{ pointerEvents: dragging ? 'none' : undefined }}
      className='overflow-hidden px-8 py-24 text-slate-50 flex justify-center'
    >
      <motion.div
        whileTap={{ scale: 0.985 }}
        className='relative h-[400px] w-[320px]'
      >
        {order.map((position) => (
          <ShuffleCard
            key={position}
            description={`Description ${position + 1}`}
            author={`Author ${position + 1}`}
            handleDragEnd={handleDragEnd}
            dragProgress={dragProgress}
            position={order.indexOf(position)}
            dragging={dragging}
            setDragging={setDragging}
          />
        ))}
      </motion.div>
    </section>
  );
};

主要な機能とその説明

  • ドラッグ進捗の追跡: useMotionValue(0) を使用して、ドラッグ操作の進捗を追跡します。useMotionValue は Framer Motion の hooks で、アニメーション可能な値を生成します。この値は、ドラッグ操作によってユーザーがカードをどれだけ移動させたかをリアルタイムで追跡し、その数値を保持します。ドラッグが終了すると、この値を参照してカードが十分にドラッグされたかどうかを判断し、必要に応じてシャッフル処理を実行します。

  • カードの順序管理: useState を用いて、カードの順序を表す order 配列の状態を管理します。これにより、カードの現在の順序を状態として保持し、シャッフル操作によってこの順序を更新することができます。

  • ドラッグ状態の管理: useState を使用して、ユーザーがカードをドラッグしているかどうかの状態を dragging で管理します。

  • カードのシャッフル: shuffleOrder 関数を用いて配列の最初の要素を取り出し、最後に追加することで、カードの順序をローテーションさせます。

  • ドラッグ終了時の処理: handleDragEndは、ドラッグ操作が終了したときに呼び出されるコールバック関数です。ドラッグ進捗が SHUFFLE_THRESHOLD 以下であれば、カードの順序をシャッフルします。

ShuffleCard の実装

page.tsx
interface ShuffleCardProps {
  handleDragEnd: () => void;
  dragProgress: MotionValue<number>;
  position: ListOrderItem;
  author: string;
  setDragging: (isDragging: boolean) => void;
  description: string;
  dragging: boolean;
}

const ShuffleCard = ({
  handleDragEnd,
  dragProgress,
  position,
  author,
  description,
  setDragging,
  dragging,
}: ShuffleCardProps) => {
  const dragX = useMotionValue(0);

  useMotionValueEvent(dragX, 'change', (latest) => {
    if (typeof latest === 'number' && dragging) {
      dragProgress.set(latest);
    } else {
      dragProgress.set(0);
    }
  });

  const x = `${position * CARD_OFFSET_PERCENTAGE}%`;
  const rotateZ = `${position * ROTATION_DEGREE - 4}deg`;
  const zIndex = 100 - position;

  const draggable = position === 0;

  return (
    <motion.div
      style={{ zIndex, x: dragX }}
      animate={{ rotate: rotateZ, x }}
      drag
      dragElastic={0.35}
      dragListener={draggable}
      dragConstraints={{
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
      }}
      onDragStart={() => setDragging(true)}
      onDragEnd={() => {
        setDragging(false);
        handleDragEnd();
      }}
      transition={{ duration: 0.35 }}
      className={cn(
        'absolute left-0 top-0 grid place-content-center',
        draggable && 'cursor-grab active:cursor-grabbing'
      )}
    >
      <Card className='h-[450px] w-[350px] flex flex-col justify-between'>
        <CardHeader>
          <h1>{author}</h1>
        </CardHeader>
        <CardContent>
          <CardTitle>{author}</CardTitle>
          <CardDescription>{description}</CardDescription>
        </CardContent>
      </Card>
    </motion.div>
  );
};

主な機能とその説明

  • ドラッグ進捗の追跡: dragProgress は、カードのドラッグ進捗を追跡するための MotionValue です。ユーザーがカードをドラッグすると、この値がリアルタイムで更新され、カードの動きを反映します。

  • useMotionValueEvent: このフックは、MotionValue の値が変更されたときにイベントをリッスンするために使用されます。useMotionValueEvent(dragX, 'change', callback) は、dragX の値が変更されるたびに指定されたコールバック関数を実行します。このコールバック内で、dragProgress の値を最新の dragX の値に設定することで、ドラッグの進捗を追跡し、カードの移動に応じてUIを更新します。ユーザーがドラッグを止めた場合、dragProgress は0にリセットされます。

  • ドラッグイベントのハンドリング: onDragStart と onDragEnd イベントは、ドラッグの開始と終了時にsetDragging関数を呼び出し、ドラッグ状態を更新します。onDragEnd では、ドラッグ操作が完了したことを handleDragEnd 関数に通知し、必要な後処理を行います。

  • スタイルとアニメーション: カードは motion.div 要素としてレンダリングされ、Framer Motion を使用してアニメーションが適用されます。style 属性で zIndex と x(水平方向の位置)を設定し、animate 属性で回転と位置のアニメーションを定義しています。

  • ドラッグ制約: dragConstraints を使用して、カードがドラッグ可能な範囲を定義しています。これにより、カードが特定の範囲を超えてドラッグされないように制限しています。

  • ドラッグ可能なカードの決定: draggable 変数は、カードがドラッグ可能かどうかを決定します。これは position が0のときにのみ true になり、最前面にあるカードのみがドラッグ可能です。

オートシャッフルの実装

オートシャッフル機能は、ユーザーがアクティブにカードをドラッグしていないときに、定期的にカードの順序を自動的にシャッフルすることで、インタラクティブな体験を提供します。

const AUTO_SHUFFLE_INTERVAL_MS = 5000;

const DraggableCardShuffler = () => {
  // ...(他のステートとロジック)

  useEffect(() => {
    const intervalRef = setInterval(() => {
      if (!dragging) {
        setOrder(shuffleOrder);
      }
    }, AUTO_SHUFFLE_INTERVAL_MS);

    return () => clearInterval(intervalRef);
  }, [dragging, shuffleOrder]);

  // ...(レンダリングロジック)
};

export default DraggableCardShuffler;

機能の詳細

  • タイマーの設定: useEffect フック内で setInterval を使用して、定期的にシャッフル関数を実行するタイマーを設定します。

  • 条件付きシャッフル: タイマーがトリガーされるたびに、dragging ステートが false であるかどうかをチェックします。これは、ユーザーが現在ドラッグ操作を行っていないことを意味します。その場合のみ、setOrder 関数を使用してカードの順序をシャッフルします。

  • クリーンアップ: コンポーネントがアンマウントされるとき、または依存配列の値が変更されたときに、useEffect のクリーンアップ関数が実行され、clearInterval によってタイマーがクリアされます。これにより、不要なタイマーがバックグラウンドで実行され続けることを防ぎます。

  • 依存配列: useEffect の依存配列に dragging と shuffleOrder を指定することで、これらの値に変更があった場合のみ、タイマーの設定を再評価します。

まとめ 📌

本記事では、React と Framer Motion を駆使して、ユーザーがドラッグアンドドロップを行うことでカードをシャッフルできるインタラクティブな UI の実装方法を紹介しました。

以上です!

Discussion