ASP.NET Core 6.0 - Lazy Load On Scroll
This article describes the implementation of lazy loading long comment and member lists. I will assume you have downloaded the ASP.NET Core 6.0 - Users With Comments Project.
Users With Comments Project and Article Series
The ASP.NET Core 6.0 - Users With Comments Project (UWCP) implements public member profiles and a moderated comment workflow from user submission to admin publication. I started with the ASP.NET Core 6.0 - Users With Device 2FA Project (UWD2FAP) which implements WebAuthn, also known as FIDO2, instead of authenticator apps. The latest version of the UWCP is published at Preview. KenHaggerty. Com. I encourage you to register and submit a comment. Details, screenshots, and related articles can be found at ASP.NET Core 6.0 - Users With Comments Project. The details page includes the version change log.
- ASP.NET Core 6.0 - Users With Comments
- ASP.NET Core 6.0 - API Implementation
- ASP.NET Core 6.0 - API Authorization
- ASP.NET Core 6.0 - Member Profile
- ASP.NET Core 6.0 - Profile Image Control
- ASP.NET Core 6.0 - Comments Workflow
- ASP.NET Core 6.0 - Filtered Comments
- ASP.NET Core 6.0 - Member Listings
- ASP.NET Core 6.0 - Lazy Load On Scroll
Visual Studio 2022 (VS 2022) is required to develop .NET 6 and ASP.NET Core 6.0 applications. .NET 6 and ASP.NET Core 6.0 are Long Term Support (LTS) releases and will be supported until November 08, 2024. .NET 5 is a Current release and will be supported until May 08, 2022. .NET 3.1 is a LTS release and will be supported until December 3, 2022. See .NET and .NET Core release lifecycle.
Large comment and member lists can occupy too much space and can take too long to load. The UWCP implements lazy loading lists with a scrollable div. The scrollable div is styled with css.
div.scrollable-primary-35 { max-height: 35rem; overflow-x: hidden; overflow-y: auto; padding: 0 0.5rem; margin: 0.25rem 0; /* Works on Firefox - https://www.digitalocean.com/community/tutorials/css-scrollbars */ scrollbar-width: thin; scrollbar-color: var(--bs-primary) rgba(var(--bs-primary-rgb), 0.2); } /* Works on Chrome, Edge, and Safari */ div.scrollable-primary-35::-webkit-scrollbar { width: 1rem; } div.scrollable-primary-35::-webkit-scrollbar-track { background: rgba(var(--bs-primary-rgb), 0.2); border-radius: 1rem; } div.scrollable-primary-35::-webkit-scrollbar-thumb { background: var(--bs-primary); border-radius: 1rem; border: 0.2rem solid var(--bs-white); }
@model int; // EntityId <div class="row comments-row justify-content-center pt-1"> <div class="col-xl-10 col-xxl-8"> <i class="bi bi-collection bg-primary text-white px-2 py-1 fs-4"></i> <h4 class="comment-count d-inline pt-1 px-2">Comments(0)</h4> <div class="comments-container scrollable-primary-35 mt-2 border-bottom border-primary" > <div class="loading-comments"> <div class="d-flex spinner-border text-primary fs-2 mx-auto my-5" style="width: 5rem; height: 5rem;" role="status"> <span class="visually-hidden">Loading...</span> </div> </div> <div class="loaded-comments d-none"> </div> </div> </div> </div>
The MemberFetchThreshold and CommentFetchThreshold in AppSettings.cs set the max record count retrieved per request.
namespace UsersWithComments; /// <summary> /// Project settings. /// </summary> public static class AppSettings { ... /// <summary> /// The max count per fetch for long member lists. /// </summary> public static readonly int MemberFetchThreshold = 50; ... /// <summary> /// The max count per fetch for long comment lists. /// </summary> public static readonly int CommentFetchThreshold = 20; ... }
The public comments partial calls the CommentsController's Get route on page load.
function getPublicComments() { let fetchUrl = '@($"{AppSettings.CommentsApiUrlBase}{AppSettings.CommentsGetPublicRoute}")'; fetchUrl += '?entityId=@Model'; return fetch(fetchUrl, { method: 'get', credentials: 'same-origin', headers: { 'Content-Type': 'application/json;charset=UTF-8' } }) .then(function(response) { if (response.ok) return response.text(); else throw Error('Response Not OK'); }) .then(function(text) { try { return JSON.parse(text); } catch (err) { throw Error(err); } }) .then(function(responseJSON) { console.log(JSON.stringify(responseJSON)); if (responseJSON.Status == 'Success') { imageTagWidth = responseJSON.ImageTagWidth; dateTimeFormat = responseJSON.DateTimeFormat; totalCommentCount = responseJSON.TotalCommentCount; document.querySelector('.comment-count').textContent = 'Comments(' + totalCommentCount + ')'; if (responseJSON.CommentList.length > 0) { loadCommentList(responseJSON.CommentList); loadedComments.classList.remove('d-none'); loadedCommentCount = loadedComments.childElementCount; fetchDisabled = totalCommentCount == loadedCommentCount; if (!fetchDisabled) commentObserver.observe(loadedComments.querySelector(`div:nth-child(${loadedCommentCount - nextFetchBufferCount})`)); if (location.hash.length > 0 && location.hash.startsWith('#commentId-')) { let hashAnchor = document.querySelector(`[id='${location.hash.substring(1)}']`); if (hashAnchor) { let hashButton = hashAnchor.nextElementSibling; hashButton.classList.remove('d-none'); window.scrollTo({ top: commentsRow.offsetTop, behavior: "smooth" }); setTimeout(function() { scrollParentToChild(loadedComments.parentElement, hashButton.parentElement); }, 500); } else if (!fetchDisabled) { fetchDisabled = true; getNextCommentGroup(); } } } loadingComments.classList.add('d-none'); } else showMessageModal(responseJSON.Errors, 'alert-danger'); }).catch(function(error) { showMessageModal(error, 'alert-danger') }); }
The fetch returns the imageTagWidth, dateTimeFormat, and totalCommentCount along with the list of comments. The imageTagWidth and dateTimeFormat are used to format the list with the loadCommentList function. If the CommentList count equals the totalCommentCount, I set a fetchDisabled variable to true. The prototype implemented a getNextGroup() function on the scrollable div's onscroll event. Research found the Intersection Observer API. See MDN - Intersection Observer API. The UWCP implements the Intersection Observer API with the scrollable div as the root.
function commentObserverCallback(entries, observer) { entries.forEach(entry => { if (entry.isIntersecting && !fetchDisabled) { observer.unobserve(entry.target); fetchDisabled = true; getNextCommentGroup(); } }); } let commentObserverOptions = { root: document.querySelector('.comments-container'), rootMargin: '0px', threshold: 0 } let commentObserver = new IntersectionObserver(commentObserverCallback, commentObserverOptions);
If fetchDisabled is false, the commentObserver observes the comment which is half of the CommentFetchThreshold from the last.
let nextFetchBufferCount = Math.round(@(AppSettings.CommentFetchThreshold / 2));
When the observed comment becomes visible or intersects the root, the commentObserverCallback is invoked. If fetchDisabled is false, the comment is unobserved, fetchDisabled is set true, and the getNextCommentGroup function is called. The getNextCommentGroup function calls the CommentsController's GetNextGroup route.
function getNextCommentGroup() { let fetchUrl = '@($"{AppSettings.CommentsApiUrlBase}{AppSettings.CommentsGetNextPublicGroupRoute}")'; fetchUrl += `?entityId=${@Model}¤tCount=${loadedComments.childElementCount}`; return fetch(fetchUrl, { method: 'get', credentials: 'same-origin', headers: { 'Content-Type': 'application/json;charset=UTF-8' } }) .then(function(response) { if (response.ok) return response.text(); else throw Error('Response Not OK'); }) .then(function(text) { try { return JSON.parse(text); } catch (err) { throw Error(err); } }) .then(function(responseJSON) { console.log(JSON.stringify(responseJSON)); if (responseJSON.Status == 'Success') { if (responseJSON.CommentList.length > 0) { loadCommentList(responseJSON.CommentList); loadedCommentCount = loadedComments.childElementCount; fetchDisabled = totalCommentCount == loadedCommentCount; if (!fetchDisabled) commentObserver.observe(loadedComments.querySelector(`div:nth-child(${loadedCommentCount - nextFetchBufferCount})`)); if (location.hash.length > 0 && location.hash.startsWith('#commentId-')) { let hashAnchor = document.querySelector(`[id='${location.hash.substring(1)}']`); if (hashAnchor) { let hashButton = hashAnchor.nextElementSibling; hashButton.classList.remove('d-none'); window.scrollTo({ top: commentsRow.offsetTop, behavior: "smooth" }); setTimeout(function() { scrollParentToChild(loadedComments.parentElement, hashButton.parentElement); }, 500); } else if (!fetchDisabled) { fetchDisabled = true; getNextCommentGroup(); } } } } else showMessageModal(responseJSON.Errors, 'alert-danger'); }).catch(function(error) { showMessageModal(error, 'alert-danger') }); }
The fetch returns the next group of comments. If the loadedCommentCount equals the totalCommentCount, I set fetchDisabled to true else I set the commentObserver to observe the new comment which is half of the CommentFetchThreshold from the last.
The comment approved email includes a link with a comment id hash.
If the comment id hash is set and the comment is not found, the getNextCommentGroup function is called until found. When the comment is loaded, the top of the comments is scrolled to the top of the window and the scrollParentToChild function sets the scrollable div to display the complete comment. See StackOverflow - Plain JavaScript - ScrollIntoView inside Div.
function scrollParentToChild(parent, child) { // Where is the parent on page var parentRect = parent.getBoundingClientRect(); // What can you see? var parentViewableArea = { height: parent.clientHeight, width: parent.clientWidth }; // Where is the child var childRect = child.getBoundingClientRect(); // Is the child viewable? var isViewable = (childRect.top >= parentRect.top) && (childRect.bottom <= parentRect.top + parentViewableArea.height); // if you can't see the child try to scroll parent if (!isViewable) { // Should we scroll using top or bottom? Find the smaller ABS adjustment const scrollTop = childRect.top - parentRect.top; const scrollBot = childRect.bottom - parentRect.bottom; if (Math.abs(scrollTop) < Math.abs(scrollBot)) { // we're near the top of the list parent.scrollTop += scrollTop; } else { // we're near the bottom of the list parent.scrollTop += scrollBot; } } }
References:
- DigitalOcean - How To Style Scrollbars with CSS
- MDN - CSS - scrollbar-width
- StackOverflow - How to listen to scroll event on a scrollable element?
- MDN - Intersection Observer API
- bobbyhadz - Get the nth child of an Element using JavaScript
- StackOverflow - Plain JavaScript - ScrollIntoView inside Div
Comments(0)