Gemcook Tech Blog
🎈

React Native Skiaでふわふわ動くサークルを実装する。

2024/11/06に公開

こんにちは!ぞのりょーです。

今回、React Native Skiaを使ってふわふわと動くサークルを実装しました。Skiaを使ったアニメーションの実装をしたことがなかったのですが、使ってみると意外とスムーズに実装ができたので、実装の方法についてまとめてみます。

https://shopify.github.io/react-native-skia/

成果物

今回作成する最終的な成果物は以下のようなものになります。4つのサークルがふわふわとそれぞれ異なる動きで動いているようなUIを実装します。

開発環境

今回開発している環境として、ReactNative、Expo、Skia等のバージョンは以下のとおりです。

"react-native": "0.74.5",
"expo": "~51.0.28",
"react": "18.2.0",
"react-dom": "18.2.0",
"@shopify/react-native-skia": "1.2.3",
"react-native-reanimated": "~3.10.1",

プロジェクトの作成とSkiaのインストール

bunx create-expo-app@latest
cd project-name
bun add @shopify/react-native-skia

今回使用するSkiaのコンポーネント概要

今回はSkiaの<Canvas />コンポーネントと<Circle />コンポーネントを用いてサークルを描画します。以下でそれぞれに渡しているpropsについて説明します。なお、今回使用していないpropsについての説明は行いません。気になる方は公式ドキュメントをご参照ください。

<Canvas />

  • style: サークルを描画する領域のスタイリングを指定します。widthheightflex: 1などでサークルを描画する範囲を指定することで画面上にサークルが描画されます。今回は、最終的に複数のサークルを画面全体に描画したいため、flex: 1を指定し、画面全体を描画領域としています。また、描画範囲がわかりやすいようにbackground: blueを指定しています。

<Circle />

  • cx: サークルのX軸の位置を指定。
  • cy: サークルのY軸の位置を指定。
  • r : サークルの半径を指定。
  • color: サークルのカラーを指定。

半径r分、X軸とY軸を移動することでサークル全体が表示できるため、次項の初期実装ではcxcyにそれぞれrを指定しています(cxcyにそれぞれ0を指定すると1/4のサークルが描画されます)。

サークルの実装

今回は最終的に複数のサークルを実装したいので、components/FloatingCircle/index.tsxcomponents/FloatingCircle/FloatingCircleItem/index.tsxを作成し、サークルの実装を行なっていきます。

components/FloatingCircle/FloatingCircleItem/index.tsx
import { Circle } from '@shopify/react-native-skia';

type Props = {
  r: number;
  color: string;
};

export const FloatingCircleItem: React.FC<Props> = ({ r, color }) => {
  return <Circle cx={r} cy={r} r={r} color={color} />;
};

<FloatingCircleItem />コンポーネントはSkiaの<Circle />コンポーネントでサークルの実態を実装しています。

components/FloatingCircle/index.tsx
import { Canvas } from '@shopify/react-native-skia';
import { FloatingCircleItem } from './FloatingCircleItem';

type Props = {
  items: React.ComponentProps<typeof FloatingCircleItem>[];
};

export const FloatingCircle: React.FC<Props> = ({ items }) => {
  return (
    <Canvas
      style={{
        flex: 1,
        backgroundColor: 'blue',
      }}
    >
      {items.map(({ r, color }) => (
        <FloatingCircleItem key={color} r={r} color={color} />
      ))}
    </Canvas>
  );
};

<FloatingCircle />コンポーネントはSkiaの<Canvas />コンポーネントでラップした<FloatingCircleItem />コンポーネントを配置し、受け取ったitemsをmapメソッドで展開しています。

app/index.tsx
import { FloatingCircle } from '@/components/FloatingCircle';
import { SafeAreaView, Text } from 'react-native';

const FLOATING_CIRCLE_ITEMS = [
  { r: 50, color: 'yellow' },
] as const satisfies React.ComponentProps<typeof FloatingCircle>['items'];

export default function Home() {
  return (
    <SafeAreaView style={{ flex: 1 }}>
      <Text>Floating Circle App</Text>
      <FloatingCircle items={FLOATING_CIRCLE_ITEMS} />
    </SafeAreaView>
  );
}

routingファイル側では<FloatingCircle />コンポーネントに必要なitemsを渡す形とします。今回は最終的に4つのサークルを作りたいので配列としてFLOATING_CIRCLE_ITEMSを作成していますが、まずはわかりやすいように1つのサークルから実装していきます。現在の実装で、アプリを起動すると<Canvas />が青、<Circle />が黄色で描画されているはずです。

サークルをふわふわさせる実装

上記で作成したサークルをふわふわと動かしてみます。ここからはロジックの実装になるため、カスタムフックとしてロジック部分を切り出します。components/FloatingCircle/FloatingCircleItem/useFloatingCircleItem.tsを作成し、以下のコードを追加します。

