JavaScript

iOS Safariで絶対配置(position:fixed)して惰性スクロールしてもロックしない、フリーズ回避方法

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でも移動していれば」、フリーズしない。

というわけで、実装イメージが出来上がる。

  1. ページ内スクロールを監視する
  2. ページ内スクロールが止まったとき、スクロール位置を取得する
  3. スクロール位置が最上部の場合は、1px下に移動する
  4. スクロール位置が最下部の場合は、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://www.simplesimplesdesign.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://www.simplesimplesdesign.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

この記事のソースコードをまるっと使ったサンプル

実装済みのコンテンツ

fixed指定したエリア内をスクロールさせている事例。

Five Minute Diary
Webアプリ。タブで切り替えた際のコンテンツエリアで使用。

Sony Eマウント フルサイズ対応のレンズ収集
横幅が720pxより小さい場合の検索エリアで使用。

最近の記事

文章のユーザビリティ

この書籍の中で、ライティングのユーザビリティについて触れている項目があり、激しく共感する部分だったので、ここで備忘録としてピックアップする。 ・・・

ライティング

インタフェースデザイン

この書籍は、I部 リサーチ、II部 デザイン、III部 インプリメンテーションと三部構成になっている。この記事では、その中から、I部 リサーチ、・・・

デザイン思考

フルHD映像を4Kにする → スーパースケール

これを知ったときはマジか!と飛び跳ねた。あらためて思い返してみると、確かに公式チュートリアルでも触れていたことをうっすら思い出す。そしてそのときとやり方がなん・・・

DaVinci Resolve

Macで編集時と書き出し後で色味が変わってしまう場合に試してみたいこと

試したことはまだないけど、Macで編集時と書き出し後で色味が変わって困ったときのために備忘録。 プロジェクト設定 > 「カラーマネジメント・・・

DaVinci Resolve

プロジェクト設定と環境設定

プロジェクト設定と環境設定について詳しく解説されている情報がなかなか見つからないので、要所要所で知り得た情報を更新していきます。 プロジェクト設定 ・・・

DaVinci Resolve

項目”Clipy”は開いているため、ゴミ箱に入れることができません。←アンインストールしようとしたときの話し

Clipyが超絶便利そうだったので、macOS Big Sur にインストール&再起動して使ってみた。ところが、command + c を複数の箇所で行い、c・・・

徒然なままに

setTimeout は requestAnimationFrame に変えるべき?

requestAnimationFrame をはじめて見たので調べていたら、setTimeout や setInterval は requestAnimati・・・

JavaScript

書籍「小さなチーム、大きな仕事」からピックアップ

6年ぶりに読み返したが、今なおハッとさせられることが多く見つかる。 この本で忘れないようにしたい項目をピックアップするだけでも役に立ちそ・・・

起業向け情報

書籍「完全網羅 起業成功マニュアル」からピックアップ

この記事では、以前に読んだ際に付箋を付けていたが、その部分だけを備忘録としてピックアップするため、本の要約ではない。実際は、何倍も濃い内容で、練・・・

起業向け情報

書籍「はじめの一歩を踏み出そう」から気になったフレーズをピックアップ

もう一回、全部読み返したいところだが、書籍がありすぎるので、当時付箋紙をつけていたところだけをピックアップ。書籍内ではより具体的な説明があるので・・・

起業向け情報

人気の記事

コンテンツの量によってフッターの位置を変える

具体的には、ブラウザ表示領域(ファーストビュー)とコンテンツ高さを比較して、コンテンツ高さの方が短い場合、フッターは下部に固定。コンテンツ高さの方が長い場合、ス・・・

CSS2007年11月4日

ファイル名を拡張子なしで取得するサンプル

変数bnにphpファイルのファイル名を拡張子なしで取得するサンプル $bn = basename($_SERVER['PHP_SELF'], ".php");・・・

php2011年10月27日

特定の文字で複数にセルを分割する

たとえば、セルに「テスト左:テスト右」と書かれているのを「テスト左」「テスト右」とセルを分割する方法です。Javascriptでいうところのsplitです。 そ・・・

Excel2009年6月17日

