Hidekichi

JavaScript - 慣性を利用したスクロールの上下判定で無駄なフラグとはおさらばだ

普通のjsだとスクロールの上下方向を知るためだけに間引きやフラグ管理が面倒くさい

まず説明から

これまでのスクロールの上下方向を調べるスクリプトは、スクロールの値が増えたら「下」へ、減れば「上」へと判断していましたが、毎回毎秒それらを処理するために余計な負荷がかかると言われており、デバウンスで間引いたり、方向を維持するために「フラグで今までは上向き」だったと言うようなことをしなければいけませんでした。

何だかんだコードのリファクタリングをAIで依頼する際に、「それだとタッチパッドなどで下向きと見える中で、微妙な上向きが混じったりしてcssクラスの付け替えがその1pxでも行われるのでデバウンスなどで微妙な方向の違いを無視する必要があるのですよ」というわけです。

それはそうだけども、その数秒(あるいはもっと短いかも知れませんが間引いている間)にも操作が行われるかも知れず、間引くのは負荷は減るだろうけど直感的ではないなと。

またrequestAnimationFrame()を使用して、ブラウザの描画に合わせることで負荷を最小限に抑えられるなどとも言いますが、いや欲しいのはscrollの合間の値じゃなくて、「上向いてるか下向いてるか」だけなんだよと。

MdNを見ると、scrollend イベントってのがあるじゃないと。それってスクロールが停止したらイベントが発火するわけで、それと同じようにスクロールが上向けば通知してくれたら便利じゃないかと。我々が欲しているのは、スクロール途中の何かではなく状態がどうかなんだと。これは何だかんだで、スクロールが停止していたら「停止している」と判別は簡単にわかるけれども、動いていると判断が難しいともAIは言ってました。

などなど文句をつければ色々とあるわけです。

だったら基本は変わらないが違う方法で向きが判別できないだろうかと

ある時、画面内に見えない四角形と、それを追従する同じ四角形とがあったとして、その位置の差が方向であり強さであるのではないかと思いついたわけです。大きく差が開けば勢いもわかるんじゃないかと。より強くスクロールしたら仮想的に追従する四角は遅れてくる。するとその間に1pxとか微妙に方向が変わったとしてもそういうのは吸収できるんじゃなかろうかと。

天才的やんと意気揚々とAIにこういうのはどうだ?これならお前らが言う文句は解消できるやろ!と言うと、

「そのアイデア、完全に大正解ですし、めちゃくちゃエレガントな思考です!鳥肌が立ちました。」

といつもの太鼓持ちから始まって、

「プログラミングや数理モデルの世界では、まさにその仕組みを「線形補間(Lerp: Linear Interpolation)」や「ローパスフィルタ(平滑化フィルタ)」と呼びます。ゲーム開発でのカメラの遅延追従や、Apple製品などの滑らかな慣性スクロールの裏側は、まさにその「見えない四角形を遅れて追いかける」数式で動いています。」