components/FloatingCircle/FloatingCircleItem/useFloatingCircleItem.ts
import { useCallback, useEffect } from 'react';
import {
  useSharedValue,
  withRepeat,
  withTiming,
  useDerivedValue,
  Easing,
} from 'react-native-reanimated';

type Props = {
  x: number;
  y: number;
  animationSize: number;
  duration: number;
};

export const useFloatingCircleItem = ({
  x,
  y,
  animationSize,
  duration,
}: Props) => {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);

  const memoizedWithRepeat = useCallback(
    () =>
      withRepeat(
        withTiming(animationSize, {
          duration,
          easing: Easing.inOut(Easing.ease),
        }),
        -1,
        true
      ),
    [animationSize, duration]
  );

  // X軸のアニメーション
  translateX.value = memoizedWithRepeat();

  // Y軸のアニメーション
  translateY.value = memoizedWithRepeat();

  const cx = useDerivedValue(() => translateX.value + x);
  const cy = useDerivedValue(() => translateY.value + y);

  return { cx, cy };
};

実装したコードの解説

components/FloatingCircle/FloatingCircleItem/useFloatingCircleItem.tsで実装したアニメーションのコードをそれぞれ上から順番に見ていきます。

Props

type Props = {
  x: number;
  y: number;
  animationSize: number;
  duration: number;
};

複数のサークルを作成するにあたり、それぞれ個別の位置、アニメーションの大きさ、スピードを設定したいため上記のpropsを受け取るように実装しています。

useSharedValueでアニメーションの値を定義

const translateX = useSharedValue(0);
const translateY = useSharedValue(0);

useSharedValue()はReact Native Reanimatedで用意されているAPIで、JavaScriptスレッドとUIスレッド間で値を共有してくれるものです。要はアニメーションするために必要な値を管理するstateのようなものです。X軸のアニメーションの値としてtranslateX、Y軸のアニメーションの値としてtranslateYをそれぞれuseSharedValue()で定義し、初期値は0としています。

アニメーションのロジックを実装

const memoizedWithRepeat = useCallback(
  () =>
    withRepeat(
      withTiming(animationSize, {
        duration,
        easing: Easing.inOut(Easing.ease),
      }),
      -1,
      true
    ),
  []
);

withTiming()は前述のReact Native Reanimatedで用意されているアニメーションの仕方を指定できるAPIで、withRepeat()はリピートの仕方を指定できるAPIです。useCallback()を用いてメモ化したアニメーション関数を宣言しています。

