framer-motionでカードをシャッフルしてみる

# はじめに
この記事はand factory.inc Advent Calendar 2024 22日目の記事です:xmas-tree:

https://qiita.com/advent-calendar/2024/andfactory

and factoryでエンジニアをしている[@y-okudera](https://qiita.com/y-okudera)です！

以前はiOSアプリを中心に開発していましたが、最近はバックエンドやWebフロントエンド開発にチャレンジしています。

今回は、フロントエンドでアニメーションの実装をする際にframer-motionが便利だったので紹介します。

# Framer Motionについて
Framer Motion は、React 向けに設計された強力で使いやすいアニメーションライブラリです。滑らかなアニメーションやインタラクションを簡単に実装でき、モーションデザインを効率的に行うことができます。

## 主な特徴
- 簡潔な API

React コンポーネントとして提供され、motion.div や motion.span のように、通常の HTML 要素にアニメーションを追加することができます。

- アニメーションの設定

initial、animate、exitなどアニメーションの開始値や終了値を指定できます。
今回示す例ではinitial、animateを利用します。

```react:
// initialで開始値、animateで終了値を指定
<motion.div initial={{ x: 0 }} animate={{ x: 100 }} />
```

また、transitionでアニメーションの遷移時間やイージングなども簡単に設定することができます。

```react:
// transitionで遷移時間、イージングを設定
<motion.div
  animate={{ scale: 1.5 }}
  transition={{ duration: 0.5, ease: "easeInOut" }}
/>
```

- 複雑なアニメーション対応

配列を使って中間地点の指定をする形でキーアニメーションを実装できます。

その他にも便利な機能がたくさん提供されています。

# タロットカード風のシャッフルアニメーションを実装する

アニメーションの実装となると、大体これを題材にしがちです:relaxed:
（過去にFlutterで類似のアニメーションを実装しています）

https://qiita.com/y-okudera/items/d81715827d01c3509eac

今回は、Next.jsでFramer Motionを利用して、カードをシャッフルしたいと思います！

## アニメーションの流れ

1. 初期状態：initial
カードの束が1つあります。クリックするとシャッフルアニメーションが開始します。

2. シャッフルアニメーション：shuffling
カードがシャッフルされます。3秒間アニメーションして3つの束に収束します。

3. アニメーション後：piled
3つの束があります。クリックするとアニメーションしながら初期状態の1つの束に戻ります。

### 完成イメージ

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/433079fd-b24b-87a4-8da5-d9f904929556.gif)

カードのデザインがうさぎなのでタロット感が全然ありませんね...:joy:

## 事前準備
まずは、シャッフルするカードのイメージを用意します。
今回もいらすとやの画像を使わせていただきましょう:metal:

https://www.irasutoya.com/2017/05/cardback.html

## ライブラリのインストール

Next.jsプロジェクトを作成し、Framer Motionをインストールします。
（プロジェクト作成の詳細については割愛します。）

```terminal:
npm install framer-motion
```

## コンポーネントの実装
アニメーションをするコンポーネントを実装していきます。

### インターフェースの定義

Propsを定義していきます。

※ 今回はinterfaceで定義していますが、typeでも問題ないです。
お好みの方法で定義してください。

```typescript:src/components/ShuffleAnimation/interface.d.ts
interface Card {
  id: number;
  image: string;
}

interface ShuffleAnimationProps {
  cards: Card[];
  onAnimationEnd: () => void;
  onCardClick: (id: number) => void;
  animationState: "initial" | "shuffling" | "piled";
}
```

アニメーション完了時の処理、カードクリック時の処理をコンポーネントを利用する側で定義できるように関数をそれぞれonAnimationEndとonCardClickで渡せるようにしています。
animationStateについては後述しますが、「初期状態」・「シャッフル中」・「積まれた状態（アニメーション後）」の3つの状態を定義しています。

### アニメーションの状態の実装