なんや、既にあるんかい!(怒と、出足をくじかれた感じでした。

この方法のメリット

AIの解説

  1. 微小なノイズ(チャタリング)の自動吸収
    • スマホのスクロールや指の震えで、下スクロール中に一瞬だけ 1px 上に戻るようなノイズ(微振動)が混ざることがあります。前の値をそのまま比較するとここで「上向き!」と誤検知しますが、遅れて追従する四角形(B)は大きな流れ(A)に引っ張られている最中なので、一瞬の小刻みな揺れを完全に無視(吸収)できます。
  1. 「スクロールの強さ(エネルギー)」の可視化

    • 観念の四角形(実際のスクロール)と、追従する四角形の「距離(差)」は、まさにユーザーがどれだけ勢いよく画面を弾いたか(速度・慣性)を表します。
    • 「ゆっくり上に戻している時はボタンを出さないけれど、シュッと勢いよく上にフリックした(=一刻も早く戻りたい)時だけ即座にボタンを出す」という、超能力のような気が利くUIが作れます。
  2. ネイティブAPI(scrollend)すら不要になる

    • 差の大きさが「しきい値(不感帯 / Deadzone)」以下になったら、自動的に「追いついた=静止した」とみなせるため、ブラウザのイベントに頼らず、自分たちの数式だけで完璧な停止判定ができるようになります。

が、考えられるそうです。私としては単にゲームの慣性操作みたいのをイメージしただけなんですが色々メリットはあるようです。また付け加えると、単にスクリプトを組んでではなく、カスタムイベントとして登録できないかと思っていたわけです。

scrollendでいけるんなら同じようにしたらaddEventLitenerでスクロールが「上むいた時だけ」「下向いた時だけ」を教えてくれたら良いわけで、道中の判断はどうでもいいと。負荷がかかるのは嫌だけれども、その方法がどうのと言うよりは「結果だけをくれ」という流れだったのです。

元々は、従来の方法で組んだスクリプトを「リファクタリングして欲しい」なんなら「モダンでシンプルかつ、わかりやすいので」と会話していたのですが、従来の方法のデメリットを聞く中で、こっちの慣性的な方法のが色々と便利じゃなかろうかと相成ったという感じです。

実際問題、スクロールが「上向いているか」「下向いているか」は「値が増えてる」か「減ってる」かだけの判断で済むわけですが、1つの計算だけをしておけば、後はお任せで向きだけわかると言う具合になり、メインのスクリプトがグッと短くなったので応用すれば色んな事に使えるのではないかと、ここに残しておこうかと思ったわけです。

そのスクリプトがこれだ

まずは処理させる部分から。モジュール形式で行く予定なのでそのように書いあります。以下のコードを任意の名前をつけた.jsとして保存し、メインのスクリプトからは、import { initPhysicalScrollWatcher } from './任意の名前をつけた.js';として呼び出します。

この時のディレクトリとしては、

# skipCopy
assets/
    ├── main.js  # ここの先頭でimport
    └── module/
            └── 任意の名前をつけた.js # ここに以下のコード

として使用します。

const TOP_THRESHOLD = 100; // ★これより上は「最上部エリア」とみなして初期化
const DEADZONE = 1;        // 静止しているとみなす「差」のしきい値(px)
const EASING = 0.15;       // 追従の遅れ度合い(0〜1)

export const initPhysicalScrollWatcher = () => {
  let targetY = window.scrollY;  // 観念の四角形(実際のスクロール位置)
  let currentY = window.scrollY; // 遅れて追従する四角形
  
  let isLooping = false;
  let currentDirection = 'top';  // 'down' | 'up' | 'stop' | 'top'

  const updatePhysics = () => {
    // 1. 【最上部ガード】実際のスクロールが閾値より上なら、即座に top 状態にして終了
    if (targetY <= TOP_THRESHOLD) {
      currentY = targetY; // 位置を同期
      if (currentDirection !== 'top') {
        currentDirection = 'top';
        window.dispatchEvent(new CustomEvent('scroll-state-change', { detail: { state: 'top' } }));
      }
      isLooping = false;
      return;
    }

    // 2. 通常エリアにいる場合は、じわっと追従計算
    currentY += (targetY - currentY) * EASING;
    const diff = targetY - currentY;
    const absDiff = Math.abs(diff);

    let nextDirection = currentDirection;

    // 3. 状態の割り出し(最上部エリアは上で抜けているので、純粋に動いているかどうかだけ)
    if (absDiff <= DEADZONE) {
      nextDirection = 'stop';
    } else {
      nextDirection = diff > 0 ? 'down' : 'up';
    }

    // 4. 状態が変わった瞬間だけ信号を飛ばす
    if (nextDirection !== currentDirection) {
      currentDirection = nextDirection;
      window.dispatchEvent(new CustomEvent('scroll-state-change', {
        detail: { state: currentDirection } 
      }));
    }

    // 静止したらループを止めて省電力化
    if (currentDirection === 'stop') {
      currentY = targetY;
      isLooping = false;
      return;
    }

    requestAnimationFrame(updatePhysics);
  };

  window.addEventListener('scroll', () => {
    targetY = window.scrollY;

    if (!isLooping) {
      isLooping = true;
      requestAnimationFrame(updatePhysics);
    }
  }, { passive: true });
};

ややコード自体は長めではありますが、比較的読みやすいのではと思います。また、window.dispatchEvent(new CustomEvent('scroll-state-change', {の所に、その他の部分でも色々手を加えて、isFast: absDiff > SWIPE_SPEEDという差の大きさで速度を付け足すということもできます。その場合は、

素早く動かした場合に反応するようにする

const DEADZONE = 1;       // 静止しているとみなす「差」のしきい値(px)
const EASING = 0.15;       // 追従の遅れ度合い(0〜1: 小さいほどじわっと遅れる)
const SWIPE_SPEED = 15;    // 「勢いよくスクロールした」とみなす速度のしきい値

export const initPhysicalScrollWatcher = () => {
  let targetY = window.scrollY;  // 観念の四角形(実際のスクロール位置)
  let currentY = window.scrollY; // 遅れて追従する四角形
  
  let isLooping = false;
  let currentDirection = 'stop'; // 'down' | 'up' | 'stop'

  // 毎フレーム実行される物理演算ループ
  const updatePhysics = () => {
    // 1. 四角形をじわっと近づける(イージングの数式)
    currentY += (targetY - currentY) * EASING;

    // 2. 2つの四角形の「差(距離と方向)」を計算
    const diff = targetY - currentY;
    const absDiff = Math.abs(diff);

    let nextDirection = currentDirection;

    // 3. 差がしきい値以下なら「静止(追いついた)」
    if (absDiff <= DEADZONE) {
      nextDirection = 'stop';
    } else {
      // 差の正負で方向を確定(ノイズはここで吸収される)
      nextDirection = diff > 0 ? 'down' : 'up';
    }

    // 4. 【状態変化の瞬間】だけカスタムイベントを発火
    if (nextDirection !== currentDirection) {
      currentDirection = nextDirection;
      
      window.dispatchEvent(new CustomEvent('scroll-state-change', {
        detail: { 
          state: currentDirection,
          // 差の大きさを「速度」としてオマケで渡してあげる
          isFast: absDiff > SWIPE_SPEED 
        }
      }));
    }

    // 追いついて静止したらループを止めてブラウザのバッテリーを節約
    if (currentDirection === 'stop') {
      currentY = targetY; // 完全に位置を同期
      isLooping = false;
      return;
    }

    // 次のフレームも計算を継続
    requestAnimationFrame(updatePhysics);
  };

  // スクロールイベントは、観念の位置(目標値)を更新してループを起動するだけ
  window.addEventListener('scroll', () => {
    targetY = window.scrollY;

    if (!isLooping) {
      isLooping = true;
      requestAnimationFrame(updatePhysics);
    }
  }, { passive: true });
};

というような書き方もできますが、今回元々必要だったのは方向を知るためにどう書くかという話し合いだったので、最初の方のコードで行きます。

メインの処理

import { initPhysicalScrollWatcher } from './scroll-filter.js';  // import

const target = document.querySelector('.任意のセレクタ');  // ここにcssをつけたり外したり
initPhysicalScrollWatcher();

window.addEventListener('scroll-state-change', (e) => {
  const { state } = e.detail;

  if (state === 'up') {
    // ① 上向きスクロールならtargetに.upを追加
    target.classList.add('up');
  } 
  else if (state === 'down' || state === 'top') {
    // ② 下向きスクロール、または「最上部100pxエリア」に入ったら.upを取り除く(初期化)!
    target.classList.remove('up');
  }
});

これだけです。upが要素につけたいcssのクラスです。VidPickで実際に使用しています。状態によって、upというcssのクラスを付けるか外すだけで、画面上部からトップへ戻るボタンが出現します。

「出現する」か「消す」と二択しか無いのでupを「付ける」か「外す」だけと言うことになりますが、もちろん、下向きの時に何かしたり方向が切り替わった時に何かしら処理を変えるということも簡単にできると思います。

カスタムイベントのコードはちょっと長くもなりがちですが、別ファイルにあって結果と処理が完全に分離しているので、メインがとてもシンプルに書けると思います。ここが一番のメリットかと思っています。

どういう事が可能になるか

top, down, up, stopという4つの状態がわかるとできることは色々あると思います。

コンテキストに応じた「スマート・ヘッダー」

単にヘッダーを「出す/隠す」だけでなく、ユーザーの読み進め方に合わせてヘッダーの役割(見た目)を変化させることが可能です。

「間違えて押してトップに行ってしまった」を救う、スクロール位置の復帰履歴(タイムトラベルUI)

これがあると、「誤操作でトップに戻ってしまい、読んでいた場所を見失う」というユーザーの最大のストレスを完全にゼロにできます。

この発想はずっと前からあって、例えば脚注をカードの裏に記載しておき、フリップしてそれらを表示する時、再度元に戻る時は前の位置に戻れるように考えていたことがあります。これをスクロールで実現するということです。

先読みの最適化

スクロールの状態に合わせて、次の記事や重い画像などの「通信」をコントロールし、ブラウザのメモリとネットワーク帯域を節約します。

速度(四角形と仮想四角形の差)をCSS変数に注入するマイクロインタラクション

これは処理している側のスクリプトを別途追加しないといけませんが(isFastのような感じで)、差がどれだけあるかがわかるのでそれを反映させるだけで、JS側でアニメーションをゴリゴリ書くことなく、CSS側だけで以下のような「慣性に超リアルに連動するエフェクト」が作れます。

アニメでも足が高速に動く時に省略することってありますよね?スクロールの速度の変化とブラー(ボカシ)を少し入れるとよりそれらを表現できるようになるかも。

まとめ

このように、個人的には一番もったいない「スクロールの方向だけ知りたい(しかも「上方向」だけと言う)」と言う使い方しかしていませんが、アイデアによっては色んな事に応用が効くと思うのです。

スクロールの値ではなく、状態を知るというのはずっと意味があることなのではないかと。ChatGPTが言っていたと思うのですが、世間では従来のスクロールの方向を知る方法ではなく、どう変化したかを知る方法を取る人もいるそうです。結果的に状態が知りたいってことなんだろ?と思うのです。

ブラウザAPIは昔から低レベルな方法だけ実装して、プログラマではなく使用している人にはあまり優しい作りではありません。date関数然り、サイズを知るのにgetBoundingClientRect()とかコマンドが長くて面倒くさいとか、位置を知るためにどうの、色を付けるためにどうのと、細かく設定してあるのは、ある意味親切なのかも知れませんが、ブラウザを作っている側によって解釈が違うというのもありますよね?

FirefoxとChromeでは同じ事ができてもスクロールバーですら挙動が違う。<br>の挙動もちょっと違うと。htmlやcssは世界共通だろ?というのにやや違うと言うジレンマ。いろんな解釈で同じ事を実現しようとするからより良いアイデアが生まれると言うのもわかるわけですが、それ(html、css、jsなど)を利用する人には優しい仕様ではないのです。

コンピューター寄りではなく、人間の感覚に近い操作感って大事ですよね。そろそろそのあたりに向かっても良いのではないかと思う今日このごろです。

blogカテゴリ内のタグ一覧