Svelte で動的スクロールを実装してみた

# はじめに

最近では検索結果を表示する際、下にスクロールしたら自動的に次のページ分を追加読込するのが一般的になっているかと思います。

[Svelte] でそういった仕組みを実装してみましたので、ここではその実装内容とポイントを紹介します。

# 実装

```html:DynamicScroll.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>
```

```html:App.svelte
<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>
```

# 動作確認

[こちら](https://svelte.dev/repl/7b2790594b144c67bc4e2cd9d252ad3a?version=3.59.1)で動作確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/1c01921a-70c5-5fe0-2d38-9a1bbc3d46f2.png)

スクロールすると自動的に読み込まれてスクロールバーの長さが変わることを確認できると思います。

尚、背景色を設定している理由はスクロールしていることを認識しやすくするためです。（数字だけ表示しているとスクロールしてるのかどうか、わからなかったりするんですよね:sweat_smile:）

実装を変えてみたりしてどんな動きになるのか、ぜひいろいろ試してみてください。

# ポイント

## DynamicScroll.svelte

### スクロールバーが表示されるまで事前ロード

`on:scroll` イベントで `load()` を実行している都合上、スクロールバーが表示されていなければ話になりません。ですので、初回表示時に `preLoad()` を実行してスクロールバーが表示されるまでデータを読み込み続けています。

```js: 初回表示時に preLoad() を実行
	async function init() {
		list = [];
		await tick();
		await preLoad();
	}
	
	init();
```

```js:スクロールバーが表示されるまでデータを読み込み続ける
	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` で、まだスクロールバーが表示されていないことを判定しています。

### 読み込むかどうかの判定方法

読み込むかどうかの判定は言葉で説明するのが難しいので図で表してみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/bcf4ba8e-03df-c422-51db-195b33875f9e.png)

太い枠の領域が画面表示領域です。赤い領域の高さが画面表示領域の高さ(`container.clientHeight`) の 10% 以下になった場合に読込が実行される仕様です。赤い領域の高さは `container.scrollTop`、`container.scrollHeight`、`container.clientHeigh` の値を駆使して計算しています。


## App.svelte

### {#key} で再描画のタイミングをコントロール

画面上の `initialValue` 入力ボックスで値を変更して `set` ボタンをクリックすると初期値(画面の最上部に表示する値)を変更して再描画されます。これを実現するために [{#key}](https://svelte.dev/tutorial/key-blocks) を使っています。

```html:{#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](https://svelte.dev/repl) がコンソールログを確認できるように設計されているからです。スマホでデバッグできるし修正もできるので、とても便利でした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/b000a767-8cf6-4735-578a-9a80e319fb6b.png)

# まとめ

[Svelte] で動的スクロールのコンポーネントを実装できました。もちろん完ぺきではありませんが、参考程度にはなるかなと思います。

改善点や不備等がありましたらコメントしていただけると嬉しいです。:smiley:

[Svelte]:https://svelte.dev/