ドラッグ中のスクロールをJSで止めようとしてAndroidだけ効かなかった話

Webアプリでドラッグ操作中にブラウザのスクロールを止めて上下に近づけたら少しずつスクロールしたい、というよくある要件でハマった話。
PCでは完璧に動いてたのに、Androidで試したら全然効かなかった。「同じChromeやのになんで?」って調べていたら、ブラウザのスクロール処理モデルが根本から違うことに気づいたので共有します。

TL;DR

  • JSでscrollTopをリセットする方法はPCでは有効、Androidでは無効
  • 理由:Androidのタッチスクロールはコンポジタースレッドが処理するため、JSから割り込めない
  • overflow: hiddenはCSSレベルでの制御なのでAndroidでも有効
  • ただしPCでoverflow: hiddenにするとスクロールバー消失(約13px)でレイアウトがズレる
  • 解決策:PC = scrollTopリセット方式、Android = overflow:hidden方式のハイブリッド

何が起きていたか

FullCalendarを使ったスケジュール編集UIで、イベントのドラッグ中に外部スクロールコンテナが勝手にスクロールされる問題が発生しました。要素を動かすと、それにつられて画面全体がスクロールしてしまう。ドラッグ中はスクロールをロックして、画面端に近づいたときだけ自動スクロールさせたい、という要件です。

PCではすでに動いていた実装がありました。scrollTopの値を記録しておき、scrollイベントが発火するたびに強制的にその位置に戻す方式です。

const lockedScrollTopRef = useRef<number | null>(null);

const startDrag = () => {
  lockedScrollTopRef.current = container.scrollTop; // 現在位置を記録

  container.addEventListener('scroll', () => {
    if (lockedScrollTopRef.current !== null) {
      container.scrollTop = lockedScrollTopRef.current; // 強制リセット
    }
  });
};

Androidで試したところ、scrollTopがドラッグ中に639→436のように変化し続けていました。リセットが全く効いていません。

PCとAndroidでスクロールの処理モデルが違う

PCのスクロール:メインスレッドで処理される

マウスホイールによるスクロールは、ブラウザのメインスレッド(JSが動いているスレッド)で処理されます。

マウスホイール回転
  ↓
メインスレッド: scroll イベント発火
  └── handler: container.scrollTop = locked ← リセット成功
  ↓
レイアウト・ペイント
  ↓
コンポジタースレッド: リセット後の状態で描画

scrollイベントのハンドラでscrollTopを書き換えると、その値がそのままコンポジタースレッドに渡されます。なので強制リセットが効きます。

Androidのスクロール:コンポジタースレッドが独立処理

タッチスクロールはブラウザのコンポジタースレッドが処理します。コンポジタースレッドはメインスレッドと独立して動作し、あらかじめペイントされたレイヤーをGPUで高速に合成・移動します。これによりJSが重くても滑らかなスクロールを実現しています。

touchstart(passive: true がデフォルト)
  ↓
コンポジタースレッドがスクロール制御を取得 ←★ここで分岐
  ↓
指が移動(touchmove)
  ├── コンポジタースレッド: スクロール即時更新 → GPU描画(画面に即反映)
  │     ↑ メインスレッドを経由しない
  │
  └── 非同期でメインスレッドに通知
        └── scroll イベント発火 → scrollTop = locked を試行
              ↑ この時点ですでに画面は動いている
                リセットしても次フレームでコンポジタースレッドが上書き

touchstartpreventDefault()を呼べば防げますが、Chrome 56以降、document/windowレベルのtouchstartはデフォルトでpassive: trueになっています。これはスクロールパフォーマンスを優先するブラウザの設計判断です。

// デフォルト(passive: true)では無視される
document.addEventListener('touchstart', (e) => {
  e.preventDefault(); // Intervention警告が出て無視される
});

実際にこの警告がコンソールに大量に出ていました。(ちなみにデバッグ方法はこちらの記事で解説してます)

[Intervention] Unable to preventDefault inside passive event listener
due to target being treated as passive.

つまりJSレベルでの介入では止められない。CSSレベルでの制御が必要。

overflow:hiddenはCSSレベルなので効く

