ページのスクロールを無効化する方法
最近、ダイアログを表示した際にページのスクロールを無効化する方法を探していて、 思ったより苦戦したのですが、良い方法を見つけたので、忘れないように備忘録としてメモしておきます。 誰かのお役に立てば幸いです。
スクロールイベントはキャンセルできない
まず最初にパッと思いついたのが、
onscroll イベントハンドラーの中で preventDefault() や return false を使用して、
スクロールイベントをキャンセルするという方法です。
$(document).on('scroll', function (e) {
e.preventDefault();
});
てっきりこれでできると思って楽観視していたのですが、 残念ながらこの方法ではスクロールを無効化することはできません。 どうやらスクロールイベントというのは通常の方法ではキャンセルできないようです。
イベントオブジェクトには cancelable という、そのイベントがキャンセル可能かどうか、イベントが発生しないように抑止することができるかを示すプロパティがあります。
わかりやすく言えば、preventDefault() や return false で、そのイベントがキャンセルできるかどうかがわかる項目です。
scroll イベントの cancelable は false になっていて、
preventDefault() や return false でキャンセルすることができないのです。
そのため、スクロールを無効化するためには、別の方法を使用する必要があります。
方法1:body タグに overflow-y:hidden を付ける
ググると大量に出てくる方法その1が body タグに overflow-y:hidden を付けるというものです。
overflow-y は要素の内容が収まらない場合の動作を設定する項目なので、
直観的で素直なアプローチと言えます。
しかし、この方法にはいくつか問題があります。
// スクロールを無効化する場合
$('body').css('overflow', 'hidden');
// スクロールを有効化する場合
$('body').css('overflow', '');
問題点1:iOS で効かない
なにより致命的な問題なのが iOS では効かないということです。 iOS でスクロールを無効化したい場合は、別の方法を使用する必要があります。
問題点2:スクロールバーが消える
ページの横幅はスクロールバーの有無によって変わってしまいます。
デフォルトではページの長さによってスクロールバーが表示されるかどうかが決まります。
そのため、スクロールバーが表示されない短いページとスクロールバーが表示される長いページが混在するようなサイトでは、
ページ遷移した際にスクロールバーの幅だけガタついてしまいます。
そこで、ページの長さにかかわらず全てのページでスクロールバーが表示されるように、
overflow-y:scroll を指定しておくことで、
ページ遷移時のガタつきを防止するというちょっとしたテクニックがあります。
しかし、この overflow-y:hidden によってスクロールを無効化する方法では、
overflow-y の値を変更することになってしまうため、
overflow-y:scroll でスクロールバーを常時表示にしていた場合でも、スクロールバーが消えてしまいます。
同じ項目の値を変更しているのですから当然と言えば当然ですね。
そのため、ページの幅が変わってしまい、デザインが崩れたり再描画が発生したりしてしまいます。
対策として padding-right:15px を付けるという方法が紹介されていたりしますが、
スクロールバーの幅というのはブラウザ事に微妙に違っていたりしますので、
根本的な解決になっておらず、逆にガタつく原因となってしまいます。
方法2:touchmove/wheel イベントの無効化
ググると大量に出てくる方法その2が touchmove イベントと wheel(mousewheel) イベントを無効化するというものです。
スクロールは止められないから、スクロールの前段階のタッチ操作やマウスホイールの操作を無効化しようというアプローチです。
なるほどそう来たかという感じですが、この方法にもいくつか問題があります。
function noscroll(e) {
e.preventDefault();
}
// スクロールを無効化する場合
document.addEventListener('touchmove', noscroll, {passive: false});
document.addEventListener('wheel', noscroll, {passive: false});
// スクロールを有効化する場合
document.removeEventListener('touchmove', noscroll);
document.removeEventListener('wheel', noscroll);
問題点1:マウスやキーボードで直接操作できる
これはスクロールの前段階のイベントを無効化している方法なので、 スクロール自体が無効化されているわけではありません。 そのため、マウスでスクロールバーを直接動かせばスクロールできてしまいますし、 キーボードの矢印キーでもスクロールできてしまいます。
問題点2:全ての要素のスクロールが効かなくなる
これはページ全体のスクロールだけではなく、そのページに含まれる要素の全てでスクロールが効かなくなる方法です。 しかしながら、ページに含まれる要素の中にはスクロールが必要な要素というのも存在します。 わかりやすい例としては、テキストエリアです。 この方法を使用すると、テキストエリアの中身をスクロールすることができなくなります。
問題点3:ピンチイン/ピンチアウト(拡大縮小)が効かなくなる
touchmove イベントは、2本指でのピンチイン/ピンチアウト(拡大縮小)する際にも使用されているイベントなので、
これを無効化してしまうと、ページの拡大縮小ができなくなります。
iOS では、テキストボックスやテキストエリアなどにフォーカスが当たった際に、 フォントサイズが 16px より小さいと自動的にページが拡大されるという仕様になっています。 そのため、それらにフォーカスをした瞬間に自動でページが拡大されますが、 拡大された状態から元に戻せなくなります。
方法3:body タグに position:fixed を付ける
困ったときは海外サイトということで、いろいろググって見つけた方法が、
body タグに position:fixed を付けるという方法です。
スクロールが止められないのなら、ページの表示の方を止めてやろうというアプローチです。
ページの表示が固定化されているので、結果的にスクロールが無効化されるということですね。
$('body').css({
'position': 'fixed',
'top': '-' + $(window).scrollTop() + 'px',
});
const $body = $('body');
const scrollY = $body.offset().top;
$body.css({
'position': '',
'top': '',
});
$(window).scrollTop(-scrollY);
position:fixed を使用すると、スクロール位置が 0 になってしまいますが、
その分を top でずらしているので、
結果的にスクロールしていた位置でページの表示が固定化されるというわけです。
スクロールを有効化する際には、逆に top からスクロール位置を取り出して、元のスクロール座標に戻しています。
一見すると複雑そうに見えるかもしれませんが、スクロールを無効化した時の逆の操作をやっているだけです。
overflow-y を変更しないため、
overflow-y:scroll でスクロールバーを常時表示にしていた場合でも、スクロールバーを表示した状態のままにしておくことができます。
また、ページの表示が固定化されているため、マウスやキーボードでもページのスクロールはしなくなります。
しかしながら、スクロールが無効化されるのは body タグだけなので、テキストエリアの中身などのスクロールは効きますし、
ピンチイン/ピンチアウトによるページの拡大縮小も効きます。
つまり、これまでの方法の問題点がほぼ解消されています。
しかも、特に難しいことをしているわけではなく、昔からある position:fixed を応用している方法のため、
ブラウザを選ばずに動作します。
かなりスマートで良い方法だと思いますが、問題点が無いわけではありません。
問題点1:iOS のアドレスバーのサイズが変わってしまう
iOS はページを下にスクロールすると上部のアドレスバーのサイズが小さくなるという仕様になっていますが、 この方法を使用すると内部的にスクロール位置が 0 に戻ってしまうため、 全く下にスクロールしていない状態(つまり、ページの一番上が表示されている状態)と認識されてしまい、 アドレスバーが元の大きいサイズに戻ってしまいます。 そのため iOS だと、スクロールを無効化した際に、そのスクロールバーの大きさだけ縦方向にガタつきます。
これを解決する方法は不明です。 というかこの方法の仕組み上、解決するのは不可能に近く、副作用と言うしか無いような気がします。 とはいえ、他の方法を使用したときに発生する問題に比べれば小さな問題なので、許容範囲内なのではないでしょうか。
問題点2:body タグにマージンが入ってるとずれる
上記のソースコードを見てもらうとわかると思うのですが、
スクロール位置の計算式に body タグのマージンに関する計算式は含まれていません。
つまり、body タグのマージンが 0 であることが前提となっています。
もし body タグにマージンが入っていると、スクロール無効化の際にその分スクロール位置がずれます。
もちろん、スクロール位置の計算式にマージンの計算式を入れてもいいのですが、
基本的に body タグのマージンは邪魔なだけだと思いますので、
マージンの方を 0 にした方が良いと思います。
body {
margin: 0;
}
問題点3:スクロール無効化時に中央揃えが効かなくなる
この方法は、スクロール無効化の際に position:fixed を使用しているため、
margin:0 auto などの指定で中央揃えをしている要素があると、
その auto の部分が 0 になってしまい、
中央揃えが効かなくなって、デザインが崩れることがあります。
また、display:flex と justify-content:center などで中央揃えしている場合でも同様です。
原因は、親の要素に大きさ(幅)の指定が入っていないことです。
余白が無いため中央揃えができないという状態になってしまいます。
簡単な解決方法は、body タグに width:100% を指定することです。
その際、body タグのマージンが邪魔になるので、やはりマージンは 0 にしておいた方が良いと思います。
body {
margin: 0;
width: 100%;
}
問題点4:スクロール有効化時にスムーズスクロールが効いてしまう
この方法は、スクロール有効化の際にスクロールの座標を元に戻すという操作を行う必要があるため、
scroll-behavior:smooth でスムーズスクロールを有効にしていると、その際にスムーズスクロールが効いてしまいます。
html {
scroll-behavior: smooth;
}
解決方法は、スクロールを有効化する前に、一旦スムーズスクロールを無効化してから、再度有効化すれば良いだけです。
const $html = $('html'); const $body = $('body'); const scrollY = $body.offset().top; $html.css('scroll-behavior', 'auto'); $body.css({ 'position': '', 'top': '', }); $(window).scrollTop(-scrollY); $html.css('scroll-behavior', 'smooth');
2025/01/08 追記:Vanilla JS で記述する場合
Vanilla JS での記述方法も記載しておきます。参考にしてみてください。
const body = document.querySelector('body');
body.style.top = -window.scrollY + 'px';
body.style.position = 'fixed';
const body = document.querySelector('body');
const scrollY = body.getBoundingClientRect().y;
body.style.position = '';
body.style.top = '';
document.scrollingElement.scrollTo({ top: -scrollY, behavior: 'instant' });