Skip to content

Commit

Permalink
[KED-1923] Add LazyList component using IntersectionObserver on sideb…
Browse files Browse the repository at this point in the history
…ar (#307)

* [KED-1923] Refactor and memoise NodeListRow

* [KED-1923] Add LazyList component

* [KED-1923] Refactor NodeListGroup, NodeListGroups using LazyList

* [KED-1923] Add fade effect to placeholders on lazy lists

* [KED-1923] Add comments and docs to LazyList

* [KED-1923] Fix placeholder fade on node list

* [KED-1923] Add viewport clipping to LazyList

* [KED-1923] Refactor into NodeRowList

* [KED-1923] Add notes on row height

* [KED-1923] Add flag for lazy sidebar features

* Use CSS  custom properties for gradient theming

* [KED-1923] Fix tests for node-list after refactor

* [KED-1923] Add tests for LazyList

* [KED-1923] Simplify LazyList clipping

* [KED-1923] Improve tests for LazyList

* [KED-1923] Update version for lazy flag in readme

* [KED-1923] Fix LazyList tests for CI

* [KED-1923] Fix app tests

* [KED-1923] Add comments to LazyList about behaviour when not supported

* [KED-1923] Move changed utility to shared module

Co-authored-by: Richard Westenra <[email protected]>
  • Loading branch information
bru5 and richardwestenra authored Dec 14, 2020
1 parent eb0b83c commit 2edbddd
Show file tree
Hide file tree
Showing 14 changed files with 917 additions and 162 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ As a JavaScript React component, the project is designed to be used in two diffe
The following flags are available to toggle experimental features:

- `newgraph` - From release v3.4.0. Improved graphing algorithm. (default `false`)
- `lazy` - From release v3.8.0. Improved sidebar performance. (default `false`)

### Setting flags

Expand Down
328 changes: 328 additions & 0 deletions src/components/lazy-list/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import { useState, useLayoutEffect, useRef, useMemo, useCallback } from 'react';

/**
* A component that renders only the children currently visible on screen.
* Renders all children if not supported by browser or is disabled via the `lazy` prop.
* @param {function} height A `function(start, end)` returning the pixel height for any given range of items
* @param {number} total The total count of all items in the list
* @param {function} children A `function(props)` rendering the list and items (see `childProps`)
* @param {?number} [buffer=0.5] A number [0...1] as a % of the visible region to render additionally
* @param {?boolean} [lazy=true] Toggles the lazy functionality
* @param {?boolean} [dispose=false] Toggles disposing items when they lose visibility
* @param {?function} onChange Optional change callback
* @param {?function} container Optional, default scroll container is `element.offsetParent`
* @return {object} The rendered children
**/
export default ({
height,
total,
children,
lazy = true,
dispose = false,
buffer = 0.5,
onChange,
container = element => element?.offsetParent
}) => {
// Required browser feature checks
const supported = typeof window.IntersectionObserver !== 'undefined';

// Active only if supported and enabled else renders all children
const active = lazy && supported;

// The range of items currently rendered
const [range, setRange] = useState([0, 0]);
const rangeRef = useRef([0, 0]);

// List container element
const listRef = useRef();

// Upper placeholder element
const upperRef = useRef();

// Lower placeholder element
const lowerRef = useRef();

// Height of a single item
const itemHeight = useMemo(() => height(0, 1), [height]);

// Height of all items
const totalHeight = useMemo(() => height(0, total), [height, total]);

// Height of items above the rendered range
const upperHeight = useMemo(() => height(0, range[0]), [height, range]);

// Height of items below the rendered range
const lowerHeight = useMemo(() => height(range[1], total), [
height,
range,
total
]);

// Skipped if not enabled or supported
if (active) {
// Allows an update only once per frame
const requestUpdate = useRequestFrameOnce(
// Memoise the frame callback
useCallback(() => {
// Get the range of items visible in this frame
const visibleRange = visibleRangeOf(
// The list container
listRef.current,
// The list's scrolling parent container
container(listRef.current),
buffer,
total,
itemHeight
);

// Merge ranges
const effectiveRange =
// If dispose, render visible range only
dispose
? visibleRange
: // If not dispose, expand current range with visible range
rangeUnion(rangeRef.current, visibleRange);

// Avoid duplicate render calls as state is not set immediate
if (!rangeEqual(rangeRef.current, effectiveRange)) {
// Store the update in a ref immediately
rangeRef.current = effectiveRange;

// Apply the update in the next render
setRange(effectiveRange);
}
}, [buffer, total, itemHeight, dispose, container])
);

// Memoised observer options
const observerOptions = useMemo(
() => ({
// Create a threshold point for every item
threshold: thresholds(total)
}),
[total]
);

// Updates on changes in visibility at the given thresholds (intersection ratios)
useIntersection(listRef, observerOptions, requestUpdate);
useIntersection(upperRef, observerOptions, requestUpdate);
useIntersection(lowerRef, observerOptions, requestUpdate);

// Updates on changes in item dimensions
useLayoutEffect(() => requestUpdate(), [
total,
itemHeight,
totalHeight,
requestUpdate
]);
}

// Memoised child props for user to apply as needed
const childProps = useMemo(
() => ({
listRef,
upperRef,
lowerRef,
total,
start: active ? range[0] : 0,
end: active ? range[1] : total,
listStyle: {
// Relative for placeholder positioning
position: 'relative',
// List must always have the correct height
height: active ? totalHeight : undefined,
// List must always pad missing items (upper at least)
paddingTop: active ? upperHeight : undefined
},
upperStyle: {
position: 'absolute',
display: !active ? 'none' : undefined,
height: upperHeight,
width: '100%',
// Upper placeholder must always snap to top edge
top: '0'
},
lowerStyle: {
position: 'absolute',
display: !active ? 'none' : undefined,
height: lowerHeight,
width: '100%',
// Lower placeholder must always snap to bottom edge
bottom: '0'
}
}),
[
active,
range,
total,
listRef,
upperRef,
lowerRef,
totalHeight,
upperHeight,
lowerHeight
]
);

// Optional change callback
onChange && onChange(childProps);

// Render the children
return children(childProps);
};

/**
* Returns a range in the form `[start, end]` clamped inside `[min, max]`
* @param {number} start The start of the range
* @param {number} end The end of the range
* @param {number} min The range minimum
* @param {number} max The range maximum
* @returns {array} The clamped range
*/
export const range = (start, end, min, max) => [
Math.max(Math.min(start, max), min),
Math.max(Math.min(end, max), min)
];

/**
* Returns the union of both ranges
* @param {array} rangeA The first range `[start, end]`
* @param {array} rangeB The second range `[start, end]`
* @returns {array} The range union
*/
export const rangeUnion = (rangeA, rangeB) => [
Math.min(rangeA[0], rangeB[0]),
Math.max(rangeA[1], rangeB[1])
];

/**
* Returns true if the ranges have the same `start` and `end` values
* @param {array} rangeA The first range `[start, end]`
* @param {array} rangeB The second range `[start, end]`
* @returns {boolean} True if ranges are equal else false
*/
export const rangeEqual = (rangeA, rangeB) =>
rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1];

