この記事の概要
以下の記事を書きました。
素朴な HTML と JavaScript だけで実現したのですが、実際に使うことを考えると、もう少し再利用性を高めたいです。
というわけで、React コンポーネントとして実装し直し、それも記事にしました。
この記事は、上記の記事を読んでいるという前提で進めます。
完成形
最初に、完成形を載せておきます。
import { useCallback, useEffect, useRef } from "react";
type PathPair = {
beforePath: string;
afterPath: string;
fill?: string;
}
type MorphingSVGProps = {
paths: PathPair[];
trigger: boolean;
width?: number;
height?: number;
}
export function MorphingSVG({
paths,
trigger,
width,
height,
}: MorphingSVGProps) {
const pathRefs = useRef<(SVGPathElement | null)[]>([]);
const lerp = useCallback((start: number, end: number, t: number): number => {
return start + (end - start) * t;
}, []);
const parsePath = useCallback((path: string): number[] => {
return path.match(/[-+]?[0-9]*\.?[0-9]+/g)?.map(Number) || [];
}, []);
const generatePathString = useCallback(
(commands: { cmd: string; length: number }[], values: number[]): string => {
let valueIndex = 0;
return commands.reduce((pathString, command) => {
const commandValues = values.slice(
valueIndex,
valueIndex + command.length
);
valueIndex += command.length;
return pathString + command.cmd + commandValues.join(" ");
}, "");
},
[]
);
const morphPaths = useCallback(
(path1: string, path2: string, t: number): string => {
const commands =
path1.match(/[a-zA-Z][^a-zA-Z]*/g)?.map((cmd) => {
const numbers = cmd.slice(1).match(/[-+]?[0-9]*\.?[0-9]+/g) || [];
return { cmd: cmd[0], length: numbers.length };
}) || [];
const values1 = parsePath(path1);
const values2 = parsePath(path2);
const morphedValues = values1.map((val, i) => lerp(val, values2[i], t));
return generatePathString(commands, morphedValues);
},
[parsePath, generatePathString, lerp]
);
useEffect(() => {
if (trigger) {
let t = 0;
const animate = () => {
t += 0.02;
if (t > 1) t = 1;
paths.forEach((pathPair, index) => {
const morphedPath = morphPaths(
pathPair.beforePath,
pathPair.afterPath,
t
);
if (pathRefs.current[index]) {
pathRefs.current[index]!.setAttribute("d", morphedPath);
}
});
if (t < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
}, [trigger, paths, morphPaths]);
return (
<svg
width={width ? width : 100}
height={height ? height : 100}
viewBox={`0 0 ${width ? width : 100} ${height ? height : 100}`}
xmlns="http://www.w3.org/2000/svg"
>
{paths.map((pathPair, index) => (
<path
key={index}
ref={(el) => (pathRefs.current[index] = el)}
d={pathPair.beforePath}
fill={pathPair.fill ? pathPair.fill : "#000"}
/>
))}
</svg>
);
}
次のように利用します。
import { useState } from "react";
import { MorphingSVG } from "./components/MorphingSVG";
function App() {
const [trigger, setTrigger] = useState(false);
const paths = [
{
beforePath: "M50 50L150 50L150 150L50 150Z",
afterPath: "M115 50L180 115L115 180L50 115Z",
fill: "#f00",
},
{
beforePath: "M49.6025 13L92.9038 88L49.6025 88L6.30127 88L49.6025 13Z",
afterPath: "M88 12L88 88L12 88L12 12L88 12Z",
fill: "#00f",
},
];
return (
<div style={{ display: "grid", justifyItems: "center" }}>
<h1>Self-made SVG Morphing</h1>
<MorphingSVG
paths={paths}
trigger={trigger}
width={200}
height={200}
/>
<button onClick={() => setTrigger(true)}>Morph</button>
</div>
);
}
export default App;
前の記事の内容から変わったこと
- 複数の
path
を指定できるようになった- それぞれのパスの色も変えられる
- 変形タイミングが操作しやすくなった
-
trigger
にtrue
が渡ったタイミングで変形する - 上に書いたように
onClick
を起点にするも良し、他のフックと同じタイミングで変形するも良し
-
- svg 要素の大きさを変えられるようになった
説明
PathPair
の形式でprops
を受け取れるようにすることで、複数のパスが一度に変形できています。
また、個別にfill
も設定できるようになっています。
また、props
としてwidth
やheight
も用意したので、svg 要素そのものの大きさも変えることができます。
これにより、画面内に複数の svg を配置し、それぞれを動かしたいときでも楽に扱えるようになりました