こんにちは、「ふ」です。
このページをここまでスクロールさせた方はもうお分かりかと思いますが、今回はマウスホイールやタッチムーヴによる「スクロール操作に連動して要素をアニメーションさせる」機能を実装したいと思います。
作っていく内容は、次の通りです。
ユーザがページを閲覧中、アニメーションさせたい要素が画面に入ってきたら、そこでスクロールを一旦停止。
停止下において、ユーザがスクロール操作(PCならマウスのwheel、タッチデバイスであればtouchmove)を行った場合、その量に合わせて要素がアニメーション。今回は「回転させる」ことにしました。
ユーザの操作量が2000pxに達したところでアニメーション完了。
画面のスクロール停止も解除します。
今回は少しばかり複雑なコードの構成となっています。この実装、プラグインなどでは見かけますが、素のJavaScriptだけで実装する方法を紹介します。
アニメーションの動きには「回転」を採用していますが、CSSを指定する部分のコードを変更するだけで、拡大/縮小や移動そのほか、あらゆる連続的プロパティの変化を実装することができます。
是非みなさんも挑戦してみてください。サイトを見に来たユーザを楽しませることができますよ。
■ fu.svg 動かしたい要素を準備して、HTML内に配置しましょう。
<div id = "area"> bb<img id = "fu" src = "fu.svg" style = "height:50%;"> </div>
コードを見てわかるように、targetとなるimgをわざわざ<div>で囲んで親子関係にしています。
要素が画面に入った/出たについては、IntersectionObserverで監視する予定です。
要素自体を監視対象にした場合、transformで回転や拡大などしたときに、IntersectionObserverの監視領域からはみ出してしまい、予期せぬ交差判定が発動してしまう恐れがあります。
そのため要素自体を監視対象にするのではなく、変形することのない親要素をその対象にすることで、transformによる「監視領域からのはみ出しによる誤作動」を防いでいます。
JavaScriptコードの全貌です⬇︎。
大きく分けると以下3つのセクションとなります。
「「2//A スクロールの禁止とイベントリスナ」」 const area = document.getElementById("area"); const fu = document.getElementById("fu"); let options = { threshold:1 } let observer = new IntersectionObserver(callback,options); observer.observe(area); function callback(entries) { if(entries[0].isIntersecting) { bbdocument.body.style.overflow = "hidden"; bbwindow.addEventListener("wheel",wheel); bbwindow.addEventListener("touchmove",move); } else { bbdocument.body.style.overflow = "auto"; bbwindow.removeEventListener("wheel",wheel); bbwindow.removeEventListener("touchmove",move); bb} } 「「2//B ユーザのスクロール量を検知」」 let sum = 0; let maemove = 0; let atomove = 0; let maestamp = 0; let atostamp = 0; function move(e) { let imamove = e.touches[0].clientY; let imastamp = e.timeStamp; if(maestamp == 0) { bbmaestamp = imastamp; } else { atostamp = imastamp; if(atostamp - maestamp >100) { maemove = 0; } maestamp = imastamp; } if(maemove == 0) { bbmaemove = imamove; } else { bbatomove = imamove; bbsum = sum + (maemove-atomove); bbkaiten(); bbmaemove = imamove; bb} } function wheel(e) { sum = sum+e.deltaY; kaiten(); } 「「2//C スクロール量に合わせてアニメーション」」 function kaiten() { if(sum>0 && sum < 2000) { bbfu.style.transform = `rotate(${360*sum/2000}deg)`; bbdocument.body.style.overflow = "hidden"; } else if(sum >= 2000) { bbsum = 2000; bbfu.style.transform = "rotate(360deg)"; bbdocument.body.style.overflow = "auto"; } else if(sum <= 0) { bbsum = 0; bbfu.style.transform = "rotate(0deg)"; bbdocument.body.style.overflow = "auto"; } }
A. スクロールの禁止とイベントリスナ
要素が画面に入ったらスクロールを一時的に禁止、ユーザのスクロールアクションに対してイベントリスナを実装します。
「画面に表示されたかどうか」の監視については、要素と画面の交差判定をおこなうIntersectionObserverAPIを採用しました。
B. ユーザのスクロール量を検知
スクロール禁止下において、ユーザのスクロールアクションが「合計何px分行われたか」を検出します。その結果を次の「要素を変形させる関数」に渡します。
wheelとtouchmoveについて、event.typeで条件分岐しても構わないのですが、別々の関数定義としました。そのほうがコードが見やすいからです。
C. スクロール量に合わせてアニメーション
Bで渡された値をもとに、要素を変形させます。ユーザのアクションが起きるたびにこの関数は呼び出されるため、アニメーションしているように見せることができます。
それでは、これら3つのセクションについて解説していくとします。
スクロールの禁止とイベントリスナ指定の部分です。
// アニメーション要素と親要素を取得 「「1..@1@」」 const area = document.getElementById("area"); const fu = document.getElementById("fu"); // 交差判定の基準を指定 「「1..@2@」」 let options = { bbthreshold:1 bb}; // インスタンスを生成 「「1..@3@」」 let observer = new IntersectionObserver(callback,options); // 監視対象を指定 「「1..@4@」」 observer.observe(area);
@1@ 目的の要素とその外側に設けたdiv領域を取得。
@2@ IntersectionObserverの第2引数に渡すoptionsオブジェクトです。要素全体が画面に入ってから処理を開始したいので、thresholdを最大値の「1」としています。
@3@ IntersectionObserverのインスタンスを生成。
第1引数にはこの後記述するcallback関数、第2引数には@2@のoptionsオブジェクトを指定します。
ちなみにcallbackは関数なので後ろに記述しても巻き上げられるのですが、optionsはオブジェクト形式のため、インスタンス生成の後に記述するとChromeがerrorを吐きます。そのため@2@を先に記述しておきました。
@4@ observeメソッドで目的の要素を監視対象にします。HTMLのところでお話ししたように、対象は要素現物の「#fu」ではなくて親要素である「#area」に指定します。
IntersectionObserverの詳しい知識については、⬇︎の記事を参考にしてください。
IntersectionObserverを使いこなす(理解編)。
「動いているだけのアニメーション」の先へと進もう。
// callback関数 「「1..@5@」」 function callback(entries) { // 要素が画面内に入っていれば 「「1..@6@」」 if(entries[0].isIntersecting) { bb// スクロールを禁止する 「「1..@7@」」 bbdocument.body.style.overflow = "hidden"; bb// イベントリスナを指定 「「1..@8@」」 bbwindow.addEventListener("wheel",wheel); bbwindow.addEventListener("touchmove",move); } else { bb// 画面から出たら、@7@@8@の指定をキャンセル 「「1..@9@」」 bbdocument.body.style.overflow = "auto"; bbwindow.removeEventListener("wheel",wheel); bbwindow.removeEventListener("touchmove",move); bb} }
@5@ 第1引数に入れた、呼び出されるcallback関数です。
@6@ IntersectionObserverでは、対象要素が指定範囲から出たときにもcallback関数を呼び出してしまいます。そのため「範囲に入っている/出ている」の真偽値を返すisIntersectingプロパティを使って条件分岐を行います。
@7@ 範囲内に要素が入っているときの処理内容です。 <body>要素に対しoverflow:hiddenを指定すると自動的にスクロール不可となります。
@8@ スクロール禁止の状態で、ユーザがスクロール操作を行ったときのイベントハンドラを指定します。
PCの場合はwheelイベント、スマホなどタッチデバイスにはtouchmoveイベントを指定しました。
それぞれ第2引数に指定した関数については、後ほど解説します。
@9@ 条件分岐のelse、つまり「要素が範囲から出た」時の処理です。@7@@8@で指定したものと全く逆の指定をすることにより、スクロールの禁止を解除、イベントリスナのキャンセルを施します。
CSS/JavaScript、 スクロールの禁止と解除。
1番軽いやり方を見つけました。
要素が画面に表示されて、スクロールが禁止されました。
その状況下でユーザがスクロール操作を行った場合に呼び出されるのが、先ほどのイベントリスナの第2引数に指定した関数です。
ユーザは1回だけでなく、何度かwheelまたはtouchmoveの操作を行うでしょう。また、マイナス方向にスクロール操作を行うケースも考えられます。
それに対し、現在何px分のスクロール処理を行ったことになるのかを算出する関数です。
// スクロール操作の合計を格納する変数 「「1..@10@」」 let sum = 0;
@10@ 「現在何px分のスクロール処理をおこなったことになるのか」を格納しておくために、変数「sum」を用意しました。
wheel/touchmoveいずれの結果も、この変数に代入します。
// マウスホイールの量を検出 「「1..@11@」」 function wheel(e) { sum = sum+e.deltaY; kaiten(); }
@11@ PCのマウスホイールの量は、イベントのdeltaYプロパティを使えば簡単に検出できます。
プラス/マイナス方向の値を返してくれるため、その結果を順次変数sumに加算していきます。加算したのち、最終的に要素を変形させる「kaiten」関数を呼び出します。
次はスマホなどのタッチデバイスに対してのもの。
基本的には移動前後のタッチ位置の差を算出し、先ほどの変数「sum」に加算していくのですが。wheelの場合と違い、こちらは厄介です。
touchmoveの位置は、clientYプロパティ(画面に対するY方向の位置)で抽出します。
ユーザが1回だけtouchmoveを行ったのであれば、問題ありません。フツーにその差を算出すればいいことです。
問題となるのが、例えば、はじめに画面下のほうでtouchmoveしたあと、次に画面上のほうでtouchmoveしたときです。
clientYプロパティは「移動量」ではなくあくまで「画面に対する位置」しか返してくれないので、タッチ場所の差分が移動量として加算されてしまいます。
そのため1回目のtouchmoveが終わったら、一旦カウントをリセットする必要が出てきます。
そこで利用するのが、timeStampプロパティです。
timeStampプロパティは、イベントの発生を時間軸で返してくれるものです。
touchmoveのタイムラグが0.1s以上であれば一旦カウントをリセットするように指定すれば、タッチ位置が変わってもその差分をキャンセルすることができます。
0.1s以内で2回touchmoveできる人は、この世にいません、多分。
// 前後のタッチ位置 「「1..@12@」」 let maemove = 0; let atomove = 0; // 前後のタイミング 「「1..@13@」」 let maestamp = 0; let atostamp = 0;
@12@@13@ 移動前と移動後のタッチ位置を格納する変数「maemove」「atomove」、前後のイベント発生タイミングを格納する変数「maestamp」「atostamp」を用意しました。
この記述は関数の外側で宣言し、いづれも初期値を0としておきます。
// touchmoveの量を検出 「「1..@14@」」 function move(e) { // 現在の位置と時間を取得 「「1..@15@」」 let imamove = e.touches[0].clientY; let imastamp = e.timeStamp; // もし初期値のままなら 「「1..@16@」」 if(maestamp == 0) { bbmaestamp = imastamp; } else { 「「3// すでにmaestampに値があれば 」」「「1..@17@」」 atostamp = imastamp; 「「3// 0.1s以上のタイムラグがある 」」「「1..@18@」」 if(atostamp - maestamp >100) { 「「3// 前回のタッチ位置をリセット 」」「「1..@19@」」 maemove = 0; } 「「3// 判定後、maestampを上書き 」」「「1..@20@」」 maestamp = imastamp; } 「「3// 前回のタッチ位置が初期値であれば 」」「「1..@21@」」 if(maemove == 0) { bbmaemove = imamove; } else { 「「3// すでにmaemoveに値があれば 」」「「1..@22@」」 bbatomove = imamove; 「「3// スクロール操作の合計に加算、関数呼び出し 」」「「1..@23@」」 bbsum = sum + (maemove-atomove); bbkaiten(); 「「3// 前回のタッチ位置を上書き 」」「「1..@24@」」 bbmaemove = imamove; bb} }
@14@ ここからtouchmoveの合計を算出する関数です。
@15@ 現在のtouch位置とtimeStampを取得。
垂直方向のtouch位置は、イベントのtouches→clinetYプロパティを参照します。なおtouchesプロパティは配列形式で返されるため、添字を付けて呼び出しています。
@16@ 先にtimeStampの判定から行っていきます。もし@13@で宣言した変数maestampが初期値の「0」であれば(←つまり初めてのタッチ操作であれば)、 イベント発生時のtimeStampを格納します。
@17@ そうではなく、すでにmaestampに0以外の値が格納されているのなら(←2回目以降のタッチ操作であれば)、発生時のtimeStampはatostampに格納。
@18@@19@ ここで、「タッチをし直したかどうか」を判定します。
前後のtimeStampにおいて0.1s以上のタイムラグがあるのであれば「一旦指を離した」とみなし、前回のタッチ位置の情報をリセットします。
@20@ 以上の判定が終わったのち、現在のtimeStampを「前回のもの」として登録し、次のイベントを待ちます。
@21@ ここからtouchmoveの移動量を算出していきます。
先づ、前回のタッチ位置が初期値が「0」なら、現在のタッチ位置をmaemoveに登録。これは初めてのタッチ操作であった場合と、@19@でリセットされた場合が当てはまります。
@22@ maemoveに0以外の値があれば。←これはユーザのtouchmoveがすでに開始されている状態ということです。その際には現在のタッチ位置はatomoveに登録。
@23@ maemoveとatomoveの差分をスクロール操作の合計である変数sumに加算。そのうえで回転関数を呼び出します。
@24@ 一連の処理が終わったのち、前回のタッチ位置を上書きしておきます。
扨(さて)いよいよ、要素をアニメーションさせるための関数です。
ここではユーザのスクロール操作が「アニメーションの範囲内であるとき」「それより下にスクロールさせたとき」「それより上にスクロールさせたとき」という、3つの条件分岐をおこないます。
function kaiten() { 「「3// スクロール操作の合計が範囲内なら 」」「「1..@25@」」 if(sum>0 && sum < 2000) { 「「3// スクロール操作の合計を回転角度に反映 」」「「1..@26@」」 bbfu.style.transform = `rotate(${360*sum/2000}deg)`; 「「3// スクロール禁止状態を保持 」」「「1..@27@」」 bbdocument.body.style.overflow = "hidden"; 「「3// アニメーション仕切ったあと、下へスクロール 」」「「1..@28@」」 } else if(sum >= 2000) { 「「3// 終了状態の固定とスクロール解除 」」「「1..@29@」」 bbsum = 2000; bbfu.style.transform = "rotate(360deg)"; bbdocument.body.style.overflow = "auto"; 「「3// アニメーション仕切ったあと、上へスクロール 」」「「1..@30@」」 } else if(sum <= 0) { 「「3// 開始状態の固定とスクロール解除 」」「「1..@31@」」 bbsum = 0; bbfu.style.transform = "rotate(0deg)"; bbdocument.body.style.overflow = "auto"; } }
@25@ はじめに、スクロール操作の合計sumがアニメーションの範囲内である場合。
@26@ sumの値に応じて、要素のプロパティ値を算出→反映させます。
今回はtransform:rotateで行っていますが、連続変化できるプロパティ値であれば⬇︎の方法で計算することができます。
算出したいプロパティ値をn、現在のsumをsum、sumの最大値を1000、プロパティの変化させたい範囲を0°〜360°としたとき。
sum : 1000 = n : 360
ゆえに、
n = 360 * sum /1000
@27@ アニメーション中なので、スクロール禁止状態を保持しましょう。
@28@ 順方向にスクロール操作をしていくと、sumはやがて2000を超え、アニメーションも完了します。そのときの処理です。
@29@ その場合変形が完了した状態をキープさせたいので、sumと要素のstyleをその状態に固定します。
ここでは見た目のrotateはもちろん、sumも終了時の値に固定させる必要があります。
例えば「sum =2200」の状態から、ユーザが逆方向にスクロールしたとき。「sum = 2200」をスタートとして@25@の回転角度が計算されると、0°←→360°の綺麗な1回転が行われず、中途半端な状態でアニメーションが開始/完了してしまいます。
加えて、スクロール禁止も解除します。
@30@ 3つ目。要素が表示された状態から、ユーザが逆方向にスクロールした場合も考えます。
@31@ 今度は@29@と逆に、アニメーション開始前の状態をキープ。
スクロール禁止については解除させます。
ついに完成⬆︎です。スクロールに合わせて要素が回転します。
お疲れ様でした!
前にもお話したように、CSSのプロパティ指定を変更すれば、いろんな変化が楽しめます。
拡大縮小で試してみましょう。
<div id = "area"> bb<img id = "fu" src = "fu.svg" style = "height:50%;「「1transform:scale(0)」」"> </div>
何もないところから始まりたいので、transform:scaleの規定値を「0」にしました。
function kaiten() { if(sum>0 && sum < 2000) { bbfu.style.transform = `「「1scale(${sum/2000})」」`; bbdocument.body.style.overflow = "hidden"; } else if(sum >= 2000) { bbsum = 2000; bbfu.style.transform = "「「1scale(1)」」"; bbdocument.body.style.overflow = "auto"; } else if(sum <= 0) { bbsum = 0; bbfu.style.transform = "「「1scale(0)」」"; bbdocument.body.style.overflow = "auto"; } }
kaiten関数のプロパティの部分だけを書き換えます。
スクロールに合わせて要素が徐々に大きくなってきます。
皆さんも、いろんなプロパティを仕込んでみてください。作っているうち、面白いアイデアが浮かぶかもしれません。
2つ以上のプロパティを変化させても面白そうですね。
今回は非常に長〜い記事となりました。最後までお付き合いくださり、ありがとうございます。
ではまた〜 ♫
JavaScriptのドルマーク$に中括弧{ }、テンプレートリテラルについて。
2022.02.09
クオテーション祭り、さようなら。
CSS+JavaScript、スクロールに合わせて要素をふわっと浮かばせる。
2022.03.01
複数や時間差にも対応。
JavaScriptの矢印「=>」〜これはアロー関数というものです。
2022.01.09
使い方とメリットについて解説。
swift、web、ガジェットなど。役立つ情報や観ていてたのしいページを書いていきたいと思います。