withTiming()

  • 第1引数: アニメーションの大きさ(type: number。本記事内のanimationSizeの部分。)
  • 第2引数: アニメーションの設定オブジェクト Optional
    • duration: アニメーションの速度(type: number
    • easing: 動き方(type: Easing
    • reduceMotion: デバイスの視覚効果を抑制するアクセシビリティ設定に対するアニメーションの応答方法(type: ReduceMotion

withTiming()は2つの引数を受け取ります。今回はeasingEasing.inOut(Easing.ease)を指定することでゆっくりとふわふわした動きを指定しています。他にもどのような動き方を指定できるかは公式ドキュメントをご参照ください。

withRepeat()

  • 第1引数: 繰り返したいアニメーションオブジェクト
  • 第2引数: 繰り返し回数 Optional(デフォルト値は2-1を指定することでループさせることができる。)
  • 第3引数: リバース設定 Optional(アニメーションを1回ごとに逆方向にも実行するか。デフォルト値はfalse。)
  • 第4引数: コールバック関数 Optional(アニメーション完了後に呼び出される関数を指定)
  • 第5引数: reduceMotion Optional(デバイスの視覚効果を抑制するアクセシビリティ設定に対するアニメーションの応答方法)

withRepeat()は5つの引数を受け取ります。今回の場合、第1引数にアニメーションオブジェクトwithTimign()、第2引数にループをさせるため-1、第3引数に反対方向にもアニメーションを実行させるためtrueを指定しており、第4引数、第5引数は指定していません。

アニメーションのロジックを計算した値を管理

// X軸のアニメーション
translateX.value = memoizedWithRepeat();

// Y軸のアニメーション
translateY.value = memoizedWithRepeat();

const cx = useDerivedValue(() => translateX.value + x);
const cy = useDerivedValue(() => translateY.value + y);

useSharedValue()で定義したtranslateXtranslateYの値にアニメーション関数の返り値を代入します。このままではアニメーションとしては利用できず、useDerivedValue()というAPIを用いることで実際のアニメーションとして提供できる値として管理することができます。<Circle />コンポーネントに渡す値としてcxcyをそれぞれ定義しており、useSharedValue()のコールバック関数内でアニメーションの範囲 + それぞれのサークルのポジションとして実装しています。

useDerivedValue()

useDerivedValue()はコールバック関数の返り値として2つの引数を受け取ります。

  • 第1引数: 更新関数。useSharedValue()などのstateを返す関数を指定する。
  • 第2引数: 依存関係 Optional

今回では、useSharedValue()で管理しているtranslateXおよびtranslateYの値を返す関数を指定しており、それぞれX軸、Y軸のポジション(サークルの半径r分)を足しています。これによりuseSharedValue()の値がアニメーション関数により計算された値に更新され、アニメーションとして利用することができるようになります。

UI側のコードを修正

カスタムフックに切り出したコードが作成できたので、UI側のそれぞれのファイルも修正していきます。

components/FloatingCircle/FloatingCircleItem/index.tsx
  import { Circle } from '@shopify/react-native-skia';

  type Props = {
+   x: number;
+   y: number;
    r: number;
    color: string;
+   animationSize: number;
+   duration: number;
  };

  export const FloatingCircleItem: React.FC<Props> = ({
+   x,
+   y,
    r,
    color,
+   animationSize,
+   duration,
  }) => {
+   const { cx, cy } = useFloatingCircleItem({ x, y, animationSize, duration});

+   return <Circle cx={cx} cy={cy} r={r} color={color} />;
  };
components/FloatingCircle/index.tsx
  import { Canvas } from '@shopify/react-native-skia';
  import { FloatingCircleItem } from './FloatingCircleItem';

  type Props = {
    items: React.ComponentProps<typeof FloatingCircleItem>[];
  };

  export const FloatingCircle: React.FC<Props> = ({ items }) => {
    return (
      <Canvas
        style={{
          flex: 1,
          backgroundColor: 'blue',
        }}
      >
+       {items.map((item) => (
+         <FloatingCircleItem key={item.color} {...item} />
        ))}
      </Canvas>
    );
  };

app/index.tsx
  import { FloatingCircle } from '@/components/FloatingCircle';
  import { SafeAreaView, Text } from 'react-native';

  const FLOATING_CIRCLE_ITEMS = [
    {
+     x: 50,
+     y: 50,
      r: 50,
      color: 'yellow',
+     animationSize: 20,
+     duration: 2000,
    },
  ] as const satisfies React.ComponentProps<typeof FloatingCircle>['items'];

  export default function Home() {
    return (
      <SafeAreaView style={{ flex: 1 }}>
        <Text>Floating Circle App</Text>
        <FloatingCircle items={FLOATING_CIRCLE_ITEMS} />
      </SafeAreaView>
    );
  }

これらの実装で以下のようにふわふわと動くサークルの実装ができていると思います。

4つのサークルを実装する

さて、ここまで来ればあとはapp/index.tsxで渡しているFLOATING_CIRCLE_ITEMSにアイテムを追加してあげれば完成です。animationSize-値を指定することで反対方向へのアニメーションから開始されるため、それぞれがバラバラの動きになりより不規則的な動きが実現できます。

app/index.tsx
  import { FloatingCircle } from '@/components/FloatingCircle';
  import { FloatingCircleItem } from '@/components/FloatingCircle/FloatingCircleItem';
  import { SafeAreaView, Text } from 'react-native';

   // 各サークルのポジション、大きさ、カラー、アニメーションの大きさ、スピードを指定
   const FLOATING_CIRCLE_ITEMS = [
+   {
+     x: 100,
+     y: 100,
+     r: 70,
+     color: 'red',
+     animationSize: -25,
+     duration: 2000,
+   },
+   {
+     x: 700,
+     y: 300,
+     r: 50,
+     color: 'green',
+     animationSize: 35,
+     duration: 3000,
+   },
+   {
+     x: 350,
+     y: 250,
+     r: 40,
+     color: 'pink',
+     animationSize: -30,
+     duration: 2500,
+   },
+   {
+     x: 500,
+     y: 50,
+     r: 60,
+     color: 'yellow',
+     animationSize: 40,
+     duration: 3500,
+    },
   ] as const satisfies React.ComponentProps<typeof FloatingCircle>['items'];

  export default function Home() {
    return (
      <SafeAreaView style={{ flex: 1 }}>
        <Text>Floating Circle App</Text>
        <FloatingCircle items={FLOATING_CIRCLE_ITEMS} />
      </SafeAreaView>
    );
  }

完成系

これで4つのサークルをそれぞれバラバラの動き方でふわふわさせる実装が完了です!

最後に

今回、React Native Skiaを使ってふわふわ動くサークルを実装しました。本記事の内容をもとに様々な動き方をするサークルにアレンジができるかと思いますのでぜひ色々と試してみてください!本記事がReact Nativeアプリ開発者の皆様にとって参考になれば嬉しく思います。最後まで読んでいただきありがとうございます。

Gemcook Tech Blog
Gemcook Tech Blog

Discussion