無限スクロールの実装

Angular2による無限スクロールの実装

1.無限スクロールとは

無限スクロールは、件数の多いリスト表示に便利なユーザーインターフェースです。

例えば、Googleで検索すると、まず10件分の結果が表示され、残りは複数の画面に分割されます。画面の一番下に、下図のようなページ送りアイコンが表示されます。
google_pageindex
求めるデータが1ページ目に無いときは、ページ送りを繰り返しています。この操作を面倒に感じませんか。ExcelやWordのように全体を高速でスクロールしたいと思いませんか。

無限スクロールを使うと、件数の多いリスト表示であっても、画面の切り替えなしに、どこまでもスクロールして表示できます。無限スクロールを体験した人で、いままでのページ送り操作に戻りたいと言う人はいません。

[参考]
無限スクロールのサンプルビデオ

操作性の向上だけでなく、大量の書類・図面・写真などの流し見をして、素早くチェックすることも可能になり、業務効率が向上します。無限スクロールは、ブラウザに処理を分散する「モダンWeb」ならではの機能です。幅広い用途に効果があります。
infini_example

2.無限スクロールのしくみ

無限スクロールは、通信中もユーザーの操作を妨げない非同期通信を使用し、画面表示とデータ受信を同時に行います。
infinite_flow

次に表示するデータを次々と事前取得することで、どこまでもスクロールできます。表示済データを廃棄することで、どんなに大量のデータであっても、容量オーバーにはなりません。また、すべてのデータを受信してからの表示ではなく、最小限のデータが準備できた時点で表示が始まりますので、ページ送り方式に比べて大幅に待たされることはありません。

3.無限スクロールの実装

無限スクロール用のJavaScriptライブラリは多数公開されています。いくつか試しましたが例外処理が不十分だったり、ライブラリのファイルサイズが大きかったりしたため、Angular2で新たに実装したところ、無限スクロール実装部分は、300行程度で済みました。

2016年8月12日更新

import {Component, OnInit} from '@angular/core';
import {RequestOptions} from "@angular/http";
import {HttpService} from "../service/http.service"
import {DataService} from "../service/data.service";
import {SharedService} from "../service/shared.service";
import {I18nDatePipe} from '../pipe/i18nDate.pipe';
import {Router, ActivatedRoute} from "@angular/router";
import {TransitionService} from "../service/transition.service";

const htmlStr: string =
    require('component/reporthistory.component.html');
const cssStr: string =
    require("component/reporthistory.component.css");
const cssNav: string =
    require("../assets/css/navBar.css");

@Component({
    selector: 'report-history',
    template: htmlStr,
    styles: [cssStr, cssNav],
    pipes: [I18nDatePipe]
})

export class ReporthistoryComponent implements OnInit {
    record;
    nextRecord;
    prevRecord;
    startRow = 1;
    endRow = 1;
    BLOCK_SIZE = 30;
    isInitLoadDone = false;
    HEADER_HEIGHT = 50;
    BLOCK_HEIGHT;
    isEnd = false;
    isNextBufferReady = false;
    isPrevBufferReady = false;
    customerId;

    constructor(private httpService: HttpService,
                private dataService: DataService,
                private sharedService: SharedService,
                private router: Router,
                private route: ActivatedRoute,
                private transitionService: TransitionService) {
    }

    ngOnInit() {
        this.initData();
    }

    //初期処理
    initData() {
        //モバイル画面の判定
        this.sharedService.onResize();

        //UrlからcustomerId取得
        let customerId;
        this.route.params
            .map(params => params['Id'])
            .subscribe((Id) => {
                console.log("@@@@ Id:" + Id);
                this.customerId = Id;
            });

        //スクロール位置の初期化
        setTimeout(()=> {
            window.scroll(0, 0)
        }, 1);

        //位置固定ヘッダで隠れる部分を位置補正
        let bodyEl= document.getElementById("myBody")
        bodyEl.style.paddingTop = this.HEADER_HEIGHT + "px";
        this.BLOCK_HEIGHT=bodyEl.clientHeight/3;

        //初期表示データ取得
        this.getRecord(1, this.BLOCK_SIZE * 4)
            .then((result: any)=> {
                let data = result.data.data;
                if (data.length !== this.BLOCK_SIZE * 4) {
                    alert("データ件数が不足しています");
                    return;
                }
                this.record = data.slice(0, this.BLOCK_SIZE * 3);//アクティブなバッファ
                this.nextRecord = data.slice(-this.BLOCK_SIZE * 3);//下方向の次バッファ
                this.isNextBufferReady = true;//下方向の次バッファ有効フラグ
                this.endRow = this.BLOCK_SIZE * 3;//バッファ末尾データの行番号
                this.isInitLoadDone = true;//初期処理完了フラグ
            });
    }

    //ーーーーーーー--------s-----------
    // メニュー選択処理
    //ーーーーーーー-------------------
    onMenuClick(str, event) {
        event.preventDefault();
        event.stopPropagation();
        switch (str) {
            case "back"://顧客情報画面へ戻る
                this.transitionService.canTransition = true;
                this.router.navigate(['/customerDetail', this.customerId]);
                break;
        }
    }

