はじめに
最近では検索結果を表示する際、下にスクロールしたら自動的に次のページ分を追加読込するのが一般的になっているかと思います。
Svelte でそういった仕組みを実装してみましたので、ここではその実装内容とポイントを紹介します。
実装
<script>
import {tick} from 'svelte';
export let previousChunk = undefined;
export let nextChunk = undefined;
const maxRetryCountOnPreLoad = 20;
const triggerRangeRatio = 0.1;
let list = [];
let container;
let clientHeight = 0;
let loading = false;
$: triggerRange = clientHeight * triggerRangeRatio;
/**
* 上方向のデータをロードします。
*/
async function loadPrevious() {
if (!previousChunk) return;
const beforeScrollHeight = container.scrollHeight;
const beforeScrollTop = container.scrollTop;
const prev = await previousChunk(list.length === 0 ? null : list[0]);
if (prev.length === 0) return;
list = [...prev, ...list];
await tick();
container.scrollTo(0, container.scrollHeight - beforeScrollHeight + beforeScrollTop);
}
/**
* 下方向のデータをロードします。
*/
async function loadNext() {
if (!nextChunk) return;
const beforeScrollTop = container.scrollTop;
const next = await nextChunk(list.length === 0 ? null : list[list.length - 1]);
if (next.length === 0) return;
list = [...list, ...next];
await tick();
container.scrollTo(0, beforeScrollTop);
}
/**
* スクロールバーが表示されるまでロードします。
*/
async function preLoad() {
if (!container) return;
if (loading) return;
loading = true;
try {
const loadInternal = async (loadFunc) => {
let loadCount = 0;
while (loadCount === 0 || container.scrollHeight <= container.clientHeight) {
loadCount++;
await loadFunc();
if (list.length === 0) return;
await tick();
if (maxRetryCountOnPreLoad < loadCount) {
break;
}
}
};
await tick();
await loadInternal(loadNext);
await tick();
await loadInternal(loadPrevious);
} finally {
loading = false;
}
}
/**
* スクロールの方向に応じてデータをロードします。
*/
async function load() {
if (!container) return;
if (loading) return;
loading = true;
try {
if (!!previousChunk && container.scrollTop <= triggerRange) {
await loadPrevious();
} else if (!!nextChunk && container.scrollTop >= container.scrollHeight - container.clientHeight - triggerRange) {
await loadNext();
}
} finally {
loading = false;
}
}
async function init() {
list = [];
await tick();
await preLoad();
}
init();
</script>
<div class="container" bind:clientHeight={clientHeight} bind:this={container} on:scroll={load} >
{#each list as value (value)}
<slot prop={value} />
{/each}
</div>
<style>
.container {
width: 100%;
height: 100%;
overflow-y: scroll;
box-sizing: border-box;
}
</style>
<script>
import DynamicScroll from './DynamicScroll.svelte';
const MIN = -180
const MAX = 180;
const chunkSize = 10;
let initialValue = 0;
$: inputValue = initialValue;
$: {
if (initialValue < MIN) initialValue = MIN;
if (MAX < initialValue) initialValue = MAX;
}
const getEndOfArray = (array) => array[array.length - 1];
function previousChunk(lastValue) {
const _last = lastValue ?? initialValue + 1;
if (_last <= MIN) return [];
let array = [];
for (let i = 0; i < chunkSize; i++) {
array.push(_last - (i + 1));
if (getEndOfArray(array) <= MIN) return array.reverse();
}
return array.reverse();
}
function nextChunk(lastValue) {
const _last = lastValue ?? initialValue - 1;
if (MAX <= _last) return [];
let array = [];
for (let i = 0; i < chunkSize; i++) {
array.push(_last + (i + 1));
if (MAX <= getEndOfArray(array)) return array;
}
return array;
}
</script>
<div class="app">
initialValue:<input type="number" bind:value={inputValue} max={MAX} min={MIN} />
<button on:click={() => initialValue = inputValue}>set</button>
<div class="numbers">
{#key initialValue}
<DynamicScroll {nextChunk} {previousChunk} let:prop={value}>
{#if value === MIN}<div class="end">end</div>{/if}
<div class="row" style:background-color={`hsl(${value},90%,80%)`}>{value}</div>
{#if value === MAX}<div class="end">end</div>{/if}
</DynamicScroll>
{/key}
</div>
</div>
<style>
:global(body) {
padding: 0;
display: flex;
justify-content: center;
}
.app {
height: 100%;
width: 80%;
max-width: 300px;
min-width: 100px;
}
input {
width: 70px;
margin-top: 20px;
}
.numbers {
height: 80%; /* 高さの指定が重要。これが無いと全件読込が発生しちゃう。 */
overflow-y: hidden;
border: solid 1px;
box-sizing: border-box;
border-radius: 20px;
}
.row {
border: none;
border-top: 1px solid rgba(0,0,0,0.1);
height: 40px;
width: 100%;
padding: 10px;
box-sizing: border-box;
text-align: center;
}
.end {
width: 100%;
text-align: center;
background-color: rgba(0,0,0,0.1);
}
</style>
動作確認
こちらで動作確認できます。
スクロールすると自動的に読み込まれてスクロールバーの長さが変わることを確認できると思います。
尚、背景色を設定している理由はスクロールしていることを認識しやすくするためです。(数字だけ表示しているとスクロールしてるのかどうか、わからなかったりするんですよね)
実装を変えてみたりしてどんな動きになるのか、ぜひいろいろ試してみてください。
ポイント
DynamicScroll.svelte
スクロールバーが表示されるまで事前ロード
on:scroll
イベントで load()
を実行している都合上、スクロールバーが表示されていなければ話になりません。ですので、初回表示時に preLoad()
を実行してスクロールバーが表示されるまでデータを読み込み続けています。
async function init() {
list = [];
await tick();
await preLoad();
}
init();
async function preLoad() {
if (!container) return;
if (loading) return;
loading = true;
try {
const loadInternal = async (loadFunc) => {
let loadCount = 0;
while (loadCount === 0 || container.scrollHeight <= container.clientHeight) {
loadCount++;
await loadFunc();
if (list.length === 0) return;
await tick();
if (maxRetryCountOnPreLoad < loadCount) {
break;
}
}
};
await tick();
await loadInternal(loadNext);
await tick();
await loadInternal(loadPrevious);
} finally {
loading = false;
}
}
container.scrollHeight <= container.clientHeight
で、まだスクロールバーが表示されていないことを判定しています。
読み込むかどうかの判定方法
読み込むかどうかの判定は言葉で説明するのが難しいので図で表してみました。
太い枠の領域が画面表示領域です。赤い領域の高さが画面表示領域の高さ(container.clientHeight
) の 10% 以下になった場合に読込が実行される仕様です。赤い領域の高さは container.scrollTop
、container.scrollHeight
、container.clientHeigh
の値を駆使して計算しています。
App.svelte
{#key} で再描画のタイミングをコントロール
画面上の initialValue
入力ボックスで値を変更して set
ボタンをクリックすると初期値(画面の最上部に表示する値)を変更して再描画されます。これを実現するために {#key} を使っています。
<div class="numbers">
{#key initialValue}
<DynamicScroll {nextChunk} {previousChunk} let:prop={value}>
{#if value === MIN}<div class="end">end</div>{/if}
<div class="row" style:background-color={`hsl(${value},90%,80%)`}>{value}</div>
{#if value === MAX}<div class="end">end</div>{/if}
</DynamicScroll>
{/key}
</div>
これにより、initialValue
が変更された場合に <DynamicScroll>~</DynamicScroll>
全体が新しく再描画されます。
スマホの場合
ちなみに、スマホの場合は上方向にスクロールしたときの挙動がちょっとおかしくなってしまいます。なぜか、追加読込した直後に scroll イベントが発生しちゃうんですよね。なので思いのほかいっきに飛んじゃいます。しかも、この2回目のイベント発生時は container.scrollTop
が負の値だったりします。残念ながらこの問題は解決できなかったです。
実は、この挙動をある程度デバッグできたのは、Svelte の REPL がコンソールログを確認できるように設計されているからです。スマホでデバッグできるし修正もできるので、とても便利でした。
まとめ
Svelte で動的スクロールのコンポーネントを実装できました。もちろん完ぺきではありませんが、参考程度にはなるかなと思います。
改善点や不備等がありましたらコメントしていただけると嬉しいです。