3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

and factory.incAdvent Calendar 2024

Day 22

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

Last updated at Posted at 2024-12-21

はじめに

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

and factoryでエンジニアをしている@y-okuderaです!

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

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

Framer Motionについて

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

主な特徴

  • 簡潔な API

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

  • アニメーションの設定

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

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

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

// transitionで遷移時間、イージングを設定
<motion.div
  animate={{ scale: 1.5 }}
  transition={{ duration: 0.5, ease: "easeInOut" }}
/>
  • 複雑なアニメーション対応

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

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

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

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

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

アニメーションの流れ

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

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

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

完成イメージ

demo.gif

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

事前準備

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

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

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

npm install framer-motion

コンポーネントの実装

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

インターフェースの定義

Propsを定義していきます。

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

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とします。

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

...

<motion.div
    ...
    initial="initial"
    ...
参考

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に渡すだけで、すべてのアニメーションターゲットをすっきりと整理して定義できるようになります!

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

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

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

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

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

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に収束
      ],
    },
  });
  1. アニメーション後:piled
    3つの束があります。クリックするとアニメーションしながら初期状態の1つの束に戻ります。

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

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()で指数関数的に調整しました。

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 を呼び出すようにします。

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

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

  ...

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

コンポーネントの全体像

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

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;
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;
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の更新くらいしか実行しないのでさらっと全体像だけ記載します。

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

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

export default TarotCard;
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に公開しています!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?