Webアプリでドラッグ操作中にブラウザのスクロールを止めて上下に近づけたら少しずつスクロールしたい、というよくある要件でハマった話。
PCでは完璧に動いてたのに、Androidで試したら全然効かなかった。「同じChromeやのになんで?」って調べていたら、ブラウザのスクロール処理モデルが根本から違うことに気づいたので共有します。
- TL;DR
- 何が起きていたか
- PCとAndroidでスクロールの処理モデルが違う
- overflow:hiddenはCSSレベルなので効く
- でもPCではoverflow:hiddenが別の問題を起こす
- ハイブリッド解決策:デバイスで方式を切り替える
- おまけ:Androidのタッチ選択は別の問題もあった
- まとめ
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 を試行
↑ この時点ですでに画面は動いている
リセットしても次フレームでコンポジタースレッドが上書き
touchstartでpreventDefault()を呼べば防げますが、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でシフトなし) |
ハイブリッド解決策:デバイスで方式を切り替える
デバイス種別を判定して制御方法を切り替えます。PointerEventのpointerTypeを使うと、マウス操作かタッチ操作かを判定できます。
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アプリを作るときは、最初からこの差異を意識して設計するのが大事やなぁと思った。