無限スクロールの実装
Angular2による無限スクロールの実装
1.無限スクロールとは
無限スクロールは、件数の多いリスト表示に便利なユーザーインターフェースです。
例えば、Googleで検索すると、まず10件分の結果が表示され、残りは複数の画面に分割されます。画面の一番下に、下図のようなページ送りアイコンが表示されます。

求めるデータが1ページ目に無いときは、ページ送りを繰り返しています。この操作を面倒に感じませんか。ExcelやWordのように全体を高速でスクロールしたいと思いませんか。
無限スクロールを使うと、件数の多いリスト表示であっても、画面の切り替えなしに、どこまでもスクロールして表示できます。無限スクロールを体験した人で、いままでのページ送り操作に戻りたいと言う人はいません。
[参考]
無限スクロールのサンプルビデオ
操作性の向上だけでなく、大量の書類・図面・写真などの流し見をして、素早くチェックすることも可能になり、業務効率が向上します。無限スクロールは、ブラウザに処理を分散する「モダンWeb」ならではの機能です。幅広い用途に効果があります。

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

次に表示するデータを次々と事前取得することで、どこまでもスクロールできます。表示済データを廃棄することで、どんなに大量のデータであっても、容量オーバーにはなりません。また、すべてのデータを受信してからの表示ではなく、最小限のデータが準備できた時点で表示が始まりますので、ページ送り方式に比べて大幅に待たされることはありません。
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の実践的な知識とノウハウを習得できます。
/hp/semi/modern/