冒頭に記載した例ではanimateに直接値を設定していましたが、Variantsでアニメーションを定義します。Variantsを定義することで、initialやanimateなどアニメーションターゲットを定義できる場所であればどこでも、ラベルで参照できるようになります。

> 1. 初期状態：initial
カードの束が1つあります。クリックするとシャッフルアニメーションが開始します。

初期状態を示すinitialラベルは、x, y座標は0で、回転も加えないのでrotateも0とします。

```typescript:src/components/ShuffleAnimation/component.tsx
const variants = {
    initial: {
      x: 0,
      y: 0,
      rotate: 0,
    },
  };

...

<motion.div
    ...
    initial="initial"
    ...
```

<details>
<summary>参考</summary>


https://motion.dev/docs/react-animation#variants

> I love using variants alongside React state – just pass your state to animate, and now you've got a tidy place to define all your animation targets!

> Reactのstateと一緒にvariantsを使うのが大好きです。stateをそのままanimateに渡すだけで、すべてのアニメーションターゲットをすっきりと整理して定義できるようになります！

</details>

initial以外のvariantsも定義していきましょう。

> 2. シャッフルアニメーション：shuffling
カードがシャッフルされます。3秒間アニメーションして3つの束に収束します。

シャッフルアニメーション中のshufflingラベルでは、x, y座標、回転ともに範囲指定したランダムな値を設定します。連続で複数の移動をさせたいので、x, y, rotateそれぞれ配列で複数の値を設定します。

`Math.random()` は、0以上1未満の範囲で浮動小数点の擬似乱数を返すので、掛け算、負の値も範囲に含める場合は引き算もして、範囲指定したランダムな値を定義します。
範囲指定の具体的な値については、実際にアニメーションを確認しながらそれらしく動くように微調整をしました。

シャッフルアニメーションの最終的な移動先は、横並びの3つの束としたいので、x は3つの値、y, rotateは0になるように設定します。
variantを使用する際にcardsのindexを渡すようにして、indexごとに最終的な移動先が決まるようにしました。


```typescript:src/components/ShuffleAnimation/component.tsx
const piles = [0, 1, 2]; // 最終的に3つの束に分ける

const variants = (index: number): Variants => ({
    initial: {
      x: 0,
      y: 0,
      rotate: 0,
    },
    shuffling: {
      x: [
        Math.random() * 200 - 100, // -100px～100px
        Math.random() * 300 - 150, // -150px～150px
        Math.random() * 200 - 100, // -100px～100px
        piles[index % 3] * 150 - 150, // 最終的に -150px, 0px, 150pxの3つに収束
      ],
      y: [
        Math.random() * 100 - 50, // -50px～50px
        Math.random() * 150 - 75, // -75px～75px
        Math.random() * 100 - 50, // -50px～50px
        0, // 最終的に0に収束
      ],
      rotate: [
        Math.random() * 30 - 15, // -15度～15度
        Math.random() * 45 - 20, // -20度～25度
        Math.random() * 30 - 15, // -15度～15度
        0, // 最終的に0に収束
      ],
    },
  });
```


> 3. アニメーション後：piled
3つの束があります。クリックするとアニメーションしながら初期状態の1つの束に戻ります。

積まれた状態（アニメーション後）は、シャッフルアニメーションの最終的な移動先と同じ値になります。

```typescript:src/components/ShuffleAnimation/component.tsx
const piles = [0, 1, 2]; // 最終的に3つの束に分ける

const variants = (index: number): Variants => ({
    initial: {
      x: 0,
      y: 0,
      rotate: 0,
    },
    shuffling: {
      x: [
        Math.random() * 200 - 100,
        Math.random() * 300 - 150,
        Math.random() * 200 - 100,
        piles[index % 3] * 150 - 150,
      ],
      y: [
        Math.random() * 100 - 50,
        Math.random() * 150 - 75,
        Math.random() * 100 - 50,
        0,
      ],
      rotate: [
        Math.random() * 30 - 15,
        Math.random() * 45 - 20,
        Math.random() * 30 - 15,
        0,
      ],
    },
    piled: {
      x: piles[index % 3] * 150 - 150,
      y: 0,
      rotate: 0,
    },
  });

```

