「仮想スクロール」を使ったタイムテーブルアプリをHTML+CSS+JavaScriptで作ってみました。
スマホでスクロールするだけで過去から未来まで日付をまたいで予定表を見られます。
FacebookやTwitterのような無限スクロールの仕組みを作るには、「仮想スクロール」の仕組みが必要です。
1. 仮想スクロールって何?
「仮想スクロール」は、画面に見えている部分だけを実際に描画し、見えていない部分はスクロールバーの高さだけを確保する技術です。
大量のデータを表示したいとき、すべての要素をブラウザに描画すると動作が重くなります。
例えば、10年分(約3650日)の予定表をすべて描画すると、1日24時間で計算すると87,600個もの要素をDOMに追加することになります。これではブラウザが重くなってしまいます。
仮想スクロールでは、画面に表示されている50個程度の要素だけを描画し、スクロールに合わせて表示内容を入れ替えます。
1.1. アプリの基本設計
今回作ったアプリは、
- 24時間のタイムテーブル表示
- 日付をまたいだスムーズなスクロール
- 過去5年から未来5年までの予定表示
- スクロール位置に合わせた日付表示の更新
- ブラウザの標準スクロールと慣性効果を活用
HTMLの基本構造は次のようになります:
<div id="app">
<div id="date-header">日付が表示されます</div>
<div id="virtual-scroller">
<div id="virtual-content">
<div id="viewport">
</div>
</div>
</div>
<div class="instructions">スクロールして時間を変更</div>
</div>Code language: HTML, XML (xml)
2. 仮想スクロールの実装手順
2.1. スクロール領域のサイズ設定
まず、実際のコンテンツ(10年分)と同じ高さの仮想領域を作ります。
function setVirtualContentSize() {
const totalDays = 365 * 10;
const contentHeight = totalDays * DAY_HEIGHT;
virtualContent.style.height = `${contentHeight}px`;
const initialDayOffset = Math.floor(totalDays / 2);
const todayHour = today.getHours();
const initialSlotIndex = initialDayOffset * SLOTS_PER_DAY + todayHour;
const initialScrollTop = calculatePositionForIndex(initialSlotIndex) - Math.floor(virtualScroller.clientHeight / 2);
virtualScroller.scrollTop = initialScrollTop;
}Code language: JavaScript (javascript)
この関数では、10年分の日数に1日の高さをかけて全体の高さを計算し、スクロール領域に設定しています。また、初期表示位置として今日の日付と現在時刻が中央に来るようにスクロール位置を調整しています。
2.2. インデックス⇔位置の相互変換
仮想スクロールでは、どの位置にどの要素を表示するかの計算が重要です。スロットインデックス(通し番号)と画面上の位置(Y座標)を相互に変換する関数を作ります。
function calculatePositionForIndex(slotIndex) {
const dayOffset = Math.floor(slotIndex / SLOTS_PER_DAY);
const hourOffset = slotIndex % SLOTS_PER_DAY;
return dayOffset * DAY_HEIGHT + hourOffset * (SLOT_HEIGHT + SLOT_MARGIN) + DIVIDER_HEIGHT;
}
function calculateIndexFromPosition(position) {
const dayOffset = Math.floor(position / DAY_HEIGHT);
const positionInDay = position % DAY_HEIGHT;
if (positionInDay < DIVIDER_HEIGHT) {
return (dayOffset * SLOTS_PER_DAY) - 1;
}
const hourOffset = Math.floor((positionInDay - DIVIDER_HEIGHT) / (SLOT_HEIGHT + SLOT_MARGIN));
return dayOffset * SLOTS_PER_DAY + Math.min(hourOffset, SLOTS_PER_DAY - 1);
}Code language: JavaScript (javascript)
これにより、スクロール位置からどのスロットを表示すべきかを計算できます。日付区切りの位置についても正確に考慮しています。
2.3. 表示要素の動的管理
スクロールに合わせて表示する要素を追加・削除する中核となる関数です。
function updateVisibleItems() {
const scrollTop = virtualScroller.scrollTop;
const visibleHeight = virtualScroller.clientHeight;
const topPosition = Math.max(0, scrollTop - (BUFFER_SIZE / 2) * (SLOT_HEIGHT + SLOT_MARGIN));
const bottomPosition = scrollTop + visibleHeight + (BUFFER_SIZE / 2) * (SLOT_HEIGHT + SLOT_MARGIN);
const startSlotIndex = calculateIndexFromPosition(topPosition);
const endSlotIndex = calculateIndexFromPosition(bottomPosition);
const topVisibleSlotIndex = calculateIndexFromPosition(scrollTop);
const { date } = getDateTimeForSlot(topVisibleSlotIndex);
if (!date.getTime || currentVisibleDate.getTime() !== date.getTime()) {
currentVisibleDate = date;
updateDateDisplay(date);
}
renderedItems.forEach((element, key) => {
if (slotIndex < startSlotIndex - SLOTS_PER_DAY || slotIndex > endSlotIndex + SLOTS_PER_DAY) {
element.remove();
renderedItems.delete(key);
}
});
for (let i = startSlotIndex; i <= endSlotIndex; i++) {
if (i < 0) continue;
const slotKey = `slot-${i}`;
if (!renderedItems.has(slotKey)) {
const timeSlot = createTimeSlot(i);
const position = calculatePositionForIndex(i);
positionItemInViewport(timeSlot, position);
viewport.appendChild(timeSlot);
renderedItems.set(slotKey, timeSlot);
}
}
}Code language: JavaScript (javascript)
この関数では次のことを行っています:
- 現在のスクロール位置から表示範囲を計算
- 画面上部に表示されているスロットの日付で日付ヘッダーを更新
- 表示範囲外になった要素をDOMから削除
- 表示範囲内の日付区切りと時間スロットを追加
バッファを設定することで、スクロール中も滑らかに表示できるようにしています。
2.4. スクロールイベントの最適化
スクロールイベントは頻繁に発生するため、パフォーマンスを考慮して最適化します。
let scrollTimeout = null;
virtualScroller.addEventListener('scroll', function() {
if (scrollTimeout) return;
scrollTimeout = requestAnimationFrame(() => {
updateVisibleItems();
scrollTimeout = null;
});
}, { passive: true });Code language: JavaScript (javascript)
requestAnimationFrameを使うことで、ブラウザの描画タイミングに合わせて処理を実行し、スムーズなスクロール体験を実現しています。
3. 工夫したポイント
3.1. 効率的なDOM管理
表示範囲内の要素のみをDOMに追加し、範囲外になった要素は削除することで、メモリ使用量を最小限に抑えています。例えば10年分87,600個もの要素のうち、実際にDOMに存在するのは50個程度です。
3.2. 日付ヘッダーの正確な更新
画面最上部に表示されているスロットの日付に基づいて日付ヘッダーを更新しています。これにより、スクロール中も常に正確な日付が表示されます。
const topVisibleSlotIndex = calculateIndexFromPosition(scrollTop);
const { date } = getDateTimeForSlot(topVisibleSlotIndex);
if (!date.getTime || currentVisibleDate.getTime() !== date.getTime()) {
currentVisibleDate = date;
updateDateDisplay(date);
}Code language: JavaScript (javascript)
3.3. バッファサイズの最適化
表示範囲の前後に2日分のバッファを設定することで、高速スクロール時でもスムーズな表示を実現しています。バッファが大きすぎるとメモリ使用量が増え、小さすぎるとスクロール中に空白が表示されることがあるため、適切なサイズを設定することが重要です。
3.4. 絶対位置による配置
すべての要素はposition: absoluteで正確な位置に配置しています。これにより、通常のDOM配置に比べて描画処理が効率化され、スムーズなスクロールが可能になります。
4. まとめ
仮想スクロールを使うと、数万件のデータを持つタイムテーブルでも軽快に動作するアプリを作れます。今回紹介したテクニックは、長いリスト、カレンダー、ニュースフィードなど、大量のデータを表示する多くのアプリケーションで応用できます。
インデックスと位置の相互変換、表示要素の動的管理、スクロールイベントの最適化など、仮想スクロールの基本を理解すれば、さまざまなアプリケーションに活用できるでしょう。
皆さんも是非、仮想スクロールを使って大量データを扱うアプリを作ってみてください!
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>タイムテーブルアプリ</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
background-color: #f5f5f5;
overflow: hidden;
height: 100vh;
position: relative;
}
#app {
height: 100%;
position: relative;
overflow: hidden;
}
#date-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 60px;
background-color: #4285f4;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
opacity: 0;
transition: opacity 0.3s ease;
}
#virtual-scroller {
position: absolute;
top: 60px;
bottom: 40px;
left: 0;
right: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
opacity: 0;
transition: opacity 0.3s ease;
}
#virtual-content {
position: relative;
width: 100%;
}
#viewport {
position: relative;
width: 100%;
}
.time-slot {
background-color: white;
margin: 0 10px 10px 10px;
border-radius: 10px;
padding: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
height: 80px;
position: absolute;
left: 0;
right: 0;
z-index: 1;
}
.time-label {
width: 60px;
text-align: center;
font-weight: bold;
color: #444;
font-size: 16px;
line-height: 60px;
}
.event-container {
flex: 1;
padding-left: 10px;
border-left: 1px solid #eee;
display: flex;
align-items: center;
}
.event {
background-color: rgba(66, 133, 244, 0.1);
border-left: 4px solid #4285f4;
border-radius: 5px;
padding: 10px;
width: 100%;
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
}
.event-title {
font-weight: bold;
margin-bottom: 4px;
}
.event-category {
font-size: 12px;
color: #666;
}
.no-event {
color: #999;
font-style: italic;
}
.date-divider {
background-color: #4285f4;
color: white;
text-align: center;
padding: 5px 0;
font-weight: bold;
margin: 0 10px 10px 10px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
position: absolute;
left: 0;
right: 0;
height: 30px;
}
.instructions {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: #eee;
color: #666;
text-align: center;
padding: 10px;
font-size: 14px;
border-top: 1px solid #ddd;
z-index: 10;
opacity: 0;
transition: opacity 0.3s ease;
}
#virtual-scroller::-webkit-scrollbar {
display: none;
}
#virtual-scroller {
-ms-overflow-style: none;
scrollbar-width: none;
}
#loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
transition: opacity 0.5s ease;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(66, 133, 244, 0.2);
border-radius: 50%;
border-top-color: #4285f4;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="loading">
<div class="spinner"></div>
</div>
<div id="app">
<div id="date-header">日付が表示されます</div>
<div id="virtual-scroller">
<div id="virtual-content">
<div id="viewport">
</div>
</div>
</div>
<div class="instructions">スクロールして時間を変更</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const virtualScroller = document.getElementById('virtual-scroller');
const virtualContent = document.getElementById('virtual-content');
const viewport = document.getElementById('viewport');
const dateHeader = document.getElementById('date-header');
const loading = document.getElementById('loading');
const instructions = document.querySelector('.instructions');
const SLOT_HEIGHT = 80;
const DIVIDER_HEIGHT = 30;
const SLOT_MARGIN = 10;
const BUFFER_SIZE = 48;
const SLOTS_PER_DAY = 24;
const DAY_HEIGHT = (SLOT_HEIGHT + SLOT_MARGIN) * SLOTS_PER_DAY + DIVIDER_HEIGHT;
const today = new Date();
today.setHours(0, 0, 0, 0);
let currentVisibleDate = new Date(today);
const weekdays = ['日', '月', '火', '水', '木', '金', '土'];
const eventCategories = ['会議', '食事', '運動', '仕事', '休憩', '一般予定'];
const dateCache = new Map();
const eventCache = new Map();
const renderedItems = new Map();
function formatDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function getDateForOffset(dayOffset) {
if (!dateCache.has(dayOffset)) {
const date = new Date(today);
date.setDate(date.getDate() + dayOffset);
dateCache.set(dayOffset, date);
}
return dateCache.get(dayOffset);
}
function getEventForSlot(slotIndex) {
if (!eventCache.has(slotIndex)) {
eventCache.set(slotIndex, generateRandomEvent());
}
return eventCache.get(slotIndex);
}
function getDateTimeForSlot(slotIndex) {
const dayOffset = Math.floor(slotIndex / SLOTS_PER_DAY);
const hour = slotIndex % SLOTS_PER_DAY;
const date = getDateForOffset(dayOffset);
return { date, hour };
}
function updateDateDisplay(date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const weekday = weekdays[date.getDay()];
dateHeader.textContent = `${year}年${month}月${day}日(${weekday})`;
}
function generateRandomEvent() {
if (Math.random() < 0.6) {
const category = eventCategories[Math.floor(Math.random() * eventCategories.length)];
const title = `${category}:${Math.floor(Math.random() * 100) + 1}`;
return { title, category };
}
return null;
}
function createDateDivider(date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const weekday = weekdays[date.getDay()];
const divider = document.createElement('div');
divider.className = 'date-divider';
divider.textContent = `${year}年${month}月${day}日(${weekday})`;
divider.dataset.date = formatDate(date);
return divider;
}
function createTimeSlot(slotIndex) {
const { date, hour } = getDateTimeForSlot(slotIndex);
const event = getEventForSlot(slotIndex);
const timeSlot = document.createElement('div');
timeSlot.className = 'time-slot';
timeSlot.dataset.date = formatDate(date);
timeSlot.dataset.hour = hour;
timeSlot.dataset.slotIndex = slotIndex;
const timeLabel = document.createElement('div');
timeLabel.className = 'time-label';
timeLabel.textContent = `${hour}:00`;
const eventContainer = document.createElement('div');
eventContainer.className = 'event-container';
if (event) {
const eventElement = document.createElement('div');
eventElement.className = 'event';
const titleElement = document.createElement('div');
titleElement.className = 'event-title';
titleElement.textContent = event.title;
const categoryElement = document.createElement('div');
categoryElement.className = 'event-category';
categoryElement.textContent = event.category;
eventElement.appendChild(titleElement);
eventElement.appendChild(categoryElement);
eventContainer.appendChild(eventElement);
} else {
const noEventElement = document.createElement('div');
noEventElement.className = 'no-event';
noEventElement.textContent = '予定なし';
eventContainer.appendChild(noEventElement);
}
timeSlot.appendChild(timeLabel);
timeSlot.appendChild(eventContainer);
return timeSlot;
}
function calculatePositionForIndex(slotIndex) {
const dayOffset = Math.floor(slotIndex / SLOTS_PER_DAY);
const hourOffset = slotIndex % SLOTS_PER_DAY;
return dayOffset * DAY_HEIGHT + hourOffset * (SLOT_HEIGHT + SLOT_MARGIN) + DIVIDER_HEIGHT;
}
function calculateDividerPosition(dayOffset) {
return dayOffset * DAY_HEIGHT;
}
function calculateIndexFromPosition(position) {
const dayOffset = Math.floor(position / DAY_HEIGHT);
const positionInDay = position % DAY_HEIGHT;
if (positionInDay < DIVIDER_HEIGHT) {
return (dayOffset * SLOTS_PER_DAY) - 1;
}
const hourOffset = Math.floor((positionInDay - DIVIDER_HEIGHT) / (SLOT_HEIGHT + SLOT_MARGIN));
return dayOffset * SLOTS_PER_DAY + Math.min(hourOffset, SLOTS_PER_DAY - 1);
}
function positionItemInViewport(element, position) {
element.style.top = `${position}px`;
}
function updateVisibleItems() {
const scrollTop = virtualScroller.scrollTop;
const visibleHeight = virtualScroller.clientHeight;
const topPosition = Math.max(0, scrollTop - (BUFFER_SIZE / 2) * (SLOT_HEIGHT + SLOT_MARGIN));
const bottomPosition = scrollTop + visibleHeight + (BUFFER_SIZE / 2) * (SLOT_HEIGHT + SLOT_MARGIN);
const startSlotIndex = calculateIndexFromPosition(topPosition);
const endSlotIndex = calculateIndexFromPosition(bottomPosition);
const topVisibleSlotIndex = calculateIndexFromPosition(scrollTop);
const { date } = getDateTimeForSlot(topVisibleSlotIndex);
if (!date.getTime || currentVisibleDate.getTime() !== date.getTime()) {
currentVisibleDate = date;
updateDateDisplay(date);
}
renderedItems.forEach((element, key) => {
const isSlot = key.startsWith('slot-');
const isDivider = key.startsWith('divider-');
if (!isSlot && !isDivider) return;
let slotIndex;
if (isSlot) {
slotIndex = parseInt(key.substring(5), 10);
} else {
const dayOffset = parseInt(key.substring(8), 10);
slotIndex = dayOffset * SLOTS_PER_DAY;
}
if (slotIndex < startSlotIndex - SLOTS_PER_DAY || slotIndex > endSlotIndex + SLOTS_PER_DAY) {
element.remove();
renderedItems.delete(key);
}
});
for (let i = Math.floor(startSlotIndex / SLOTS_PER_DAY); i <= Math.floor(endSlotIndex / SLOTS_PER_DAY); i++) {
const dividerKey = `divider-${i}`;
if (!renderedItems.has(dividerKey)) {
const date = getDateForOffset(i);
const divider = createDateDivider(date);
const position = calculateDividerPosition(i);
positionItemInViewport(divider, position);
viewport.appendChild(divider);
renderedItems.set(dividerKey, divider);
}
}
for (let i = startSlotIndex; i <= endSlotIndex; i++) {
if (i < 0) continue;
const slotKey = `slot-${i}`;
if (!renderedItems.has(slotKey)) {
const timeSlot = createTimeSlot(i);
const position = calculatePositionForIndex(i);
positionItemInViewport(timeSlot, position);
viewport.appendChild(timeSlot);
renderedItems.set(slotKey, timeSlot);
}
}
}
function setVirtualContentSize() {
const totalDays = 365 * 10;
const contentHeight = totalDays * DAY_HEIGHT;
virtualContent.style.height = `${contentHeight}px`;
viewport.style.width = `${virtualScroller.clientWidth}px`;
const initialDayOffset = 1825;
const todayHour = today.getHours();
const initialSlotIndex = initialDayOffset * SLOTS_PER_DAY + todayHour;
const initialScrollTop = calculatePositionForIndex(initialSlotIndex) - Math.floor(virtualScroller.clientHeight / 2);
virtualScroller.scrollTop = initialScrollTop;
}
function initialize() {
updateDateDisplay(today);
currentVisibleDate = new Date(today);
setVirtualContentSize();
updateVisibleItems();
let scrollTimeout = null;
virtualScroller.addEventListener('scroll', function() {
if (scrollTimeout) return;
scrollTimeout = requestAnimationFrame(() => {
updateVisibleItems();
scrollTimeout = null;
});
}, { passive: true });
window.addEventListener('resize', function() {
viewport.style.width = `${virtualScroller.clientWidth}px`;
updateVisibleItems();
});
setTimeout(() => {
dateHeader.style.opacity = '1';
virtualScroller.style.opacity = '1';
instructions.style.opacity = '1';
loading.style.opacity = '0';
setTimeout(() => {
loading.style.display = 'none';
}, 500);
}, 300);
}
initialize();
});
</script>
</body>
</html>Code language: HTML, XML (xml)
show less