Skiaのアニメーションモデル
Skiaのコンポーネントがどのように「動き」を表現するのか、useState+requestAnimationFrameによる素朴な方法から、Skiaが提供するuseClockやパス補間フックまでを見ていきます。
これまでの章で扱ってきたのは、いずれも静止した1枚の絵でした。ここからの第5部では、その絵をどう動かすかを扱います。
<Canvas>配下のpropsは、cx={128}のような固定値だけでなく、時間とともに変わる値も受け取れます。値が変われば、Skiaの専用レンダラー(第1部で見たシーングラフへの変換)がその差分を検知して再描画します。「毎フレームcanvas.drawCircle()を呼ぶ」ような命令的なループを自分で書く必要はありません。propsの値を更新するだけで、あとはSkia側が面倒を見てくれます。
もっとも素朴な方法: useState + requestAnimationFrame
Reanimatedを使わなくても、ReactのuseStateとrequestAnimationFrameだけでアニメーションは書けます。
import { useEffect, useState } from "react";
import { Canvas, Circle } from "@shopify/react-native-skia";
const RawLoopDemo = () => {
const [angle, setAngle] = useState(0);
useEffect(() => {
let frame: number;
const tick = () => {
setAngle((a) => a + 0.02);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, []);
const cx = 128 + 60 * Math.cos(angle);
const cy = 128 + 60 * Math.sin(angle);
return (
<Canvas style={{ flex: 1 }}>
<Circle cx={cx} cy={cy} r={20} color="cyan" />
</Canvas>
);
};
外部システム(ブラウザのフレームタイミングAPI)との同期なので、useEffectを使うのはReactの流儀として妥当です。ただしこの方法は、毎フレームsetAngleを呼ぶたびにJavaScriptスレッド上でコンポーネントの再レンダリングが起き、その結果をSkiaのレンダラーに渡す、という経路を通ります。フレームレートが低いデバイスや、他の処理でJSスレッドが混み合っている場面では、動きがカクつく原因になります。
React Native SkiaはReanimatedと組み合わせることで、この再レンダリングの経路を丸ごと省略できます。次章で詳しく扱いますが、この章ではまずSkiaが用意しているアニメーション専用のフックを見ていきましょう。
useClock: 経過時間を返すクロック
useClockは、マウントしてからの経過時間(ミリ秒)を返すフックです。戻り値はReanimatedの共有値(Shared Value)で、useDerivedValueの中で読み取ると、UIスレッド上で毎フレーム再計算されます(Web上でこの自動再計算を機能させる条件については、この章末の「注意点」を参照してください)。
import { Canvas, Circle, useClock } from "@shopify/react-native-skia";
import { useDerivedValue } from "react-native-reanimated";
const ClockDemo = () => {
const clock = useClock();
// 第2引数のdependenciesについては章末の「注意点」を参照。
const cx = useDerivedValue(
() => 128 + 60 * Math.cos(clock.value / 500),
[clock]
);
const cy = useDerivedValue(
() => 128 + 60 * Math.sin(clock.value / 500),
[clock]
);
return (
<Canvas style={{ flex: 1 }}>
<Circle cx={cx} cy={cy} r={20} color="cyan" />
</Canvas>
);
};
先ほどのrequestAnimationFrame版と見た目の結果はほぼ同じですが、仕組みはまったく違います。clockの更新もcx・cyの再計算もUIスレッド上で完結し、Reactの再レンダリングは1度も発生しません。共有値やworkletの詳細は次章で扱いますが、ここでは「Skiaは時間経過そのものを共有値として扱えるフックを用意している」という点だけ押さえておいてください。
パスやバッファを補間するフック
useClock以外にも、Skiaはアニメーション向けのフックをいくつか提供しています。いずれもReanimatedの共有値を返すか、worklet内で更新する前提で作られています。
| フック | 用途 |
|---|---|
usePathValue |
PathBuilderを使ってパスを構築し、その結果を共有値として返す |
usePathInterpolation |
progress値に応じて、複数のパスの間をなめらかに補間する |
useVectorInterpolation |
ベクトル({x, y})値の補間 |
useRectBuffer |
Atlasなどが受け取る矩形の配列をworklet内から更新できるバッファにする |
useRSXformBuffer |
Atlasが受け取る回転・スケール変換の配列をworklet内から更新できるバッファにする |
usePathInterpolationは、アイコンの形が切り替わるようなアニメーションに向いています。ただし補間対象のパスは、コマンドの数と種類が揃っている(interpolatableである)必要があります。
import { usePathInterpolation, Canvas, Path, Skia } from "@shopify/react-native-skia";
import { useSharedValue } from "react-native-reanimated";
const angryPath = Skia.Path.MakeFromSVGString("...")!;
const normalPath = Skia.Path.MakeFromSVGString("...")!;
const goodPath = Skia.Path.MakeFromSVGString("...")!;
const FaceDemo = () => {
const progress = useSharedValue(0);
const path = usePathInterpolation(
progress,
[0, 0.5, 1],
[angryPath, normalPath, goodPath]
);
return (
<Canvas style={{ flex: 1 }}>
<Path path={path} style="stroke" strokeWidth={5} strokeCap="round" />
</Canvas>
);
};
下のデモは、頂点数を揃えた3つの形(円に近い形・星形・波形)をusePathInterpolationで滑らかに繋いでいます。スライダーでprogressを動かすと、形が連続的に変形していく様子がわかります。
useRectBuffer・useRSXformBufferは、第2部で扱った<Atlas>のように、大量のインスタンスをまとめて動かしたい場面で使います。useTextureと組み合わせたテクスチャアニメーションは第4章で改めて扱います。
注意点
これらのフックはどれも内部でReanimatedの共有値・useDerivedValue・useAnimatedReactionなどに依存しています。つまりreact-native-reanimatedのインストールが前提です。単発のトランジションだけならuseStateでも十分ですが、毎フレーム値が変わり続けるアニメーションでは、次章で扱うReanimated連携を使うほうがパフォーマンス面で有利です。
Web上でのuseDerivedValueについて: useDerivedValueは本来、渡したコールバックが参照している共有値(この例ではclock)をBabel/SWCのreact-native-workletsプラグインが自動検出し、その値が変わるたびに再計算します。しかしこのプロジェクトのようにMetro/Babelを使わないVite製のWebビルドでは、そのプラグインが動いていないため自動検出が機能せず、useDerivedValueは初回計算のまま値が凍りついてしまいます。回避策は、上のコード例のように第2引数のdependencies配列に、コールバックが参照している共有値を明示的に渡すことです。これはReanimated自身が「Babelプラグイン無しでWebで使う場合のためのAPI」として公式にドキュメント化している挙動です。ネイティブ(iOS/Android)ではBabelプラグインが動くため、dependenciesを省略してもこの問題は起こりません。
公式ドキュメントのAnimations・Hooksも参照してください。