### アニメーションの設定の実装

続いて、transitionでアニメーションの設定をします。
今回は、アニメーションの長さ、遅延、イージングの設定をします。

アニメーションの状態がシャッフルアニメーション中かそれ以外かで設定を変更するため、フラグを用意し値を設定します。

遅延が大きすぎると目立ってしまうので、`Math.exp()`で指数関数的に調整しました。

```typescript:src/components/ShuffleAnimation/component.tsx
  const isShuffling = animationState === "shuffling";

  const transition = (index: number) => ({
    duration: isShuffling ? 3 : 0.5, // シャッフルアニメーションは3秒
    delay: isShuffling ? index * 0.05 * Math.exp(-index / 20) : 0, // シャッフルアニメーション時は遅延を指数関数的に調整
    ease: "easeInOut",
  });
```

### アニメーション完了時の処理を実装

アニメーションが完了したときは、onAnimationCompleteでコールバックを受け取ることができます。

今回は、複数のカードをアニメーションさせるので、**最後のカードのアニメーションが終わったら**`onAnimationEnd` を呼び出すようにします。

```typescript:src/components/ShuffleAnimation/component.tsx
  const handleAnimationComplete = (index: number) => {
    if (animationState !== "shuffling") return;

    // 最後のカードのアニメーションが終わったら `onAnimationEnd` を呼び出す
    if (index === totalCards - 1) {
      onAnimationEnd();
    }
  };

  ...

  <motion.div
    ...
    onAnimationComplete={() => handleAnimationComplete(index)}
    ...
  >
```

### コンポーネントの全体像

一部説明を省いていますが、コンポーネントの全体像は以下のような感じです。

```tsx:src/components/ShuffleAnimation/index.tsx
import Component from "./component";

const ShuffleAnimation = ({
  cards,
  onAnimationEnd,
  onCardClick,
  animationState,
}: ShuffleAnimationProps) => {
  return (
    <Component
      cards={cards}
      onAnimationEnd={onAnimationEnd}
      onCardClick={onCardClick}
      animationState={animationState}
    />
  );
};

export default ShuffleAnimation;
```

```tsx:src/components/ShuffleAnimation/component.tsx
import { motion, Variants } from "framer-motion";
import styles from "./styles.module.css";

const Component = ({
  cards,
  onAnimationEnd,
  onCardClick,
  animationState,
}: ShuffleAnimationProps) => {
  const totalCards = cards.length;
  const piles = [0, 1, 2]; // 最終的に3つの束に分ける

  const handleAnimationComplete = (index: number) => {
    if (animationState !== "shuffling") return;

    // 最後のカードのアニメーションが終わったら `onAnimationEnd` を呼び出す
    if (index === totalCards - 1) {
      onAnimationEnd();
    }
  };

  const variants = (index: number): Variants => ({
    initial: {
      x: 0,
      y: 0,
      rotate: 0,
    },
    shuffling: {
      x: [
        Math.random() * 200 - 100, // -100px～100px
        Math.random() * 300 - 150, // -150px～150px
        Math.random() * 200 - 100, // -100px～100px
        piles[index % 3] * 150 - 150, // 最終的に -150px, 0px, 150pxの3つに収束
      ],
      y: [
        Math.random() * 100 - 50, // -50px～50px
        Math.random() * 150 - 75, // -75px～75px
        Math.random() * 100 - 50, // -50px～50px
        0, // 最終的に0に収束
      ],
      rotate: [
        Math.random() * 30 - 15, // -15度～15度
        Math.random() * 45 - 20, // -20度～25度
        Math.random() * 30 - 15, // -15度～15度
        0, // 最終的に0に収束
      ],
    },
    piled: {
      x: piles[index % 3] * 150 - 150,
      y: 0,
      rotate: 0,
    },
  });

  const isShuffling = animationState === "shuffling";
  const isClickable = !isShuffling;

  const transition = (index: number) => ({
    duration: isShuffling ? 3 : 0.5, // シャッフルアニメーションは3秒
    delay: isShuffling ? index * 0.05 * Math.exp(-index / 20) : 0, // シャッフルアニメーション時は遅延を指数関数的に調整
    ease: "easeInOut",
  });

  return (
    <div className={styles.shuffleContainer}>
      {cards.map((card, index) => (
        <motion.div
          animate={animationState}
          className={`${styles.card} ${isClickable ? styles.clickable : ""}`}
          initial="initial"
          key={card.id}
          onClick={() => isClickable && onCardClick(card.id)}
          onAnimationComplete={() => handleAnimationComplete(index)}
          transition={transition(index)}
          variants={variants(index)}
        >
          <div
            className={styles.cardImage}
            style={{ backgroundImage: `url(${card.image})` }}
          ></div>
        </motion.div>
      ))}
    </div>
  );
};

export default Component;
```