記事のコメントの投稿と表示をテンプレートに直接書いてみる

記事のコメント部分を最低限必要な情報のみ直書きしてみます(WordPress 4.9.2 で実施)。 前提 誰でもコメントできる(管理画面の「設定」→・・・

WordPress2018年2月4日

格安SIMのLINEモバイルにMNP完了!

みなさんは格安SIMを使っていますか?ぼくはdocomo/au/softbankを行ったり来たりしていましたが、この度、格安SIMデビューしました! 超絶・・・

徒然なままに2018年8月21日

ビットレートの目安

ナローバンド(アナログ/ISDN回線)向け 60Kbps ブロードバンド(ADSL/FTTH回線)向け 300Kbps

Flash2007年1月14日

JavaScriptでIEかどうかの判別

Javascriptでブラウザ判別するのはともかく嫌いで、どうにかこれまで避けてきたのだけど。 どうしてもIEかそれ以外の判別をしなければならないシチュエーショ・・・

JavaScript2009年1月4日

女性を美しく見せるフォトレタッチテクニック(CSS Nite Vol.39)

レタッチを始める前の心得 写真の使用目的 写真の用途 Webと印刷の違い 写真を触る際の注意点 画像は触るだけ劣化していく 前の作業工程に戻れる姿勢 ・・・

参加セミナー・イベント2013年12月19日

入力フォームを自由にカスタマイズできる「Advanced Custom Fields」

Movable Typeでいうところのカスタムフィールドを使おうとしたら、Wordpressでは「Advanced Custom Fields」というプラグイン・・・

WordPress2017年7月28日

フォントリンク集

25 Free Futuristic Fonts to Jumpstart Your Designs 25 Best Free Business Fonts ・・・

お役立ちツール2018年5月12日

コメントありの記事

インタフェースデザイン

「test」

デザイン思考

Instagram API を使ってインスタに投稿した画像データを取得する

「>ころころさん コメントありがとうございます! 確かに、APIドキュメント見ましたが、ライブに関する情報は今のところ取得できないようですね。 インスタライ・・・」

API

Sony Eマウント フルサイズ対応のカメラレンズを収集するページを作ってみた

「SIGMAから超軽量で明るい単焦点レンズが2本発売になりましたので、リストにレンズ情報追加しました!」

お仕事・制作日記

overflowをautoにした要素のスクロール位置を変更する → scrollTop scrollLeft

「>名無しさん ご指摘、ありがとうございます! 記事冒頭の「まずはサンプル」のサンプルが動いていませんでしたので修正しました。」

JavaScript

えっ!?Yahooの検索結果から遷移しようとしたら全く違うサイトにリダイレクトされてしまった...

「>前田さん コメントありがとうございます。 環境は分かりかねますが、修正したいファイルのパーミッションを604や644に変更すると書き込みできるようになります(・・・」

お仕事・制作日記

記事の投稿IDなどの投稿者(ユーザー)情報を取得する方法 → get_the_author_meta

「アバターを取得する「get_avatar」についても追記しました!」

WordPress

jQueryでformを操る【nameの値を取得する/クリアする】

「「値をクリアする」を追加しました。チェックボックスやラジオボタンの値を何も選択していない状態に戻すときについて触れています。」

JavaScript

iframeの高さを自動調整する

「>匿名さん コメントありがとうございます。 サンプルでは、beforeは何も処理していないので期待する表示にならない、で正解です。 クリックした後が、対応・・・」

JavaScript

MAMPでMySQL Serverが突然起動しなくなった時 → ログファイル削除

「(匿名)さん コメントありがとうございます。何も設定変更していないのに、それまで使用できていたサーバーが突然使えなくなるのは辛いですよね、、。 ここで紹介したや・・・」

MAMP

イラストレーターでレイヤーごとにPNGで書き出す

「コメントありがとうございます! そもそもできるかはわかりませんが、ソースを書き換える必要はあります。 下記記事などが参考になるかもしれません。 illustra・・・」

Illustorator

WEB制作マークアップJavaScriptiOS Safariで絶対配置(position:fixed)して惰性スクロールしてもロックしない、フリーズ回避方法 | シンプルシンプルデザイン