overflow: hiddenはCSSのプロパティとしてブラウザ全体(コンポジタースレッド含む)が認識します。JSの後追いリセットではなく、そもそもスクロールを発生させないためAndroidでも確実に効きます。

const startDrag = () => {
  container.style.overflow = 'hidden'; // CSSレベルで制御
};

const stopDrag = () => {
  container.style.overflow = 'auto'; // 復元
};

でもPCではoverflow:hiddenが別の問題を起こす

Androidではスクロールバーがオーバーレイ表示(幅0px)なのでoverflow: hiddenにしてもレイアウトは変わりません。

PCはスクロールバーが約13px分のレイアウト領域を占有しています。overflow: hiddenにすると:

BEFORE: clientWidth=1723, scrollbar=13px, offsetWidth=1736
AFTER:  clientWidth=1736(スクロールバー消失でコンテンツ幅が13px拡大)

この13pxのレイアウトシフトが、FullCalendar内のドラッグ中のマウス座標計算を狂わせてカーソルとドラッグ要素の位置がズレていきます。

つまりoverflow: hiddenをPCで使うことはできない。

方式 PC Android
scrollTopリセット OK NG(コンポジタースレッドが上書き)
overflow:hidden NG(スクロールバー消失でレイアウトシフト) OK(スクロールバー幅0でシフトなし)

ハイブリッド解決策:デバイスで方式を切り替える

デバイス種別を判定して制御方法を切り替えます。PointerEventpointerTypeを使うと、マウス操作かタッチ操作かを判定できます。

const startDrag = (isTouch: boolean) => {
  if (isTouch) {
    // Android: CSSレベルで止める
    container.style.overflow = 'hidden';
  } else {
    // PC: scrollイベントでリセット
    lockedScrollTopRef.current = container.scrollTop;
    container.addEventListener('scroll', lockHandler);
  }
};

呼び出し側でpointerTypeを渡します。

// マウス操作はpointerdownで即座に検知
container.addEventListener('pointerdown', (e) => {
  if (e.pointerType === 'touch') return; // タッチは別処理
  startDrag(false); // PC
});

// FCのドラッグコールバック(PC/Android共通)
<FullCalendar
  eventDragStart={() => startDrag(true)}  // Androidはここから
  eventDragStop={() => stopDrag()}
/>

おまけ:Androidのタッチ選択は別の問題もあった

FullCalendarでの既存イベントドラッグはeventDragStartコールバックで検知できます。ただし新規イベントの範囲選択(空きスロットを長押し→ドラッグ)は、selectコールバックが選択完了時にしか発火しないため、選択開始を検知できません。

MutationObserverでFullCalendarが選択中に追加する.fc-highlight要素を監視する方法で解決しました。

const observer = new MutationObserver((mutations) => {
  const hasAdded = mutations.some(m =>
    [...m.addedNodes].some(n => (n as Element).classList?.contains('fc-highlight'))
  );
  const hasRemoved = mutations.some(m =>
    [...m.removedNodes].some(n => (n as Element).classList?.contains('fc-highlight'))
  );

  if (hasAdded && !isSelectionHighlightActiveRef.current) {
    // 新規選択開始
    isSelectionHighlightActiveRef.current = true;
    startDrag(true);
  }
  if (hasRemoved && !hasAdded) {
    // ハイライト完全消去(選択解除)
    isSelectionHighlightActiveRef.current = false;
  }
});

isSelectionHighlightActiveRefフラグが重要です。FullCalendarは選択範囲変更時にハイライト要素を削除→追加(入れ替え)します。またReactの再レンダリングでも同じことが起きます。フラグなしだと「新規選択開始」と「入れ替え」を区別できずにstartDragが何度も呼ばれます。

  • 追加あり + isSelectionHighlightActiveRef=false → 新規選択 → startDrag(true)
  • 追加あり + isSelectionHighlightActiveRef=true → 入れ替え → 無視
  • 削除のみ → 選択解除 → フラグリセット

まとめ

「同じChrome」でもスクロールの処理モデルがPCとAndroidで根本的に違う。コンポジタースレッドの存在を知ってからは「なんでJSで止められへんねん!」という謎がスッキリ解けた。

PC・Android両対応が必要なWebアプリを作るときは、最初からこの差異を意識して設計するのが大事やなぁと思った。