```css:src/components/ShuffleAnimation/styles.module.css
.shuffleContainer {
  position: relative;
  width: 100%;
  height: 400px;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: visible;
}

.card {
  position: absolute;
  width: 100px;
  height: 150px;
  perspective: 1000px;
}

.cardImage {
  width: 100%;
  height: 100%;
  background-size: cover;
  background-position: center;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  transform-origin: center center;
}

.clickable {
  cursor: pointer;
}
```

## コンポーネントを利用するfeatureの実装

コンポーネントを利用する側を実装します。
pageに"use client"をつけたくないので、src/app/features/tarotCard実装をします。

こちらは、cardsの生成とonAnimationEnd, onCardClickのときのstateの更新くらいしか実行しないのでさらっと全体像だけ記載します。

```tsx:src/app/features/tarotCard/index.tsx
import Component from "./component";

const TarotCard = () => {
  return <Component />;
};

export default TarotCard;
```

```tsx:src/app/features/tarotCard/component.tsx
"use client";

import ShuffleAnimation from "@/components/ShuffleAnimation";
import { useEffect, useState } from "react";

const Component = () => {
  const [animationState, setAnimationState] = useState<
    "initial" | "shuffling" | "piled"
  >("initial");
  const cardCount = 78;
  const [cards] = useState<Card[]>(
    Array.from({ length: cardCount }, (_, i) => ({
      id: i + 1,
      image: "/images/card_back.png",
    }))
  );

  /**
   * アニメーションが終わったら呼ばれる関数
   */
  const handleAnimationEnd = () => {
    console.log("handleAnimationEnd");
    setAnimationState("piled");
  };

  /**
   * カードがクリックされたときに呼ばれる関数
   */
  const handleCardClick = (id: number) => {
    console.log(
      `handleCardClick id: ${id} animationState: '${animationState}'`
    );
    // 'initial' なら 'shuffling' に、'piled' なら 'initial' に戻す
    animationState === "initial"
      ? setAnimationState("shuffling")
      : animationState === "piled"
      ? setAnimationState("initial")
      : undefined;
  };

  useEffect(() => {
    console.log("AnimationState changed to", animationState);
  }, [animationState]);

  return (
    <ShuffleAnimation
      cards={cards}
      onAnimationEnd={handleAnimationEnd}
      onCardClick={handleCardClick}
      animationState={animationState}
    />
  );
};

export default Component;
```

# まとめ
Framer Motionを利用することで複雑なアニメーションを比較的簡単に実装できました！
実際のプロダクトではデザイナーさんからアニメーションの具体的な指示があるケースも多いと思いますが、Framer Motionを利用することで良い感じに実現できそうです。

今回実装したコードはGitHubに公開しています！

https://github.com/y-okudera/tarot-shuffle