目的のスクロール量到達を検知して何かするためのJavaScriptコード

## 概要
Webページが一定以上スクロール（読了）される度に何かするためのコード。
例えば、「Webページが18%、50%、80%読了された時に何かしたい」という状況で使用する。

:::note 
### 新しい記事の紹介
本記事は2017年に書かれた古い記事です。SPAにも対応したGTMでのスクロール計測方法について書かれた新しい記事があるので合わせてご覧ください。

**SPAでGA4のスクロールイベントを計測する方法**
https://qiita.com/aqril_1132/items/aa0c1e4c57eb5a94a8ec

:::

### 読了率の定義

このコード内では **「ページ全体の縦幅の内、どこまで視界に入ったか」** を読了率として定義している。

（読了率を **「スクロールしなければ見えない領域の内、どの程度スクロールしたか」** と定義したいときの設定についても[記事の後半で説明](#%E8%AA%AD%E4%BA%86%E7%8E%87%E3%81%AE%E5%AE%9A%E7%BE%A9%E5%A4%89%E6%9B%B4)）

```math
\begin{align}
\mbox{読了率}\,\mathrm{(\%)}
&=\frac{\mbox{ページ最上部からビューポート最下部 までの長さ}}{\mbox{ページ全体の長さ}}\times100\\\\
&=\frac{\left($(window).scrollTop()+$(window).height()\right)\times100}{$(document).height()}
\end{align}
```

- [.scrollTop() | jQuery API Documentation](http://api.jquery.com/scrollTop/)
- [.height() | jQuery API Documentation](http://api.jquery.com/height/)

ページにスクロール可能な幅が存在しないとき、読了率は最初から100%になる。

[フラグメント付きのリンク](http://www.res-system.com/weblog/item/436)等により、既にスクロールされた状態でページ閲覧を開始したとき、既にスクロールされている領域は読了したと見なす。

### 動作
- 指定の目標読了率にスクロールが到達する度に`intent()`が実行され、[カスタムイベント](http://qiita.com/shouchida/items/de326cb608027b46f58c)`scrollEvt`が発火する
- 0.3秒毎に読了率が達成されたか確認する(ブラウザ負荷軽減などの観点から[scrollイベント](https://api.jquery.com/scroll/)は使用していない)
- 複数の目標読了率が同時に達成されたとして、数字の小さい読了率から順に0.3秒毎に達成確認が行われる。
- イベント発火は指定した読了率毎に1度のみ
- 指定したすべての読了率へのスクロール到達を確認した後、動作を終了する

これらの動作仕様により、スクロール完了を検知しようとする時は（サイト閲覧者は100%スクロール後に即座に上方向へのスクロール戻しを行う可能性があるため、）98%などで設定するか、他の方法を利用する。

## コード

~~onメソッドを使用しているためversion1.7以上のjQueryを読み込んだ上で使用~~

```js:scrollEvent.js
;(function($){
    var rawTgtReadingRate = [20,75,100]; // ここに目標読了率を0~100で入力して使用

    /** 最後に達成した目標読了率(0~100[%]) */
    var lastReachedTgt;
    var conditions = {
        /** ドキュメントの縦幅の長さ[pixel] */
        docHeight : 1,
        /** ウィンドウの縦幅の長さ[pixel] */
        winHeight : 0,
        /** ウィンドウ上端の縦幅位置[pixel] */
        scrollTop : 0,
        /** 読了率(0~100[%]) */
        readingRate : 0,
        /** オブジェクトの情報を更新 */
        update : function(){
            this.docHeight = $(document).height();
            this.winHeight = $(window).height();
            this.scrollTop = $(window).scrollTop();
            this.readingRate = Math.round((this.scrollTop + this.winHeight) * 100 / this.docHeight);
        }
    };
    var intent = function(readingRate){
        console.log('intent: ' + readingRate + '%');  // test
        $(window).trigger('scrollEvt', readingRate);
    };

    var timerId;
    var checkConditions = function(){
        if(typeof(timerId) !== 'undefined'){
            clearInterval(timerId);
        }
        conditions.update();
        if(conditions.readingRate >= tgtReadingRate[0]){
            lastReachedTgt = tgtReadingRate.shift();
            intent(lastReachedTgt);
        }
        if(tgtReadingRate.length){
            timerId = setInterval(checkConditions, 300);
        }
    };

    var tgtReadingRate = [];
    for(var i=0,len=rawTgtReadingRate.length;i<len;i++){
        if(rawTgtReadingRate[i]>0 && rawTgtReadingRate[i]<=100){
            tgtReadingRate.push(rawTgtReadingRate[i]);
        }
    }
    tgtReadingRate.sort(function(a,b){return a - b;});

    if(Number($.fn.jquery.match(/\d+\.\d+/)[0])>=1.7){
        $(window).on('load', checkConditions);
    }else{
        if(window.addEventListener){
            window.addEventListener('load', checkConditions);
        }else if(window.attachEvent){
            window.attachEvent('onload', checkConditions);
        }
    }
})(jQuery);
```

Webページの長さもブラウザのウィンドウの縦幅の長さも常に変更される可能性があるため、都度再計算している。

JavaScript処理などによりloadイベントの後にページの長さが大きく伸びるとき、伸び終わる前に読了率の計算が始まってしまい、意図した通りの計測ができないことがある。
このときは、ページが伸びる処理が完了した後に最初の`checkConditions()`が動くようにコードを若干書き換える必要がある。

カスタムイベント`scrollEvt`の発生は以下の要領で検知できる。

```js:eventHandler.js
$(window).on('scrollEvt',function(){
    console.log(arguments[0].type + ': ' + arguments[1] + '%');
});
```

**実行結果**
![image.png](https://qiita-image-store.s3.amazonaws.com/0/103957/a1bbb0ae-37ff-2979-65e6-6bd56c988232.png)


## カスタマイズ
### 読了率の定義変更

読了率の定義を「スクロールしなければ見えない領域の内、どの程度スクロールしたか」というものに変えたいのであれば、読了率の定義は以下のようになる。

```math
\begin{align}
\mbox{読了率}\,\mathrm{(\%)}
&=\frac{\mbox{スクロールした長さ}}{\mbox{スクロールしなければ見えない領域の長さ}} \times 100 \\\\
&= \frac{$(window).scrollTop() \times 100}{$(document).height() - $(window).height()}
\end{align}
```

また、 **スクロールしなければ見えない領域** が存在しない(=計算式の分母が0になる)ことも想定される。

このため、`conditions.update()`内の`this.readingRate`を更新する計算式を以下のように変更すれば対応できる。

```js:readingRate2
this.readingRate = this.docHeight > this.winHeight ? Math.round(this.scrollTop * 100 / (this.docHeight - this.winHeight)) : 100;
```

### スクロール巻き戻しへの対応
現在のコードは必ず0.3秒以上の間隔を置いて`intent()`が実行され、カスタムイベントが発生する。
このため、例えば以下のように細かく目標読了率を設定した場合、読了率100%のイベントが発生する前にユーザーがスクロールを巻き戻してしまう可能性がある。

```js
    var rawTgtReadingRate = [20,25,30,35,40,45,50,55,60,65,70,75,80,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100];
```

以下のように、`checkConditions()`内の以下のif文を以下のように変更すれば、次の0.3秒を待たずに一瞬で現在達成している読了率分のイベントを起こすことができる。

これを...

```js
    var checkConditions = function(){
        if(typeof(timerId) !== 'undefined'){
            clearInterval(timerId);
        }
        conditions.update();
        if(conditions.readingRate >= tgtReadingRate[0]){
            lastReachedTgt = tgtReadingRate.shift();
            intent(lastReachedTgt);
        }
        if(tgtReadingRate.length){
            timerId = setInterval(checkConditions, 300);
        }
    };
```

以下のように変更

```js
    var checkConditions = function(){
        if(typeof(timerId) !== 'undefined'){
            clearInterval(timerId);
        }
        conditions.update();
        /** ↓ 変更された行 */
        while(tgtReadingRate.length && conditions.readingRate >= tgtReadingRate[0]){
            lastReachedTgt = tgtReadingRate.shift();
            intent(lastReachedTgt);
        }
        if(tgtReadingRate.length){
            timerId = setInterval(checkConditions, 300);
        }
    };
```

また、読了率の計算が必ず過去の最大値とするように変更すれば、読了率の巻き戻りを防ぐことができる。

**読了率の分母をページ全体の長さで定義する計算式のとき**

```js:readingRate
this.readingRate = Math.max(Math.round((this.scrollTop + this.winHeight) * 100 / this.docHeight),this.readingRate);
```

**読了率の分母をスクロールしなければ見れない領域の長さで定義する計算式のとき**

```js:readingRate2
this.readingRate = Math.max(this.docHeight > this.winHeight ? Math.round(this.scrollTop * 100 / (this.docHeight - this.winHeight)) : 100, this.readingRate);
```


↑ 読了率の更新時に[Math.max()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Math/max)で今の値と過去の値を比較し、値が大きい方を新しい読了率としてセットしている。

<!--

### 個人的によく使うカスタマイズ

- 設置先にはver2.0以上のjQueryが設置されている前提
- 読了率の分母：まだ見えていない領域の長さ
- 読了率の巻き戻りを防ぐ設定
- (理由あって)カスタムイベントが瞬間的に複数回発生しないようにする
- スクロールを検知したときのみ読了率の計算を行う

```js:ScrollEventMyCustom.js
;(function($){
    var rawTgtReadingRate = [30,50,100]; // ここに目標読了率を0~100で入力して使用


    /** 最後に達成した目標読了率(0~100[%]) */
    var lastReachedTgt;
    var conditions = {
        /** ドキュメントの縦幅の長さ[pixel] */
        docHeight : 1,
        /** ウィンドウの縦幅の長さ[pixel] */
        winHeight : 0,
        /** ウィンドウ上端の縦幅位置[pixel] */
        scrollTop : 0,
        /** 読了率(0~100[%]) */
        readingRate : 0,
        /** オブジェクトの情報を更新 */
        update : function(){
            this.docHeight = $(document).height();
            this.winHeight = $(window).height();
            this.scrollTop = $(window).scrollTop();
            this.readingRate = Math.max(this.docHeight > this.winHeight ? Math.round(this.scrollTop * 100 / (this.docHeight - this.winHeight)) : 100, this.readingRate);
        }
    };
    var intent = function(readingRate){
        console.log('intent: ' + readingRate + '%');  // test
        $(window).trigger('scrollEvt', readingRate);
    };

    var timerId;
    var checkConditions = function(){
        if(typeof(timerId) !== 'undefined'){
            clearInterval(timerId);
        }
        console.log('tick.');  // test
        conditions.update();
        if(conditions.readingRate >= tgtReadingRate[0]){
            lastReachedTgt = tgtReadingRate.shift();
            intent(lastReachedTgt);
        }
        if(tgtReadingRate.length){
            if(conditions.readingRate >= tgtReadingRate[0]){
                 timerId = setInterval(checkConditions , 300);
            }else{
                 timerId = setInterval(waitScroll, 300);
            }

        }
    };

    var waitScroll = function(){
        if(typeof(timerId) !== 'undefined'){
            clearInterval(timerId);
        }
        console.log('set scroll listener');  // test
        $(window).on('scroll.'+processName,function(){
            console.log('scroll!!');  // test
            $(window).off('scroll.'+processName);
            checkConditions();
        });
    };

    
    var tgtReadingRate = [];
    for(var i=0,len=rawTgtReadingRate.length;i<len;i++){
        if(rawTgtReadingRate[i]>0 && rawTgtReadingRate[i]<=100){
            tgtReadingRate.push(rawTgtReadingRate[i]);
        }
    }
    tgtReadingRate.sort(function(a,b){return a - b;});
    
    var processName = 'scrollListener'+Math.floor(Math.random()*Math.pow(10,6));
    $(window).on('load', checkConditions);
})(jQuery);
```

-->

## 上手く動作しないとき
[DOCTYPE宣言](http://qiita.com/akatsuki174/items/16a2189f2845205fcf88)が不正だと`$(window).height()`が上手く動作しないため、正しく動作しない。

- [!DOCTYPE宣言が書き換えられて$(window).height()がおかしくなった – モノグサにお灸](https://monoxa.net/2014/09/get-windows-height-bug-at-jquery/)
- [DOCTYPEしないとjQueryがwindowとdocumentを混同する件 - Qiita](http://qiita.com/hkanamaru/items/893c20d0396379c6b3c0)
- [$(window).height()が効かなくてハマッタことについて - ひよっこPGのブログ](http://buzzword111.hatenablog.com/entry/2013/10/14/030517)



完