VirtualScroller: rework for less memory use, faster speeds

This commit is contained in:
Liam Fitzgerald 2021-04-26 17:04:14 +10:00
parent c1f055d46e
commit aaea592cfc
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB

View File

@ -1,4 +1,4 @@
import React, { Component, useCallback } from 'react';
import React, { Component, useCallback, SyntheticEvent } from 'react';
import _ from 'lodash';
import normalizeWheel from 'normalize-wheel';
import bigInt, { BigInteger } from 'big-integer';
@ -76,7 +76,7 @@ interface VirtualScrollerProps<T> {
}
interface VirtualScrollerState<T> {
visibleItems: BigIntOrderedMap<T>;
visibleItems: BigInteger[];
scrollbar: number;
loaded: {
top: boolean;
@ -91,10 +91,9 @@ const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) {
console.log(`[${level}]: ${message}`);
}
}
const ZONE_SIZE = IS_IOS ? 10 : 40;
const ZONE_SIZE = IS_IOS ? 10 : 80;
// nb: in this file, an index refers to a BigInteger and an offset refers to a
@ -114,7 +113,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
/**
* A map of child refs, used to calculate scroll position
*/
private childRefs = new BigIntOrderedMap<HTMLElement>();
private childRefs = new Map<string, HTMLElement>();
/**
* A set of child refs which have been unmounted
*/
@ -149,7 +148,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
constructor(props: VirtualScrollerProps<T>) {
super(props);
this.state = {
visibleItems: new BigIntOrderedMap(),
visibleItems: [],
scrollbar: 0,
loaded: {
top: false,
@ -164,6 +163,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this);
this.restore = this.restore.bind(this);
this.startOffset = this.startOffset.bind(this);
}
componentDidMount() {
@ -181,7 +181,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
[...this.orphans].forEach(o => {
const index = bigInt(o);
this.childRefs.delete(index);
this.childRefs.delete(index.toString());
});
this.orphans.clear();
};
@ -206,13 +206,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
const { id, size, data, offset, pendingSize } = this.props;
const { visibleItems } = this.state;
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
if(this.scrollLocked && visibleItems?.peekLargest() && data?.peekLargest()) {
if(!visibleItems.peekLargest()[0].eq(data.peekLargest()[0])) {
this.updateVisible(0);
}
if(this.scrollLocked) {
this.updateVisible(0);
this.resetScroll();
}
}
@ -228,11 +225,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
startOffset() {
const startIndex = this.state?.visibleItems?.peekLargest()?.[0];
const { data } = this.props;
const startIndex = this.state.visibleItems?.[0];
if(!startIndex) {
return 0;
}
const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex))
const dataList = Array.from(data);
const offset = dataList.findIndex(([i]) => i.eq(startIndex))
if(offset === -1) {
// TODO: revisit when we remove nodes for any other reason than
// pending indices being removed
@ -252,19 +251,17 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
log('reflow', `from: ${this.startOffset()} to: ${newOffset}`);
const { data, onCalculateVisibleItems } = this.props;
const visibleItems = new BigIntOrderedMap<any>(
[...data].slice(newOffset, newOffset + this.pageSize)
);
const visibleItems = data.keys().slice(newOffset, newOffset + this.pageSize);
this.save();
this.setState({
visibleItems,
}, () => {
requestAnimationFrame(() => {
this.restore();
});
});
requestAnimationFrame(() => {
this.restore();
});
}
scrollKeyMap(): Map<string, number> {
@ -296,7 +293,6 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
setWindow(element) {
if (!element)
return;
console.log('resetting window');
this.save();
if (this.window) {
@ -309,7 +305,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const { averageHeight } = this.props;
this.window = element;
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 5.5));
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 3));
this.pageDelta = Math.floor(this.pageSize / 3);
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
@ -356,7 +352,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
};
onScroll(event: UIEvent) {
onScroll(event: SyntheticEvent<HTMLElement, ScrollEvent>) {
this.updateScroll();
if(!this.window) {
// bail if we're going to adjust scroll anyway
@ -368,9 +364,11 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
const { onStartReached, onEndReached } = this.props;
const windowHeight = this.window.offsetHeight;
const { scrollTop, scrollHeight } = this.window;
const { scrollTop, scrollHeight } = event.target as HTMLElement;
const startOffset = this.startOffset();
const scrollEnd = scrollTop + windowHeight;
if (scrollTop < ZONE_SIZE) {
log('scroll', `Entered start zone ${scrollTop}`);
if (startOffset === 0) {
@ -423,7 +421,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return;
}
let ref = this.childRefs.get(this.savedIndex)
let ref = this.childRefs.get(this.savedIndex.toString())
if(!ref) {
return;
}
@ -438,7 +436,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
scrollToIndex = (index: BigInteger) => {
let ref = this.childRefs.get(index);
let ref = this.childRefs.get(index.toString());
if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index));
if(offset === -1) {
@ -446,7 +444,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => {
ref = this.childRefs.get(index);
ref = this.childRefs.get(index.toString());
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
@ -467,17 +465,18 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return;
}
if(this.saveDepth !== 0) {
console.log('bail', 'deep save');
return;
}
log('scroll', 'saving...');
this.saveDepth++;
let bottomIndex: BigInteger | null = null;
const { scrollTop, scrollHeight } = this.window;
const topSpacing = scrollHeight - scrollTop;
[...Array.from(this.state.visibleItems)].reverse().forEach(([index, datum]) => {
const el = this.childRefs.get(index);
([...this.state.visibleItems]).reverse().forEach((index) => {
const el = this.childRefs.get(index.toString());
if(!el) {
return;
}
@ -490,11 +489,12 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(!bottomIndex) {
// weird, shouldn't really happen
this.saveDepth--;
log('bail', 'no index found');
return;
}
this.savedIndex = bottomIndex;
const ref = this.childRefs.get(bottomIndex)!;
const ref = this.childRefs.get(bottomIndex.toString())!;
const { offsetTop } = ref;
this.savedDistance = topSpacing - offsetTop
}
@ -503,7 +503,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
setRef = (element: HTMLElement | null, index: BigInteger) => {
if(element) {
this.childRefs.set(index, element);
this.childRefs.set(index.toString(), element);
this.orphans.delete(index.toString());
} else {
this.orphans.add(index.toString());
@ -525,11 +525,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const isTop = origin === 'top';
const indexesToRender = isTop ? visibleItems.keys() : visibleItems.keys().reverse();
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const children = isTop ? visibleItems : [...visibleItems].reverse();
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems.peekLargest()?.[0] || bigInt.zero);
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems?.[0] || bigInt.zero);
const atEnd = this.state.loaded.top;
return (
@ -542,7 +541,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
<LoadingSpinner />
</Center>)}
<VirtualContext.Provider value={this.shiftLayout}>
{indexesToRender.map(index => (
{children.map(index => (
<VirtualChild
key={index.toString()}
setRef={this.setRef}
@ -575,8 +574,10 @@ function VirtualChild(props: VirtualChildProps) {
const ref = useCallback((el: HTMLElement | null) => {
setRef(el, props.index);
}, [setRef, props.index])
// VirtualChild should always be keyed on the index, so the index should be
// valid for the entire lifecycle of the component, hence no dependencies
}, []);
return (<Renderer ref={ref} {...rest} />);
return <Renderer ref={ref} {...rest} />
};