Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[KED-1923] Add LazyList component using IntersectionObserver on sidebar #307

Merged
merged 24 commits into from
Dec 14, 2020
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e95df26
[KED-1923] Refactor and memoise NodeListRow
bru5 Nov 13, 2020
44f9a0f
[KED-1923] Add LazyList component
bru5 Nov 13, 2020
d1be957
[KED-1923] Refactor NodeListGroup, NodeListGroups using LazyList
bru5 Nov 13, 2020
e191e94
[KED-1923] Add fade effect to placeholders on lazy lists
bru5 Nov 13, 2020
8cdba33
[KED-1923] Add comments and docs to LazyList
bru5 Nov 17, 2020
ade568c
[KED-1923] Fix placeholder fade on node list
bru5 Nov 17, 2020
724349e
[KED-1923] Add viewport clipping to LazyList
bru5 Nov 18, 2020
eaa61a6
[KED-1923] Refactor into NodeRowList
bru5 Nov 18, 2020
092cd69
[KED-1923] Add notes on row height
bru5 Nov 18, 2020
45e9ec2
[KED-1923] Add flag for lazy sidebar features
bru5 Nov 18, 2020
29f8071
Merge branch 'main' into feature/lazy-sidebar
richardwestenra Nov 26, 2020
e2b3b1b
Use CSS custom properties for gradient theming
richardwestenra Nov 26, 2020
4b4d34b
[KED-1923] Fix tests for node-list after refactor
bru5 Dec 3, 2020
1696308
[KED-1923] Add tests for LazyList
bru5 Dec 3, 2020
a77e701
[KED-1923] Simplify LazyList clipping
bru5 Dec 7, 2020
307e4e9
[KED-1923] Improve tests for LazyList
bru5 Dec 7, 2020
ecf3f55
Merge origin/feature/lazy-sidebar
bru5 Dec 7, 2020
e6c0643
[KED-1923] Update version for lazy flag in readme
bru5 Dec 8, 2020
9a38263
[KED-1923] Fix LazyList tests for CI
bru5 Dec 11, 2020
17ac26d
[KED-1923] Fix app tests
bru5 Dec 11, 2020
4b55ce1
Merge branch 'main' into feature/lazy-sidebar
bru5 Dec 11, 2020
b6d4b5b
[KED-1923] Add comments to LazyList about behaviour when not supported
bru5 Dec 14, 2020
7ae380a
[KED-1923] Move changed utility to shared module
bru5 Dec 14, 2020
074aea9
Merge branch 'main' into feature/lazy-sidebar
bru5 Dec 14, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ The following flags are available to toggle experimental features:

- `newgraph` - From release v3.4.0. Improved graphing algorithm. (default `false`)
- `meta` - From release v3.7.0. Show node metadata panel on click. (default `false`)
- `lazy` - From release v3.8.0. Improved sidebar performance. (default `false`)

### Setting flags

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

/**
* A component that renders only the children currently visible on screen.
* @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 feature checks
const supported = typeof window.IntersectionObserver !== 'undefined';
bru5 marked this conversation as resolved.
Show resolved Hide resolved

// Active only if enabled by prop and features detected
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
richardwestenra marked this conversation as resolved.
Show resolved Hide resolved
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