SVGでハンドライティングアニメーションを実装する
文字を書き順通りに徐々にアニメーションさせて描画する「ハンドライティングアニメーション」をSVGで実装する方法。
以前に AfterEffects で同じようなことをやっていて、それのSVGバージョン。AfterEffects の場合は、当然ながら動画として出力しvideo
要素で動画を埋め込んで表示させるわけだけど、SVGの場合は、(主に)インラインSVGを設置してJavaScriptでアニメーションさせて実現する。
少し前までは、一部のブラウザでうまく表示できなかった(気がした)が、今は全く問題なく表示させることができるようになっていた。古いIE(10以前)ではさすがに無理だけど、もう考慮する必要はないと思われるので、十分に使える表現だと思う。
SVGの作成が意外に面倒だったので作成時のポイントとか、HTMLへ設置する方法、JS実装ではまった点等を覚書として書いておく。
ハンドライティングアニメーションとは
正直、正式な名称はよく分からないんだけど、以下のようなアニメーションを「ハンドライティングアニメーション」と勝手に呼んでいる。
これをSVGで表現する場合、SVGのマスキング(mask
要素)を用いる方法とクリッピング(clipPath
要素)を用いる方法の2通りの方法がある。両方とも試してみて、簡単なデモも作ってみた。
Chrome、Safari、Firefoxでは、どちらも問題なく表示できているけど、EdgeとIE11ではクリッピングを用いた方法がうまく表示できていない。理由はよく分からなかった(SVGの書き方がよくないのかもしれない)が、記述的にもマスキングの方がシンプルなので、実際に使用する際はmask
要素を用いた方法でやるのがいいと思う。ちなみにJSはどちらも同じ。
デモのコード一式はGitHubにアップしてある。
大まかな仕組みや必要なもの
ざっくりと解説すると以下のような感じになる。
必要なSVG画像
前述の通り、この実装には2通りの方法があるが、どちらの場合もそのものの形をあらわすベースとなるSVG画像(以下「ベース画像」という)とベース画像に沿ってアニメーションするSVG画像(以下「アニメーション画像」という)の2つのSVGが必要となる。
ベース画像はそのものの形をあらわす外枠となる画像なので、必然的にクローズドパスとなるが、アニメーション画像は一方向に進むだけの画像なので、基本的にはオープンパスとなる(絶対ではない)。
今回は、JSの実装上、アニメーション画像のSVGはpath
要素で構成されている必要があるので、その点だけ注意が必要。
なお、クローズドパスでも複雑な形であればpath
要素で出力されるし、オープンパスでもpath
要素で出力されない場合もある。また、設定でも変わってくると思うので、出力されたSVGコードの確認は必須。この辺りは後述する。
余談だけど、ベース画像をSVG画像ではなく、SVGのtext
要素で代用することもできた。ただ、位置を合わせるのが非常にめんどくさいのと、レスポンシブ化がほぼほぼ不可能なのでやらない方がいい。
仕組み
簡単な仕組みを説明すると、
- アニメーション画像の
strokeDasharray
とstrokeDashoffset
にそのpath
のlength
値をセットし、アニメーション画像のstroke
が全く表示されない状態にする - マスキングの場合にはマスクの効果によって、クリッピングの場合にはクリップマスクの効果によって、ベース画像の範囲を超えて描画されないようにSVGで配置(
mask
属性またはclip-path
属性を用いてベース画像とアニメーション画像をリンク)する - JSでアニメーション画像の
strokeDashoffset
値を徐々に減らしていくことで、アニメーション画像のstroke
が徐々に表示される(strokeDashoffset
が 0 になると全長が表示される)。それに伴いベース画像が塗りつぶされていくように見える - 手で書いたような(ハンドライティング)アニメーションになる
という感じ。
SVGのstrokeDasharray
とstrokeDashoffset
を利用したアニメーションは前からよくあるけど、これとマスク(またはクリッピング)の効果を利用しているという点が肝になってる。
SVG画像の作成
まず、mask
要素を用いる方法の場合、ベース画像は外部SVGファイル、アニメーション画像はインラインSVGが必要になる。ベース画像はSVG形式でなく、透過のPNG形式でも大丈夫だけど、Retinaディスプレイ等で閲覧した場合、ぼやけることがあるのでSVG形式がオススメ。
clipPath
要素を用いる方法の場合には、ベース画像、アニメーション画像ともにインラインSVGとなる。
作成のポイント
SVG画像を作成する際のポイントは以下の通り。
- ベース画像をアニメーション画像の線で確実、かつ重ならないようにギリギリで覆う
- アニメーション画像は
path
要素で出力されるよう考慮する - ベース画像、アニメーション画像ともに同じ大きさ(
viewBox
値)となるようにする
作成方法
SVGを作成するツールはなんでもいい。以下はイラストレーターを使用した場合の記載となる。また、文字(テキスト)をアニメーションさせる想定で進める。なお、同じアートボード上にベース画像、アニメーション画像を同時に作成していく。
新規作成
まず、カラーコードをRGBにして新規ドキュメントを作成する。大きさは適当でいいけど、小さすぎるとSVGに出力した際に数値がおかしくなるので、なるべく大きめで作っておく。
ベース画像の作成
ベース画像は、文字ツールを使って好きなテキストをタイプし、アウトライン化する。カーニングとかは適当に。ベース画像はこれだけで完了。
アニメーション画像の作成
アニメーション画像は、ベース画像に合せて描画させたい順(アニメーション順)にペーンツールでアンカーポイントを打っていき、線(の太さ)で覆っていく。
パスは、文字ごとに分けても、繋げられるだけ繋げてしまってもいい。ただ、文字ごとに分けた方が線の太さを途中で変えられたり、path
ごとに色を変えることができたりするので柔軟性が高くなる。
オープンパスが基本だけど、SVGに出力された時にpath
要素として出力されればいいので、閉じてしまってもpath
要素で出力されていれば問題はない。
パスが直線の場合は、オープンパスであってもline
要素として出力されてしまうので、もし一直線のパスがある場合には「複合パス」にしておくことでpath
要素で出力されるようになる(ここはイラストレーターの設定によって変わるかもしれないが念のため)。
ベース画像を全てパス(線)で綺麗に覆うことができたらアニメーション画像は完了。
viewBox について
ベース画像とアニメーション画像は別々に使用するわけだけど、個別にSVGとして保存してしまうとviewBox
の値が異なってしまうので、一旦、それぞれでグループ化だけしておいて(レイヤー名を半角英数でつけておくと分かりやすい)一緒にSVG化してしまうのがいい。
また、viewBox
の値はアートボードの大きさと同じになるため、(大きい方の)アニメーション画像に合わせたサイズに調整しておく必要がある。
なお、アニメーション画像は「線」だけでできているので、そのままだと「線」を含めた正確な大きさが分からない。もし正確な大きさを割り出したければ、一旦「オブジェクト」→「パス」→「パスのアウトライン」でパスをアウトライン化すれば正確な値を知ることができる(小数点は繰上げでおk)。
SVGで保存
SVGにする方法はいくつかあるが、「別名で保存」からのファイル形式を「SVG(svg)」にして「保存」すると、「SVGオプション」→「詳細オプション」でいろいろと設定できて分かりやすかった。
後にSVG化したファイル自体を編集する必要があるので、詳細オプションの「CSS プロパティ」を「スタイル要素」としておくと色々と捗る。
「小数点以下の桁数」は、1〜3ぐらいにして、「エンコーディング」は「UTF-8」。「レスポンシブ」にチェックを入れるとwidth
やheight
が省略されるようなので、チェックを入れた場合には、CSS側で設定する必要がある。
設定の問題なのかもしれないけど「書き出し」でSVG化するとワンライン(改行が削除された状態)で出力されてしまい、その後の加工が大変になる。「別名で保存」の場合には、適度に整形されていて扱いやすかった。
これでSVGの作成は完了。
HTMLへ設置
作成したSVGをエディタで開き、ベース画像とアニメーション画像に分離する。イラストレーターでそれぞれグループ化してあれば、ベース画像とアニメーション画像はg
要素で分離され、id
属性にレイヤー名がセットされているはず。
マスキングの場合
アニメーション画像の部分をg
要素ごと切り取り、HTMLの任意の場所へ貼り付ける。g
要素はmask
要素へ変更しid
属性を付加、defs
要素でラップした上でsvg
要素でマークアップする。
そして、各path
要素にclass
属性をセットし、各path
要素にあったstroke-width
値をCSSで指定し、fill:none;
とstroke:#fff;
を全path
要素へCSSであてる。
アニメーション画像を切り取って残った部分は、ベース画像として利用するため、不要な部分(style
要素とか、g
要素もいらない)を削除した上で、svg
要素にfill
属性を付加し描画させたい色を指定する(ここ重要)。path
要素ごとにfill
属性をセットすれば、それぞれで色を切り替えることもできる。
マスキングの場合、ベース画像は外部ファイルとするので、これを保存して、HTMLのsvg
要素にimage
要素を追加し、xlink:href
とmask
属性でアニメーション画像とベース画像をリンクさせる。
あとはコードを見れば分かる。
index.html
<svg class="p-svg__mask" viewBox="0 0 892 135" id="js-mask">
<defs>
<!-- アニメーション画像 -->
<mask id="clipMask">
<path class="p-svg__path sw15" d="...<!-- 省略 -->..."/>
<!-- 省略 -->
<path class="p-svg__path sw20" d="...<!-- 省略 -->..."/>
</mask>
</defs>
<!-- ベース画像 -->
<image xlink:href="./images/base.svg" width="100%" height="100%" mask="url(#clipMask)"></image>
</svg>
_svg.scss
.p-svg__path {
fill: none;
stroke: #fff;
&.sw13 {
stroke-width: 13px;
}
&.sw14 {
stroke-width: 14px;
}
&.sw15 {
stroke-width: 15px;
}
&.sw16 {
stroke-width: 16px;
}
&.sw19 {
stroke-width: 19px;
}
&.sw20 {
stroke-width: 20px;
}
}
マスク効果の黒を透明にし白を描画色で表示するという性質を利用しているので、アニメーション画像を白くして(stroke:#fff;
)、ベース画像自体に表示させたい色を指定している。
クリッピングの場合
クリッピングの場合は、ベース画像、アニメーション画像ともにインラインSVGとしてHTMLへ貼付する。ベース画像部分をclipPath
要素で囲み、色はアニメーション画像で指定する。関連づけが必要になるので、アニメーション画像はg
要素で囲んでおく。
クリッピングに関しては、コードを見ればだいたい分かると思う。CSSは上記と同じなので省略。
index.html
<svg class="p-svg__clip" viewBox="0 0 892 135">
<defs>
<!-- ベース画像 -->
<clipPath id="clipPath">
<path d="...<!-- 省略 -->..."/>
<!-- 省略 -->
<path d="...<!-- 省略 -->..."/>
</clipPath>
</defs>
<!-- アニメーション画像 -->
<g id="js-clip" clip-path="url(#clipPath)">
<path class="p-svg__path sw15" d="...<!-- 省略 -->..."/>
<!-- 省略 -->
<path class="p-svg__path sw20" d="...<!-- 省略 -->..."/>
</g>
</svg>
JavaScriptでアニメーション
はじめに、JSについては以下を大いに参考にさせてもらいました。
使い方など
SVGのアニメーションは、JSのrequestAnimationFrame
で行っている。ライブラリを利用するのが手っ取り早いが、requestAnimationFrame
を使いたかったのでライブラリは使用しなかった。
使い方は、querySelectorAll
でアニメーションさせたいSVGのpath
を取得して、new
してplayAnimation()
を実行すれば動く。SVGの構成なりが間違っていなければ、配置したpath
順に次々自動でアニメーションを開始する。
JavaScript
const target = Array.from(document.querySelectorAll('#id-name path'));
const phw = new PlayHandwriting(target, speed, interval); // 第一引数に要素(path要素を指定)、第二引数にスピード(数が小さい方が早い)、第三引数にpath間のインターバル(ミリ秒)
phw.playAnimation();
EdgeとIE11では、querySelectorAll
で取得した要素(NodeList)をforEach
で配列として渡せない(Arrayではないから?)ようなので、Array.from
でNodeListからArrayを生成すればいけた。
アニメーションのスピードに関して、一貫して(全長で)同じ速度でアニメーションさせたかったため、一般的なアニメーションにおける時間単位の指定ではなく、ただの数値(小さいほど早い)を第二引数に指定することでアニメーションスピードを変更できるようにした。「全長で何秒」みたいな指定が理想だったんだけど、速さ・時間・距離みたいなのが苦手すぎて無理だった(もう考えたくなかった)。
個人的に、new
を配列に突っ込んでforEach
で回してインスタンスを生成するっていうのが新たな発見だった。また、forEach
便利すぎて使いまくってる。
JavaScriptに関しては、ほぼ全ての部分ではまったし、自信ないので変なところがあったら教えてもらいたいです。
終わり
これ読んで実装できる人は、これ読まなくても実装できるんじゃないかってくらいの内容ですが、あくまでも自分用のメモなのでご容赦ください。
もし読んでいただいて間違いとかあったらお気軽に指摘していただけれると嬉しいです。