Scrolling tanpa batas

Implementasi scroll tanpa batas ini dirancang untuk memastikan tidak pernah ada pergeseran tata letak, terlepas dari waktu yang diperlukan server untuk merespons dengan konten baru.

Salah satu masalah paling umum pada banyak penerapan scrolling tanpa batas adalah bahwa footer halaman (atau elemen UX serupa) didorong jauh ke bawah halaman setiap kali item baru ditambahkan. Dengan implementasi scroll tanpa batas ini, hal ini tidak akan terjadi.

Pendekatan tingkat tinggi

Jika memungkinkan, item baru akan disisipkan ke halaman sebelum pengguna menjangkaunya. Karena penyisipan ini terjadi di luar layar (dan tidak terlihat oleh pengguna), pengguna tidak akan mengalami pergeseran tata letak.

Jika konten baru tidak dapat disisipkan tepat waktu, tombol "Tampilkan Selengkapnya" akan ditampilkan. Namun, tombol ini hanya diaktifkan jika item baru siap ditampilkan - hal ini memastikan bahwa pengguna tidak mengklik tombol hanya untuk menemukan bahwa tidak ada yang terjadi. Jadi, terlepas dari seberapa lambat server merespons dengan konten baru (atau seberapa cepat pengguna men-scroll), tidak akan pernah ada pergeseran tata letak yang tidak terduga.

Penerapan

Intersection Observer API adalah cara berperforma tinggi untuk memantau posisi dan visibilitas elemen halaman. Desain ini diimplementasikan menggunakan dua observer sisipan:

  • listObserver mengamati posisi #infinite-scroll-button yang terletak di akhir daftar scroll tanpa batas. Saat tombol mendekati area pandang, konten yang tidak disisipkan akan ditambahkan ke DOM.
  • sentinelObserver mengamati posisi elemen #sentinel. Saat sentinel terlihat, lebih banyak konten akan diminta dari server. Menyesuaikan posisi sentinel adalah cara untuk mengontrol seberapa awal konten baru harus diminta dari server.

Ini bukan satu-satunya cara untuk mengatasi pergeseran tata letak yang berasal dari penggunaan scrolling tanpa batas. Cara lain untuk mengatasi masalah ini termasuk beralih ke penomoran halaman, menggunakan virtualisasi daftar, dan menyesuaikan tata letak halaman.

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