From 3fd1c24b4bd3077493c8b5e0e92cecaec745b39a Mon Sep 17 00:00:00 2001 From: James Mockett <1166188+jamesmockett@users.noreply.github.com> Date: Thu, 28 Nov 2024 12:36:20 +0000 Subject: [PATCH] Carousels: Optimise updating button state (#12923) * Toggle button state when card half in or out of view * Throttle scroll events with requestAnimationFrame * Throttle scroll events with timeout as no animation * Add comments to event throttling functions --- .../src/components/ScrollableCarousel.tsx | 32 +++++++++++++---- .../SlideshowCarousel.importable.tsx | 34 ++++++++++++------- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/dotcom-rendering/src/components/ScrollableCarousel.tsx b/dotcom-rendering/src/components/ScrollableCarousel.tsx index 7656f66e15..ea72821dc4 100644 --- a/dotcom-rendering/src/components/ScrollableCarousel.tsx +++ b/dotcom-rendering/src/components/ScrollableCarousel.tsx @@ -288,9 +288,9 @@ export const ScrollableCarousel = ({ * Updates state of navigation buttons based on carousel's scroll position. * * This function checks the current scroll position of the carousel and sets - * the styles of the previous and next buttons accordingly. The previous - * button is disabled if the carousel is at the start, and the next button - * is disabled if the carousel is at the end. + * the styles of the previous and next buttons accordingly. The button state + * is toggled when the midpoint of the first or last card has been scrolled + * in or out of view. */ const updateButtonVisibilityOnScroll = () => { const carouselElement = carouselRef.current; @@ -299,9 +299,27 @@ export const ScrollableCarousel = ({ const scrollLeft = carouselElement.scrollLeft; const maxScrollLeft = carouselElement.scrollWidth - carouselElement.clientWidth; + const cardWidth = carouselElement.querySelector('li')?.offsetWidth ?? 0; - setPreviousButtonEnabled(scrollLeft > 0); - setNextButtonEnabled(scrollLeft < maxScrollLeft); + setPreviousButtonEnabled(scrollLeft > cardWidth / 2); + setNextButtonEnabled(scrollLeft < maxScrollLeft - cardWidth / 2); + }; + + /** + * Throttle scroll events to optimise performance. As we're only using this + * to toggle button state as the carousel is scrolled we don't need to + * handle every event. This function ensures the callback is only called + * once every 200ms, no matter how many scroll events are fired. + */ + const throttleEvent = (callback: () => void) => { + let isThrottled: boolean = false; + return function () { + if (!isThrottled) { + callback(); + isThrottled = true; + setTimeout(() => (isThrottled = false), 200); + } + }; }; useEffect(() => { @@ -310,13 +328,13 @@ export const ScrollableCarousel = ({ carouselElement.addEventListener( 'scroll', - updateButtonVisibilityOnScroll, + throttleEvent(updateButtonVisibilityOnScroll), ); return () => { carouselElement.removeEventListener( 'scroll', - updateButtonVisibilityOnScroll, + throttleEvent(updateButtonVisibilityOnScroll), ); }; }, []); diff --git a/dotcom-rendering/src/components/SlideshowCarousel.importable.tsx b/dotcom-rendering/src/components/SlideshowCarousel.importable.tsx index 2fe1be68b2..544d408add 100644 --- a/dotcom-rendering/src/components/SlideshowCarousel.importable.tsx +++ b/dotcom-rendering/src/components/SlideshowCarousel.importable.tsx @@ -124,26 +124,36 @@ export const SlideshowCarousel = ({ * Updates state of navigation buttons based on carousel's scroll position. * * This function checks the current scroll position of the carousel and sets - * the styles of the previous and next buttons accordingly. The previous - * button is disabled if the carousel is at the start, and the next button - * is disabled if the carousel is at the end. + * the styles of the previous and next buttons accordingly. The button state + * is toggled when the midpoint of the first or last card has been scrolled + * in or out of view. */ const updatePaginationStateOnScroll = () => { const carouselElement = carouselRef.current; if (!carouselElement) return; const scrollLeft = carouselElement.scrollLeft; - const maxScrollLeft = carouselElement.scrollWidth - carouselElement.clientWidth; - - setPreviousButtonEnabled(scrollLeft > 0); - setNextButtonEnabled(scrollLeft < maxScrollLeft); - const cardWidth = carouselElement.querySelector('li')?.offsetWidth ?? 0; - const page = Math.round(scrollLeft / cardWidth); - setCurrentPage(page); + setPreviousButtonEnabled(scrollLeft > cardWidth / 2); + setNextButtonEnabled(scrollLeft < maxScrollLeft - cardWidth / 2); + setCurrentPage(Math.round(scrollLeft / cardWidth)); + }; + + /** + * Throttle scroll events to optimise performance. As the scroll events are + * used to trigger the pagination dot animation we're using + * `requestAnimationFrame` rather than `setTimeout` to ensure this animates + * smoothly in sync with the carousel being scrolled. + */ + const throttleEvent = (callback: () => void) => { + let requestId: number; + return function () { + cancelAnimationFrame(requestId); + requestId = requestAnimationFrame(callback); + }; }; useEffect(() => { @@ -152,13 +162,13 @@ export const SlideshowCarousel = ({ carouselElement.addEventListener( 'scroll', - updatePaginationStateOnScroll, + throttleEvent(updatePaginationStateOnScroll), ); return () => { carouselElement.removeEventListener( 'scroll', - updatePaginationStateOnScroll, + throttleEvent(updatePaginationStateOnScroll), ); }; }, []);