Rolagem infinita

Essa implementação de rolagem infinita foi projetada para garantir que nunca haja mudanças de layout, independente de quanto tempo o servidor leva para responder com novo conteúdo.

Um dos problemas mais comuns com muitas implementações de rolagem infinita é que o rodapé da página (ou elemento de UX semelhante) é empurrado para baixo na página sempre que novos itens são adicionados. Com essa implementação de rolagem infinita, isso nunca ocorre.

Abordagem geral

Sempre que possível, novos itens são inseridos na página antes que o usuário os alcance. Como essa inserção acontece fora da tela (e não fica visível para o usuário), o usuário não muda de layout.

Caso o novo conteúdo não possa ser inserido a tempo, um botão "Mostrar mais" vai aparecer. No entanto, o botão só será ativado quando novos itens estiverem prontos para exibição. Isso garante que o usuário não clique no botão apenas para descobrir que nada acontece. Assim, independentemente da velocidade de resposta do servidor com o novo conteúdo (ou da rolagem do usuário), nunca haverá mudanças de layout inesperadas.

Implementação

A API Intersection Observer é uma maneira eficiente de monitorar a posição e a visibilidade dos elementos da página. Esse design é implementado usando dois observadores de interserção separados:

  • O listObserver observa a posição do #infinite-scroll-button que está localizado no final da lista de rolagem infinita. Quando o botão está se aproximando da janela de visualização, o conteúdo não inserido é adicionado ao DOM.
  • sentinelObserver observa a posição do elemento #sentinel. Quando o sentinelo fica visível, mais conteúdo é solicitado ao servidor. Ajustar a posição da sentinela é uma maneira de controlar com que antecedência um novo conteúdo deve ser solicitado ao servidor.

Essa não é a única maneira de lidar com mudanças de layout decorrentes do uso da rolagem infinita. Outras maneiras de abordar esse problema incluem alternar para paginação, usar virtualização de lista e ajustar layouts de página.

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