이 무한 스크롤 구현은 서버가 새 콘텐츠로 응답하는 데 걸리는 시간에 관계없이 레이아웃 변경이 발생하지 않도록 설계되었습니다.
많은 무한 스크롤 구현에서 발생하는 가장 일반적인 문제 중 하나는 새 항목이 추가될 때마다 페이지 바닥글 (또는 유사한 UX 요소)이 페이지 아래로 더 푸시되는 것입니다. 이 무한 스크롤 구현을 사용하면 이러한 일이 발생하지 않습니다.
대략적인 접근 방식
가능한 경우 사용자가 새 항목에 도달하기 전에 페이지에 새 항목이 삽입됩니다. 이러한 삽입은 화면 밖에서 이루어지고 사용자에게 표시되지 않으므로 사용자에게 레이아웃이 변경되지 않습니다.
새 콘텐츠를 제때 삽입할 수 없는 경우 '더보기' 버튼이 대신 표시됩니다. 하지만 버튼은 새 항목을 표시할 준비가 된 경우에만 사용 설정됩니다. 이렇게 하면 사용자가 아무 일도 일어나지 않을 때만 버튼을 클릭하지 않습니다. 따라서 서버가 새 콘텐츠로 얼마나 느리게 응답하는지 (또는 사용자가 얼마나 빨리 스크롤하든) 예기치 않은 레이아웃 변경은 절대 발생하지 않습니다.
구현
Intersection Observer API는 페이지 요소의 위치와 공개 상태를 모니터링하는 효율적인 방법입니다. 이 설계는 두 개의 개별 인터설션 관찰자를 사용하여 구현됩니다.
listObserver
는 무한 스크롤 목록의 끝에 있는#infinite-scroll-button
의 위치를 관찰합니다. 버튼이 표시 영역에 가까워지면 삽입되지 않은 콘텐츠가 DOM에 추가됩니다.sentinelObserver
는#sentinel
요소의 위치를 관찰합니다. 센티널이 표시되면 서버에서 더 많은 콘텐츠가 요청됩니다. 센티널의 위치를 조정하면 서버에서 새 콘텐츠를 얼마나 미리 요청해야 하는지를 제어할 수 있습니다.
이것이 무한 스크롤 사용으로 인한 레이아웃 변경 문제를 해결하는 유일한 방법은 아닙니다. 이 문제에 접근하는 다른 방법으로는 페이지로 나누기로 전환, 목록 가상화 사용, 페이지 레이아웃 조정이 있습니다.
HTML
<div id="infinite-scroll-container">
<div id="sentinel"></div>
<div class="item">A</div>
<div class="item">B</div>
<div class="item">C</div>
<div class="item">D</div>
<div class="item">E</div>
<button id="infinite-scroll-button" disabled>
<span class="disabled-text">Loading more items...</span>
<span class="active-text">Show more</span>
</button>
</div>
CSS
:root {
--active-button-primary: #0080ff;
--active-button-font:#ffffff;
--disabled-button-primary: #f5f5f5;
--disabled-button-secondary: #c4c4c4;
--disabled-button-font: #000000;
}
#infinite-scroll-container {
position: relative;
}
#sentinel {
position: absolute;
bottom: 150vh;
}
#infinite-scroll-button {
cursor: pointer;
border: none;
padding: 1em;
width: 100%;
font-size: 1em;
}
#infinite-scroll-button:enabled {
color: var(--active-button-font);
background-color: var(--active-button-primary)
}
#infinite-scroll-button:disabled {
color: var(--disabled-button-font);
background-color: var(--disabled-button-primary);
cursor: not-allowed;
animation: 3s ease-in-out infinite loadingAnimation;
}
#infinite-scroll-button:enabled .disabled-text {
display: none;
}
#infinite-scroll-button:disabled .active-text {
display: none;
}
@keyframes loadingAnimation {
0% {
background-color: var(--disabled-button-primary);
}
50% {
background-color: var(--disabled-button-secondary);
}
100% {
background-color: var(--disabled-button-primary);
}
}
JS
function infiniteScroll() {
let responseBuffer = [];
let hasMore;
let requestPending = false;
const loadingButtonEl = document.querySelector('#infinite-scroll-button');
const containerEl = document.querySelector('#infinite-scroll-container');
const sentinelEl = document.querySelector("#sentinel");
const insertNewItems = () => {
while (responseBuffer.length > 0) {
const data = responseBuffer.shift();
const el = document.createElement("div");
el.textContent = data;
el.classList.add("item");
el.classList.add("new");
containerEl.insertBefore(el, loadingButtonEl);
console.log(`inserted: ${data}`);
}
sentinelObserver.observe(sentinelEl);
if (hasMore === false) {
loadingButtonEl.style = "display: none";
sentinelObserver.unobserve(sentinelEl);
listObserver.unobserve(loadingButtonEl);
}
loadingButtonEl.disabled = true
}
loadingButtonEl.addEventListener("click", insertNewItems);
const requestHandler = () => {
if (requestPending) return;
console.log("making request");
requestPending = true;
fakeServer.fakeRequest().then((response) => {
console.log("server response", response);
requestPending = false;
responseBuffer = responseBuffer.concat(response.items);
hasMore = response.hasMore;
loadingButtonEl.disabled = false;;
});
}
const sentinelObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0) {
observer.unobserve(sentinelEl);
requestHandler();
}
});
});
const listObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0 && entry.intersectionRatio < 1) {
insertNewItems();
}
});
}, {
rootMargin: "0px 0px 200px 0px"
});
sentinelObserver.observe(sentinelEl);
listObserver.observe(loadingButtonEl);
}
infiniteScroll();