    //ーーーーーーー-------------------
    //スクロールイベント処理
    //ーーーーーーー-------------------
    onScroll(event) {
        //初期処理完了前のスクロールイベントは無視
        if (!this.isInitLoadDone) return;

        //画面のレイアウト情報取得
        let html = document.documentElement;//html要素
        let pageHeight = html.scrollHeight;//全体の高さ
        let clientHeight = html.clientHeight; //表示域の高さ
        let scrollPos = window.pageYOffset;//スクロール位置
        let bufferHeight = pageHeight / 3;//Buffer Blockの表示域の高さ
        let bottom_margine = //下方向スクロール可能サイズ
            pageHeight - clientHeight - scrollPos;
        let top_margine = scrollPos; //上方向スクロール可能サイズ

        //バッファ枯渇の場合はBottomで反転バウンド(下方向)
        if (!this.isEnd && bottom_margine === 0) {
            setTimeout(()=> {
                scrollBy(0, -20)
            }, 1);
            return;
        }

        //バッファ枯渇の場合はTopで反転バウンド(上方向)
        if (this.startRow !== 1 && top_margine === 0) {
            setTimeout(()=> {
                scrollBy(0, 20)
            }, 1);
            return;
        }

        //バッファ追加の判定(下方向)
        if (bottom_margine < bufferHeight * 0.5 && !this.isEnd &&
            this.isNextBufferReady) {
            this.isNextBufferReady = false;
            console.log("@@@@ onScrollDown");
            this.onScrollDown();
            return;
        }

        //バッファ追加の判定(上方向)
        if ((scrollPos < bufferHeight * 0.5) &&
            (this.startRow !== 1) &&
            (this.isPrevBufferReady || this.prevRecord === null)) {
            this.isPrevBufferReady = false;
            console.log("@@@@ onScrollUp");
            this.onScrollUp();
            return;
        }
    }

    //バッファ追加(下方向)
    onScrollDown() {
        this.log();

        //次の上方向バッファを取得
        this.prevRecord = this.record.slice(0, this.BLOCK_SIZE * 3);
        this.isPrevBufferReady = true;

        //表示データ書き換え
        this.record = this.nextRecord;

        //表示位置カウンタの更新
        this.startRow += this.BLOCK_SIZE;
        this.endRow += this.record.length - this.BLOCK_SIZE * 2;

        //表示位置補正
        let bodyEl= document.getElementById("myBody")
        this.BLOCK_HEIGHT=(bodyEl.clientHeight+50)/3;

        let offset = 0 - this.BLOCK_HEIGHT;
        console.log("@@@@ Scroll offset: " + offset);
        setTimeout(()=> {
            window.scrollBy(0, offset)
        }, 1);

        //次のデータを準備
        setTimeout(()=> {
            this.getNextBuffer();
        }, 1);
    }

    //バッファ追加(上方向)
    onScrollUp() {

        //次の下方向バッファを取得
        this.nextRecord = this.record.slice(0, this.BLOCK_SIZE * 3);
        this.isNextBufferReady = true;

        //表示データ書き換え
        this.record = this.prevRecord;

        //表示位置カウンタの更新
        this.startRow -= this.record.length - this.BLOCK_SIZE * 2;
        this.endRow -= this.BLOCK_SIZE;

        //表示位置補正
        let offset = this.BLOCK_HEIGHT;
        console.log("@@@@ Scroll offset: " + offset);
        setTimeout(()=> {
            window.scrollBy(0, offset)
        }, 1);

        //次のデータを準備
        setTimeout(()=> {
            this.getPrevBuffer();
        }, 1);
    }

    //次に追加するバッファ作成(下方向)
    getNextBuffer() {
        this.getRecord(this.endRow + 1, this.BLOCK_SIZE)
            .then((result: any)=> {
                let rec = result.data.data;

                if (rec.length === this.BLOCK_SIZE) {//ブロックサイズ分の追加データあり
                    this.isEnd = false;
                    this.nextRecord = this.record
                        .slice(-this.BLOCK_SIZE * 2).concat(rec);
                } else if (rec.length === 0) { //追加データなし
                    this.isEnd = true;
                } else { //末尾データでブロックサイズ分のデータなし
                    this.isEnd = true;
                    this.record = this.record.concat(rec);
                }

                //バッファ準備完了
                this.isNextBufferReady = true;
            })
    }

    //次に追加するバッファ作成(上方向)
    getPrevBuffer() {
        this.getRecord(this.startRow - this.BLOCK_SIZE, this.BLOCK_SIZE)
            .then((result: any)=> {
                let rec = result.data.data;
                this.prevRecord = rec.concat
                (this.record.slice(0, this.BLOCK_SIZE * 2));

                //バッファ準備完了
                this.isPrevBufferReady = true;
                this.isEnd = false;
            });
        this.log();
    }

    //サーバーからデータ取得
    getRecord(begin: number, size: number): Promise<any> {
        console.log("@@@@@ getRecord begin=" + begin + " size=" + size +
            " startRow=" + this.startRow + " endRow=" + this.endRow);

        let arr = new Array(size);
        //HTTPリクエスト条件設定
        const config = new RequestOptions();
        config.url = this.httpService.AJAX_URL + "history";
        config.body = ({begin: begin, size: size});
        //HTTPリクエスト開始
        let promise = this.httpService.send("post", config);
        promise.then(
            (result: any)=> {
                return result;
            },
            (error)=> {
                alert("通信エラー" + error.message);
                return "error";
            });
        return promise;
    }

    log() {
        if (this.record)
            console.log("@@@@@ current=" + this.record[0].id + "|" + this.record[this.record.length - 1].id);
        if (this.nextRecord)
            console.log("@@@@@ next=" + this.nextRecord[0].id + "|" + this.nextRecord[this.nextRecord.length - 1].id);
        if (this.prevRecord)
            console.log("@@@@@ prev=" + this.prevRecord[0].id + "|" + this.prevRecord[this.prevRecord.length - 1].id);

    }
}

弊社では、「Angular2とTypeScriptによる モダンWeb開発セミナー」を開催します。
Angular2の実践的な知識とノウハウを習得できます。
http://www.staffnet.co.jp/hp/semi/modern/