2022年10月15日追記
いつからかわからないが、このバグはすでに解消されてるッ(iOS15.6.1 Safariで確認済み)!!この記事は結構な時間と検証を繰り返して書いたけど、また一つ過去になった。
iPhoneやiPadで position: fixed; で絶対配置したエリア内をスクロールするとき、最上部をより上にスクロールしようとしたり、最下部をより下にスクロールしようとすると、ビヨーンとバウンスされずにロックされ、数秒固まる。このフリーズ状態はもはやiOSのバグとしか言いようがない。
はじめて気がついたのが2010年代半ばで、かれこれ数年の月日が流れている。さすがに、iOS側で対応してると思いきや未だ解決されていないことに気がついた…。そこで改めて最適な解決方法が生まれているのではとググる。同様に指摘された記事は見つかるが、残念ながら真新しいものはないどころか、それらの方法では解決できない…。
現在、自作したJavaScriptのいろいろなTIPSを整理しつつ、必要なTIPSはES6に書き直している。その中の一つが、本記事についての自作スクリプト。ということで、実例サンプルと合わせてES6に書き直した回避方法を共有させていただきます。
結論:フリーズしません!上部も下部もビヨーンと惰性スクロール
まずは、iOS Safariの実機でご覧ください。
何も処理していないページ(何が問題かを実際に確認できる)。
フリーズするバージョン
この問題をjQueryを使って解決したページ。
フリーズ回避 jQuery編
この問題を何のフレームワークやライブラリも使わず、JavaScript(DOM)を使って解決したページ。
フリーズ回避 JavaScript(DOM)編
ちなみに「position: fixed; とoverflow: auto; は同じ要素(タグ)に指定しない」や「-webkit-overflow-scrolling の値をtouchではなく auto にする」 と書かれた記事が多数あるが、これらでは解決しない。
position: fixed; overflow: auto; を同じ要素(タグ)に指定してもスクロールします
「position: fixed; と overflow: auto; を同じ要素(タグ)に指定するとスクロールしない」という記事を見かけたが、これをご覧いただければ分かるとおり、2021年現在、position: fixed; overflow: auto; を同じ要素(タグ)に指定してもスクロールする。
-webkit-overflow-scrolling は全く関係ありません
「-webkit-overflow-scrolling: auto; にすると回避する場合がある」という記事も見かけた。惰性スクロールを無効にすることはUX視点では概念モデルと異なるので根本解決ではない。そもそも、2021年現在、-webkit-overflow-scrolling の指定自体が不要と判断している。実際、プログレッシブ・エンハンスメントな思考なので、2019年後半頃から指定するのはやめた。
そもそも、なぜ「-webkit-overflow-scrolling: touch;」を指定するようになったのか。
それをお話しするために、時をAndroid端末が市場に出始めた頃に戻そう。当時のAndroidのUXはそれはお世辞にも良いとは言えず、その代表格が画面スクロール時のカクカク。それから次々とアップデート版でUXが改善されていく中で「-webkit-overflow-scrolling: touch;」を使うとカクカクしなくなる、という情報が出た。また、iOS Safari でも、ページ内スクロールを実装しようとしたときのみ、そのエリア内のスクロール時はカクカクする現象があり「-webkit-overflow-scrolling: touch;」を使うことで回避できた。
だがしかし、それはもはや過去の話し。今どきの環境であれば、そもそも何の設定をしなくても、カクカク動くことはない。
と話しがズレました。本題に戻ります。
まず、どのような条件でフリーズするのかを理解する
どのような条件でフリーズするのか。フリーズするには2つのケースがある。
- ページ内画面を表示後すぐに、そのエリアを上スクロールしようとしたとき
- ページ内スクロールで下部までスクロールしてビヨーンという惰性が終わったあとに、さらに下にスクロールしようしたとき(上部のビヨーンが終わったあとに、さらに上にスクロールしようしたときも同様)
注目したいのは、どのタイミングでフリーズするのか。
実機でいろいろ試した結果、ビヨーンと惰性している間ではなく、「ビヨーンが終わった後、スクロールが止まった時点」であることが分かった。
フリーズする条件が明確になったことで、逆にフリーズしない条件がわかる。
フリーズしない条件を整理して、実装イメージをつくる
それでは、どのような条件ならフリーズしないのか、言語化してみる。
「ページ内スクロールが止まった時点」で「トップや最下部から1pxでも移動していれば」、フリーズしない。
というわけで、実装イメージが出来上がる。
- ページ内スクロールを監視する
- ページ内スクロールが止まったとき、スクロール位置を取得する
- スクロール位置が最上部の場合は、1px下に移動する
- スクロール位置が最下部の場合は、1px上に移動する
このように、最上部や最下部の状態が発生したら、上部 + 1px、下部 – 1px、と調整することでフリーズしなくなる。
注意1)4. のスクロール位置が下部の場合は、1px上に移動するということ。つまり1px移動できる伸び代を用意する必要がある
注意2)スクロール領域より、その中のコンテンツ領域が短い場合に配慮をする(その場合でもビヨーンという惰性スクロールは発生させる)
補足)スクロール位置を判別して処理する間にスクロールときだけフリーズしてしまうことになるが、通常利用でフリーズすることは、限りなくない。
というわけで、これらの条件を満たすようにコーディングすれば完成だ。
サンプルソース
ソースコードはGitHubで公開中。
GitHub: https://github.com/smplsmplsdsn/ios-safari-bugix-smooth-scroll
HTMLとCSSのポイントは2つ。
- スクロール領域に子要素を用意してその中に、コンテンツを入れる
- スクロール領域の子要素には、高さにスクロール領域 +1px を指定する
HTML
<div class="scroll">
<div class="scroll-inner">
(スクロールするエリア。ここに好きなだけマークアップする)
</div>
</div>
CSS
スクロール領域(.scroll)の幅と高さ(width, height)や固定エリアの表示位置(top, left)の値は適当。デザインに合わせて調整してください。ちなみに、スクロール領域の単位がpxでない場合、スクロール領域内の高さは、calcを使って指定する。
例:height: calc(100% + 1px);
.scroll {
position: fixed;
top: 20px;
left: 20px;
z-index: 1;
width: calc(100% - 40px);
height: 400px;
overflow: auto;
width: 100%;
height: 400px;
}
.scroll-inner {
min-height: 401px; // 親要素の高さ + 1px
}
最後にJavaScript。JavaScriptは、jQuery版とDOM版の2通りを紹介するので、どちらかお好きな方で。
個人的には、圧倒的にjQuery版をオススメする(不要な判別処理の必要がなく、コードをシンプルに保持でき、その箇所においての各ブラウザや各デバイスの検証を省けるため)。
ただし、フレームワークに依存してJavaScriptを記述している場合は、DOM版をベースに、そのフレームワークの記述ルールに従って書き直すことで、メンテナブルに実装できます(その場合は、GitHubにフレームワーク名をファイル名にした新規JSファルをプルリクもらえると、めっちゃ嬉しいです。GitHubの該当リンクはこちら)。
JavaScript(jQueryを利用している場合)
/**
* 【Bugfix】iOS Safari で惰性スクロールがロックされてしまうのを回避する
* https://sample27.simplesimples.com/web/markup/javascript/ios-safari-bugfix-smooth-scroll/
*
* 引数には、スクロール領域(position:fixed; overflow:auto; を指定している要素)を指定する
* display:none; の状態で実行しても何も動作しないので注意
*
* @param {object} tgt* jQueryでの要素指定 例) $(".js-ios-scroll")
*/
const bugfixScroll = (tgt) => {
let is_top = true,
is_bottom = false,
moving;
/**
* スクロール位置が上部、もしくは下部にあるとき1px移動する
*/
const checkScroll = () => {
let t = tgt.scrollTop(),
h = $("> :first-child", tgt).outerHeight(true) - tgt.height();
/**
* 0.01秒最上部より上の位置にある場合、1px下に移動し、
* 0.01秒最下部より下の位置にある場合、1px上に移動する
*/
const setPos = (v) => {
if (moving) clearTimeout(moving);
moving = setTimeout(function(){
tgt.scrollTop(v);
if (v === 1) {
is_top = false;
} else {
is_bottom = false;
}
}, 10);
}
// 小数点は切り上げて、整数にする
h = Math.ceil(h);
// スクロール位置が惰性で最上部より上の位置にあるか判別する
if (t < 0) {
is_top = true;
} else if (is_top){
setPos(1);
}
// スクロール位置が惰性で最下部より下の位置にあるか判別する
if (t > h) {
is_bottom = true;
} else if (is_bottom) {
setPos(t - 1);
}
}
// ページ上部にあるときは、1px下に移動する
if (tgt.scrollTop() == 0) {
tgt.scrollTop(1);
}
// tgt内をスクロールしている間、処理する
tgt.on("scroll", checkScroll);
}
$(function () {
bugfixScroll($(".scroll"));
});
JavaScript(DOM)
/**
* 【Bugfix】iOS Safari で惰性スクロールがロックされてしまうのを回避する
* https://sample27.simplesimples.com/web/markup/javascript/ios-safari-bugfix-smooth-scroll/
*
* 引数には、スクロール領域(position:fixed; overflow:auto; を指定している要素)を指定する
* display:none; の状態で実行しても何も動作しないので注意
*
* @param {object} tgt* DOMで要素を特定指定 例) document.getElementsByClassName('js-ios-scroll')[0]
*/
const bugfixScroll = (tgt) => {
let is_top = true,
is_bottom = false,
moving;
/**
* スクロール位置が上部、もしくは下部にあるとき1px移動する
*/
const checkScroll = () => {
let t = tgt.scrollTop,
h = tgt.children[0].offsetHeight - tgt.offsetHeight;
// MEMO: h の取得だが、本来は、margin上下とpadding上下とborder上下の値をケアする必要があるが、ここでは割愛
// ただし、CSS側で、下記の2つのルールを採用すれば、このサンプルのように、JSでのケアの必要はない
// 1. tgt(.scroll)には、padding上下とborder上下を指定しない
// 2. tgt.children[0](.scroll-inner)には、margin上下を指定しない
/**
* 0.01秒最上部より上の位置にある場合、1px下に移動し、
* 0.01秒最下部より下の位置にある場合、1px上に移動する
*/
const setPos = (v) => {
if (moving) clearTimeout(moving);
moving = setTimeout(function(){
tgt.scrollTop = v;
if (v === 1) {
is_top = false;
} else {
is_bottom = false;
}
}, 10);
}
// 小数点は切り上げて、整数にする
h = Math.ceil(h);
// スクロール位置が惰性で最上部より上の位置にあるか判別する
if (t < 0) {
is_top = true;
} else if (is_top){
setPos(1);
}
// スクロール位置が惰性で最下部より下の位置にあるか判別する
if (t > h) {
is_bottom = true;
} else if (is_bottom) {
setPos(t - 1);
}
}
// ページ上部にあるときは、1px下に移動する
if (tgt.scrollTop === 0) {
tgt.scrollTop = 1;
}
// tgt内をスクロールしている間、処理する
tgt.addEventListener("scroll", checkScroll);
}
window.addEventListener('load', function () {
bugfixScroll(document.getElementsByClassName('scroll')[0]);
});
JavaScriptの余談
checkScrollの処理内で、都度高さを取得しているが、これはリサイズした場合に配慮している。
表示したときのみ高さを取得すれば良いように感じるかもしれないが、実際は、ラップトップであればブラウザサイズを変更したり、スマホであれば仮想キーボードを表示したり、処理中に高さが変更されることへの配慮だ。
また、なぜsetTimeout処理を使っているのか。これは、setTimeout処理をしないで、即時処理にしてしまうとビヨーンと惰性スクロールしなくなるためだ。そこで、0.01秒というタイムラグを作っている。この数値はあくまで個人的に実機でいろいろ検証した結果、妥当と判断した値だ。ちなみに、この間隔の時間が長いほど、最上部・最下部状態が長くなるため、フリーズする可能性が高まってしまう。
それでは最後に、iPhoneやiPadで確認できるページまとめ。
実機で動作確認できます
ソースコードはGitHubで公開中。
GitHub: https://github.com/smplsmplsdsn/ios-safari-bugix-smooth-scroll