/**
* Gets the range of items inside the container's screen bounds.
* Assumes a single fixed height for all child items.
* Only considers visibility along the vertical y-axis (i.e. only top, bottom bounds).
* @param {HTMLElement} element The target element (e.g. list container)
* @param {?HTMLElement} container The clipping container of the target (e.g. scroll container)
* @param {number} buffer A number [0...1] as a % of the container to render additionally
* @param {number} childTotal The total count of all children in the target (e.g. list row count)
* @param {number} childHeight Height of a single child element (e.g. height of one list row)
* @returns {array} The calculated range of visible items as `[start, end]`
*/
const visibleRangeOf = (
element,
container,
buffer,
childTotal,
childHeight
) => {
// Check element exists
if (!element) {
return [0, 0];
}

// If no container use the element itself
if (!container) {
container = element;
}

// Find the clipping container bounds (e.g. scroll container)
const clip = container.getBoundingClientRect();

// Find element bounds (e.g. list container inside scroll container)
const list = element.getBoundingClientRect();

// Find the number of items to buffer
const bufferCount = Math.ceil((buffer * clip.height) / childHeight);

// When clip is fully above viewport or element is fully above clip
if (clip.bottom < 0 || list.bottom < clip.top) {
// Only bottom part of the buffer in range
return range(childTotal - bufferCount, childTotal, 0, childTotal);
}

// Get the viewport bounds
const viewport = {
top: 0,
bottom: window.innerHeight || document.documentElement.clientHeight
};

// When clip is fully below viewport or element is fully below clip
if (clip.top > viewport.bottom || list.top > clip.bottom) {
// Only top part of the buffer in range
return range(0, bufferCount, 0, childTotal);
}

// Find intersection of clip and viewport now overlap guaranteed
const top = Math.max(clip.top, viewport.top);
const bottom = Math.min(clip.bottom, viewport.bottom);

// Find unbounded item range within the intersection
const start = Math.floor((top - list.top) / childHeight);
const end = Math.ceil((bottom - list.top) / childHeight);

// Apply buffer and clamp unbounded range to list bounds
return range(start - bufferCount, end + bufferCount, 0, childTotal);
};

/**
* A hook to create a callback that runs once, at the end of the frame
* @param {function} callback The callback
* @returns {function} The wrapped callback
*/
const useRequestFrameOnce = callback => {
const request = useRef();

// Allow only a single callback per-frame
return useCallback(() => {
cancelAnimationFrame(request.current);
request.current = requestAnimationFrame(callback);
}, [request, callback]);
};

/**
* Generates an array of the form [0, ...n / total]
* except where total is `0` where it returns `[0]`.
* @param {number} total The total number of thresholds to create
* @returns {array} The threshold array
*/
export const thresholds = total =>
total === 0
? [0]
: [...Array.from({ length: total }, (_, i) => i / total), 1];

/**
* A hook that creates and manages an IntersectionObserver for the given element
* @param {object} element A React.Ref from the target element
* @param {object} options An IntersectionObserver options object
* @param {function} callback A function to call with IntersectionObserver changes
*/
const useIntersection = (element, options, callback) => {
const observer = useRef();

// After rendering and layout
return useLayoutEffect(() => {
// Check the element is ready
if (!element.current) {
return;
}

// Dispose any previous observer
if (observer.current) {
observer.current.disconnect();
}

// Create a new observer
observer.current = new window.IntersectionObserver(callback, options);
observer.current.observe(element.current);

// Manually callback as element may already be visible
callback();
}, [callback, element, options]);
};
Loading

0 comments on commit 2edbddd

Please sign in to comment.