Nieskończone przewijanie

Zastosowanie tego rozwiązania gwarantuje, że nigdy nie dochodzi do żadnych zmian układu, niezależnie od tego, jak długo serwer potrzebuje nowej treści.

Jednym z najczęstszych problemów w wielu implementacjach przewijania nieskończonego jest to, że po dodaniu nowych elementów stopka strony (lub podobny element UX) jest przesuwana w dół strony. Przy takiej implementacji nieskończonego przewijania nic się nie dzieje.

Ogólne podejście

Gdy tylko jest to możliwe, nowe elementy są wstawiane na stronie, zanim użytkownik do nich wejdzie. Wstawianie elementów odbywa się poza ekranem (i nie jest widoczne dla użytkownika), dlatego układ nie ulega zmianie.

Jeśli nie można wstawić nowej treści na czas, zamiast niego wyświetla się przycisk „Pokaż więcej”. Przycisk jest jednak włączony tylko wtedy, gdy nowe elementy są gotowe do wyświetlenia. Dzięki temu użytkownik nie kliknie przycisku tylko i nic się nie stanie. W związku z tym niezależnie od tego, jak szybko serwer odpowiada na nowe treści (czy to, jak szybko użytkownik przewija stronę), nigdy nie nastąpi nieoczekiwane przesunięcie układu.

Implementacja

Interfejs Intersection Observer API to skuteczny sposób monitorowania pozycji i widoczności elementów strony. W tym projekcie zastosowano 2 osobne obserwatorzy intersercji:

  • listObserver obserwuje pozycję elementu #infinite-scroll-button, który znajduje się na końcu listy nieskończonych przewijania. Gdy przycisk znajdzie się w pobliżu widocznego obszaru, niewstawione treści są dodawane do interfejsu DOM.
  • sentinelObserver obserwuje pozycję elementu #sentinel. Gdy celownik staje się widoczny, serwer wysyła więcej żądań treści. Dostosowanie pozycji wskaźnika to sposób na kontrolowanie z wyprzedzeniem, z jakim wyprzedzeniem należy wysyłać żądania nowych treści do serwera.

To nie jedyny sposób rozwiązywania problemów z układem spowodowanym przez nieskończone przewijanie. Inne sposoby rozwiązania tego problemu to między innymi podział na strony, zastosowanie wirtualizacji list oraz dostosowywanie układów stron.

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();