VirtualScroller: change virtualisation method

We now virtualize 'treadmill' style i.e. by rendering a subset of the
list into a window. When the scroll position gets close to an edge, we
save our scroll position, adjust the subset and then restore our scroll
position
This commit is contained in:
Liam Fitzgerald 2021-02-23 16:41:10 +10:00
parent 390a17b706
commit 56337c953b
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
5 changed files with 308 additions and 226 deletions

View File

@ -38,7 +38,7 @@ export function ChatResource(props: ChatResourceProps) {
const canWrite = isWriter(group, station); const canWrite = isWriter(group, station);
useEffect(() => { useEffect(() => {
const count = Math.min(50, unreadCount + 15); const count = 100;
props.api.graph.getNewest(owner, name, count); props.api.graph.getNewest(owner, name, count);
}, [station]); }, [station]);

View File

@ -78,7 +78,6 @@ export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
)); ));
interface ChatMessageProps { interface ChatMessageProps {
measure(element): void;
msg: Post; msg: Post;
previousMsg?: Post; previousMsg?: Post;
nextMsg?: Post; nextMsg?: Post;
@ -96,9 +95,14 @@ interface ChatMessageProps {
api: GlobalApi; api: GlobalApi;
highlighted?: boolean; highlighted?: boolean;
renderSigil?: boolean; renderSigil?: boolean;
innerRef: (el: HTMLDivElement | null) => void;
shiftLayout: {
save: () => void;
restore: () => void;
}
} }
export default class ChatMessage extends Component<ChatMessageProps> { class ChatMessage extends Component<ChatMessageProps> {
private divRef: React.RefObject<HTMLDivElement>; private divRef: React.RefObject<HTMLDivElement>;
constructor(props) { constructor(props) {
@ -107,9 +111,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
} }
componentDidMount() { componentDidMount() {
if (this.divRef.current) {
this.props.measure(this.divRef.current);
}
} }
render() { render() {
@ -124,7 +125,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
className = '', className = '',
isPending, isPending,
style, style,
measure,
scrollWindow, scrollWindow,
isLastMessage, isLastMessage,
unreadMarkerRef, unreadMarkerRef,
@ -132,6 +132,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
api, api,
highlighted, highlighted,
fontSize, fontSize,
shiftLayout,
groups, groups,
associations associations
} = this.props; } = this.props;
@ -157,9 +158,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
.unix(msg['time-sent'] / 1000) .unix(msg['time-sent'] / 1000)
.format(renderSigil ? 'h:mm A' : 'h:mm'); .format(renderSigil ? 'h:mm A' : 'h:mm');
const reboundMeasure = (event) => {
return measure(this.divRef.current);
};
const messageProps = { const messageProps = {
msg, msg,
@ -167,7 +165,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
contacts, contacts,
association, association,
group, group,
measure: reboundMeasure.bind(this),
style, style,
containerClass, containerClass,
isPending, isPending,
@ -177,7 +174,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
highlighted, highlighted,
fontSize, fontSize,
associations, associations,
groups groups,
shiftLayout
}; };
const unreadContainerStyle = { const unreadContainerStyle = {
@ -186,7 +184,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
return ( return (
<Box <Box
ref={this.divRef} ref={this.props.innerRef}
pt={renderSigil ? 2 : 0} pt={renderSigil ? 2 : 0}
pb={isLastMessage ? 4 : 2} pb={isLastMessage ? 4 : 2}
pr={5} pr={5}
@ -218,11 +216,12 @@ export default class ChatMessage extends Component<ChatMessageProps> {
} }
} }
export default React.forwardRef((props, ref) => <ChatMessage {...props} innerRef={ref} />);
export const MessageAuthor = ({ export const MessageAuthor = ({
timestamp, timestamp,
contacts, contacts,
msg, msg,
measure,
group, group,
api, api,
associations, associations,
@ -367,8 +366,8 @@ export const Message = ({
timestamp, timestamp,
contacts, contacts,
msg, msg,
measure,
group, group,
shiftLayout,
api, api,
associations, associations,
groups, groups,
@ -401,7 +400,7 @@ export const Message = ({
<TextContent <TextContent
associations={associations} associations={associations}
groups={groups} groups={groups}
measure={measure} shiftLayout={shiftLayout}
api={api} api={api}
fontSize={1} fontSize={1}
lineHeight={'20px'} lineHeight={'20px'}
@ -420,7 +419,7 @@ export const Message = ({
> >
<RemoteContent <RemoteContent
url={content.url} url={content.url}
onLoad={measure} shiftLayout={shiftLayout}
imageProps={{ imageProps={{
style: { style: {
maxWidth: 'min(100%,18rem)', maxWidth: 'min(100%,18rem)',

View File

@ -86,7 +86,6 @@ export default class ChatWindow extends Component<
componentDidMount() { componentDidMount() {
this.calculateUnreadIndex(); this.calculateUnreadIndex();
this.virtualList?.calculateVisibleItems();
window.addEventListener('blur', this.handleWindowBlur); window.addEventListener('blur', this.handleWindowBlur);
window.addEventListener('focus', this.handleWindowFocus); window.addEventListener('focus', this.handleWindowFocus);
setTimeout(() => { setTimeout(() => {
@ -145,7 +144,7 @@ export default class ChatWindow extends Component<
this.scrollToUnread(); this.scrollToUnread();
} }
this.prevSize = graph.size; this.prevSize = graph.size;
this.virtualList?.calculateVisibleItems(); //this.virtualList?.calculateVisibleItems();
this.stayLockedIfActive(); this.stayLockedIfActive();
} }
@ -168,7 +167,7 @@ export default class ChatWindow extends Component<
return; return;
} }
this.virtualList?.scrollToData(unreadIndex); //this.virtualList?.scrollToData(unreadIndex);
} }
dismissUnread() { dismissUnread() {
@ -179,18 +178,18 @@ export default class ChatWindow extends Component<
this.props.api.hark.markCountAsRead(association, '/', 'mention'); this.props.api.hark.markCountAsRead(association, '/', 'mention');
} }
async fetchMessages(newer: boolean, force = false): Promise<void> { async fetchMessages(newer: boolean): Promise<boolean> {
const { api, station, graph } = this.props; const { api, station, graph } = this.props;
if(this.state.fetchPending) {
if (this.state.fetchPending && !force) { return false;
return new Promise((resolve, reject) => {});
} }
this.setState({ fetchPending: true }); this.setState({ fetchPending: true });
const [, , ship, name] = station.split('/'); const [, , ship, name] = station.split('/');
const currSize = graph.size; const currSize = graph.size;
if (newer && !this.loadedNewest) { if (newer) {
const [index] = graph.peekLargest()!; const [index] = graph.peekLargest()!;
await api.graph.getYoungerSiblings( await api.graph.getYoungerSiblings(
ship, ship,
@ -198,20 +197,14 @@ export default class ChatWindow extends Component<
20, 20,
`/${index.toString()}` `/${index.toString()}`
); );
if (currSize === graph.size) { } else {
console.log('loaded all newest');
this.loadedNewest = true;
}
} else if (!newer && !this.loadedOldest) {
const [index] = graph.peekSmallest()!; const [index] = graph.peekSmallest()!;
await api.graph.getOlderSiblings(ship, name, 20, `/${index.toString()}`); await api.graph.getOlderSiblings(ship, name, 20, `/${index.toString()}`);
this.calculateUnreadIndex(); this.calculateUnreadIndex();
if (currSize === graph.size) {
console.log('loaded all oldest');
this.loadedOldest = true;
}
} }
this.setState({ fetchPending: false }); this.setState({ fetchPending: false });
console.log(currSize, graph.size);
return currSize === graph.size;
} }
onScroll({ scrollTop, scrollHeight, windowHeight }) { onScroll({ scrollTop, scrollHeight, windowHeight }) {
@ -293,7 +286,8 @@ export default class ChatWindow extends Component<
onScroll={this.onScroll.bind(this)} onScroll={this.onScroll.bind(this)}
data={graph} data={graph}
size={graph.size} size={graph.size}
renderer={({ index, measure, scrollWindow }) => { id={association.resource}
renderer={({ index, shiftLayout, measure, scrollWindow, ref }) => {
const msg = graph.get(index)?.post; const msg = graph.get(index)?.post;
if (!msg) return null; if (!msg) return null;
if (!this.state.initialized) { if (!this.state.initialized) {
@ -316,12 +310,14 @@ export default class ChatWindow extends Component<
const isLastRead: boolean = this.state.unreadIndex.eq(index); const isLastRead: boolean = this.state.unreadIndex.eq(index);
const props = { const props = {
measure, measure,
ref,
highlighted, highlighted,
scrollWindow, scrollWindow,
isPending, isPending,
isLastRead, isLastRead,
isLastMessage, isLastMessage,
msg, msg,
shiftLayout,
...messageProps ...messageProps
}; };
return ( return (
@ -333,9 +329,7 @@ export default class ChatWindow extends Component<
/> />
); );
}} }}
loadRows={(newer) => { loadRows={this.fetchMessages.bind(this)}
this.fetchMessages(newer);
}}
/> />
</Col> </Col>
); );

View File

@ -1,4 +1,4 @@
import React, { PureComponent, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react'; import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser'; import { hasProvider } from 'oembed-parser';
import EmbedContainer from 'react-oembed-container'; import EmbedContainer from 'react-oembed-container';
@ -18,6 +18,10 @@ interface RemoteContentProps {
textProps?: any; textProps?: any;
style?: any; style?: any;
onLoad?(): void; onLoad?(): void;
shiftLayout: {
save: () => void;
restore: () => void;
}
} }
interface RemoteContentState { interface RemoteContentState {
@ -30,9 +34,10 @@ const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i); const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i); const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState> { class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
private fetchController: AbortController | undefined; private fetchController: AbortController | undefined;
containerRef: HTMLDivElement | null = null; containerRef: HTMLDivElement | null = null;
private saving = true;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
@ -56,8 +61,23 @@ class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState
event.stopPropagation(); event.stopPropagation();
let unfoldState = this.state.unfold; let unfoldState = this.state.unfold;
unfoldState = !unfoldState; unfoldState = !unfoldState;
this.props.shiftLayout.save();
this.setState({ unfold: unfoldState }); this.setState({ unfold: unfoldState });
setTimeout(this.props.onLoad, 500); }
componentDidUpdate(prevProps, prevState) {
if(prevState.embed !== this.state.embed) {
console.log('remotecontent: restoring');
prevProps.shiftLayout.restore();
}
}
onLoad = () => {
window.requestAnimationFrame(() => {
this.props.shiftLayout.restore();
});
} }
loadOembed() { loadOembed() {
@ -107,9 +127,9 @@ return;
oembedProps = {}, oembedProps = {},
textProps = {}, textProps = {},
style = {}, style = {},
onLoad = () => {},
...props ...props
} = this.props; } = this.props;
const { onLoad } = this;
const { noCors } = this.state; const { noCors } = this.state;
const isImage = IMAGE_REGEX.test(url); const isImage = IMAGE_REGEX.test(url);
const isAudio = AUDIO_REGEX.test(url); const isAudio = AUDIO_REGEX.test(url);
@ -193,13 +213,14 @@ return;
className='embed-container' className='embed-container'
style={style} style={style}
flexShrink={0} flexShrink={0}
onLoad={onLoad} onLoad={this.onLoad}
{...oembedProps} {...oembedProps}
{...props} {...props}
> >
{this.state.embed && this.state.embed.html && this.state.unfold {this.state.embed && this.state.embed.html && this.state.unfold
? <EmbedContainer markup={this.state.embed.html}> ? <EmbedContainer markup={this.state.embed.html}>
<div className="embed-container" ref={(el) => { <div className="embed-container" ref={(el) => {
this.onLoad();
this.containerRef = el; this.containerRef = el;
}} }}
dangerouslySetInnerHTML={{ __html: this.state.embed.html }} dangerouslySetInnerHTML={{ __html: this.state.embed.html }}

View File

@ -8,47 +8,83 @@ import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
interface RendererProps { interface RendererProps {
index: BigInteger; index: BigInteger;
measure: (el: any) => void; shiftLayout: {
scrollWindow: any save: () => void;
restore: () => void;
}
scrollWindow: any;
ref: (el: HTMLElement | null) => void;
} }
const PAGE_DELTA = 20;
const PAGE_SIZE = 60;
interface VirtualScrollerProps { interface VirtualScrollerProps<T> {
origin: 'top' | 'bottom'; origin: 'top' | 'bottom';
loadRows(newer: boolean): void; loadRows(newer: boolean): Promise<boolean>;
data: BigIntOrderedMap<BigInteger, any>; data: BigIntOrderedMap<T>;
id: string;
renderer: (props: RendererProps) => JSX.Element | null; renderer: (props: RendererProps) => JSX.Element | null;
onStartReached?(): void; onStartReached?(): void;
onEndReached?(): void; onEndReached?(): void;
size: number; size: number;
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<BigInteger, JSX.Element>): void; totalSize: number;
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<T>): void;
onScroll?({ scrollTop, scrollHeight, windowHeight }): void; onScroll?({ scrollTop, scrollHeight, windowHeight }): void;
style?: any; style?: any;
} }
interface VirtualScrollerState { interface VirtualScrollerState<T> {
startgap: number | undefined; startgap: number | undefined;
visibleItems: BigIntOrderedMap<BigInteger, Element>; visibleItems: BigIntOrderedMap<T>;
endgap: number | undefined; endgap: number | undefined;
totalHeight: number; totalHeight: number;
averageHeight: number; averageHeight: number;
scrollTop: number;
} }
export default class VirtualScroller extends Component<VirtualScrollerProps, VirtualScrollerState> { // nb: in this file, an index refers to a BigInteger and an offset refers to a
private scrollContainer: React.RefObject<HTMLDivElement>; // number used to index a listified BigIntOrderedMap
public window: HTMLDivElement | null;
private cache: BigIntOrderedMap<any>;
private pendingLoad: {
start: BigInteger;
end: BigInteger
timeout: ReturnType<typeof setTimeout>;
} | undefined;
overscan = 150; export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState<T>> {
/**
* A reference to our scroll container
*/
private window: HTMLDivElement | null = null;
/**
* A map of child refs, used to calculate scroll position
*/
private childRefs = new BigIntOrderedMap<HTMLElement>();
/**
* If saving, the bottommost visible element that we pin our scroll to
*/
private savedIndex: BigInteger | null = null;
/**
* If saving, the distance between the top of `this.savedEl` and the bottom
* of the screen
*/
private savedDistance = 0;
OVERSCAN_SIZE = 100; // Minimum number of messages on either side before loadRows is called /**
* If saving, the number of requested saves. If several images are loading
* at once, we save the scroll pos the first time we see it and restore
* once the number of requested saves is zero
*/
private saveDepth = 0;
constructor(props: VirtualScrollerProps) { private isUpdating = false;
private scrollLocked = true;
private dataEdges: [BigInteger, BigInteger] = [bigInt.zero, bigInt.zero];
private loaded = {
top: false,
bottom: false
};
private dirtied : 'top' | 'bottom' | null = null;
constructor(props: VirtualScrollerProps<T>) {
super(props); super(props);
this.state = { this.state = {
startgap: props.origin === 'top' ? 0 : undefined, startgap: props.origin === 'top' ? 0 : undefined,
@ -56,137 +92,92 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
endgap: props.origin === 'bottom' ? 0 : undefined, endgap: props.origin === 'bottom' ? 0 : undefined,
totalHeight: 0, totalHeight: 0,
averageHeight: 130, averageHeight: 130,
scrollTop: props.origin === 'top' ? 0 : undefined
}; };
this.scrollContainer = React.createRef(); this.updateVisible = this.updateVisible.bind(this);
this.window = null;
this.cache = new BigIntOrderedMap();
this.recalculateTotalHeight = _.throttle(this.recalculateTotalHeight.bind(this), 200);
this.calculateVisibleItems = _.throttle(this.calculateVisibleItems.bind(this), 200);
this.estimateIndexFromScrollTop = this.estimateIndexFromScrollTop.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this); this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.heightOf = this.heightOf.bind(this); this.onScroll = this.onScroll.bind(this)
this.setScrollTop = this.setScrollTop.bind(this);
this.scrollToData = this.scrollToData.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this); this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.loadRows = _.debounce(this.loadRows, 300, { leading: true }).bind(this); window.restore = () => this.restore();
window.save = () => this.save();
} }
componentDidMount() { componentDidMount() {
this.calculateVisibleItems(); this.updateDataEdges();
this.updateVisible(0);
this.recalculateTotalHeight();
} }
componentDidUpdate(prevProps: VirtualScrollerProps, prevState: VirtualScrollerState) { componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
const { const { id, size, data } = this.props;
scrollContainer, window, const { visibleItems } = this.state;
props: { origin }, if(id !== prevProps.id) {
state: { totalHeight, scrollTop } this.resetScroll();
} = this; this.updateVisible(0);
} } else if(size !== prevProps.size) {
const index = visibleItems.peekSmallest()?.[0]!;
const newOffset = [...data].findIndex(([i]) => i.eq(index));
scrollToData(targetIndex: BigInteger): Promise<void> { if(this.scrollLocked && this.dataEdges[1].neq(data.peekLargest()?.[0]!)) {
if (!this.window) { console.log('locking');
return new Promise((resolve, reject) => { this.updateVisible(0);
reject(); return;
}); } else if(this.scrollLocked || newOffset === -1) {
} console.log('resetting');
const { offsetHeight } = this.window; this.resetScroll();
let scrollTop = 0;
let itemHeight = 0;
new BigIntOrderedMap([...this.props.data].reverse()).forEach((datum, index) => {
const height = this.heightOf(index);
if (index.geq(targetIndex)) {
scrollTop += height;
if (index.eq(targetIndex)) {
itemHeight = height;
}
} }
}); this.updateDataEdges();
return this.setScrollTop(scrollTop - (offsetHeight / 2) + itemHeight);
}
recalculateTotalHeight() {
let { averageHeight } = this.state;
let totalHeight = 0;
this.props.data.forEach((datum, index) => {
totalHeight += Math.max(this.heightOf(index), 0);
});
averageHeight = Number((totalHeight / this.props.data.size).toFixed());
totalHeight += (this.props.size - this.props.data.size) * averageHeight;
this.setState({ totalHeight, averageHeight });
}
estimateIndexFromScrollTop(targetScrollTop: number): BigInteger | undefined {
if (!this.window)
return undefined;
const index = bigInt(this.props.size);
const { averageHeight } = this.state;
let height = 0;
while (height < targetScrollTop) {
const itemHeight = this.cache.has(index) ? this.cache.get(index).height : averageHeight;
height += itemHeight;
index.subtract(bigInt.one);
} }
return index;
} }
heightOf(index: BigInteger): number { componentWillUnmount() {
return this.cache.has(index) ? this.cache.get(index).height : this.state.averageHeight; window.removeEventListener('keydown', this.invertedKeyHandler);
} }
calculateVisibleItems() { updateDataEdges() {
if (!this.window) const { data } = this.props;
return; const small = data.peekSmallest()?.[0]!;
let startgap = 0, heightShown = 0, endgap = 0; const large = data.peekLargest()?.[0]!;
let startGapFilled = false;
const visibleItems = new BigIntOrderedMap<any>();
const { scrollTop, offsetHeight: windowHeight } = this.window;
const { averageHeight, totalHeight } = this.state;
const { data, size: totalSize, onCalculateVisibleItems } = this.props;
[...data].forEach(([index, datum]) => { this.dataEdges = [small, large];
const height = this.heightOf(index); }
if (startgap < (scrollTop - this.overscan) && !startGapFilled) {
startgap += height;
} else if (heightShown < (windowHeight + this.overscan)) {
startGapFilled = true;
visibleItems.set(index, datum);
heightShown += height;
}
});
endgap = totalHeight - heightShown - startgap; startOffset() {
const startIndex = this.state.visibleItems.peekLargest()?.[0]!;
const firstVisibleKey = visibleItems.peekSmallest()?.[0] ?? this.estimateIndexFromScrollTop(scrollTop)!; const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex))
const smallest = data.peekSmallest(); if(offset === -1) {
if (smallest && smallest[0].eq(firstVisibleKey)) { throw new Error("a");
this.loadRows(false);
} }
const lastVisibleKey = return offset;
visibleItems.peekLargest()?.[0] }
?? bigInt(this.estimateIndexFromScrollTop(scrollTop + windowHeight)!);
const largest = data.peekLargest(); /**
* Updates the `startOffset` and adjusts visible items accordingly.
if (largest && largest[0].eq(lastVisibleKey)) { * Saves the scroll positions before repainting and restores it afterwards
this.loadRows(true); */
updateVisible(newOffset: number) {
if (!this.window || this.isUpdating) {
return;
} }
this.isUpdating = true;
const { data, onCalculateVisibleItems } = this.props;
const visibleItems = new BigIntOrderedMap<any>(
[...data].slice(newOffset, newOffset + PAGE_SIZE)
);
this.save();
onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null; onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null;
this.setState({ this.setState({
startgap: Number(startgap.toFixed()),
visibleItems, visibleItems,
endgap: Number(endgap.toFixed()) }, () => {
requestAnimationFrame(() => {
this.restore();
this.isUpdating = false;
});
}); });
} }
loadRows(newer: boolean) {
this.props.loadRows(newer);
}
scrollKeyMap(): Map<string, number> { scrollKeyMap(): Map<string, number> {
return new Map([ return new Map([
['ArrowUp', this.state.averageHeight], ['ArrowUp', this.state.averageHeight],
@ -213,13 +204,9 @@ return;
} }
} }
componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler);
}
setWindow(element) { setWindow(element) {
if (!element) if (!element)
return; return;
if (this.window) { if (this.window) {
if (this.window.isSameNode(element)) { if (this.window.isSameNode(element)) {
return; return;
@ -228,8 +215,6 @@ return;
} }
} }
this.overscan = Math.max(element.offsetHeight * 3, 500);
this.window = element; this.window = element;
if (this.props.origin === 'bottom') { if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => { element.addEventListener('wheel', (event) => {
@ -244,48 +229,145 @@ return;
this.resetScroll(); this.resetScroll();
} }
resetScroll(): Promise<void> { resetScroll() {
if (!this.window) if (!this.window) {
return new Promise((resolve, reject) => { return;
reject(); }
}); this.window.scrollTop = 0;
return this.setScrollTop(0); this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
} }
setScrollTop(distance: number, delay = 100): Promise<void> { async loadRows(newer: boolean) {
return new Promise((resolve, reject) => { const dir = newer ? 'bottom' : 'top';
setTimeout(() => { if(this.loaded[dir]) {
if (!this.window) { return;
reject(); }
return; const done = await this.props.loadRows(newer);
} if(done) {
this.window.scrollTop = distance; this.dirtied = dir;
resolve(); this.loaded[dir] = true;
}, delay); }
});
} }
onScroll(event) { onScroll(event: UIEvent) {
if (!this.window) if(!this.window || this.savedIndex) {
return; // bail if we're going to adjust scroll anyway
const { onStartReached, onEndReached, onScroll } = this.props; return;
}
const { onStartReached, onEndReached } = this.props;
const windowHeight = this.window.offsetHeight; const windowHeight = this.window.offsetHeight;
const { scrollTop, scrollHeight } = this.window; const { scrollTop, scrollHeight } = this.window;
if (scrollTop !== scrollHeight) {
this.setState({ scrollTop });
}
this.calculateVisibleItems(); const startOffset = this.startOffset();
onScroll ? onScroll({ scrollTop, scrollHeight, windowHeight }) : null; if (scrollTop < 20) {
if (scrollTop === 0) { if (onStartReached) {
if (onStartReached) onStartReached();
onStartReached(); }
} else if (scrollTop + windowHeight >= scrollHeight) { const newOffset = Math.max(0, startOffset - PAGE_DELTA);
if (onEndReached) if(newOffset < 10) {
onEndReached(); setTimeout(() => this.loadRows(true));
}
if(newOffset === 0) {
this.scrollLocked = true;
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
}
else if (scrollTop + windowHeight >= scrollHeight - 100) {
if (onEndReached) {
onEndReached();
}
const newOffset = Math.min(startOffset + PAGE_DELTA, this.props.data.size - PAGE_SIZE);
if((newOffset + 10 < this.props.data.size - PAGE_SIZE)) {
setTimeout(() => this.loadRows(false));
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
} else {
this.scrollLocked = false;
} }
} }
restore() {
if(!this.window || !this.savedIndex) {
return;
}
this.saveDepth--;
if(this.saveDepth !== 0) {
//console.log('multiple restores');
return;
}
const { offsetTop } = this.childRefs.get(this.savedIndex)!;
const newScrollTop = this.window.scrollHeight - offsetTop - this.savedDistance;
this.window.scrollTop = newScrollTop;
this.savedIndex = null;
this.savedDistance = 0;
}
save() {
if(!this.window || this.savedIndex) {
return;
}
this.saveDepth++;
if(this.saveDepth !== 1) {
//console.log(new Error().stack);
//console.log('multiple saves');
return;
}
let bottomIndex: BigInteger | null = null;
const { scrollTop, scrollHeight } = this.window;
const topSpacing = scrollHeight - scrollTop;
[...Array.from(this.state.visibleItems)].reverse().forEach(([index, datum]) => {
const idxTop = this.childRefs.get(index)!.offsetTop;
if(idxTop < topSpacing) {
bottomIndex = index;
}
});
if(!bottomIndex) {
console.log('weird case');
// weird, shouldn't really happen
return;
}
this.savedIndex = bottomIndex;
const { offsetTop } = this.childRefs.get(bottomIndex)!;
this.savedDistance = topSpacing - offsetTop
}
shiftLayout = { save: this.save.bind(this), restore: this.restore.bind(this) };
setRef = (index: BigInteger) => (element: HTMLElement | null) => {
if(element) {
this.childRefs.set(index, element);
} else {
this.childRefs.delete(index);
}
}
renderItem = (index: BigInteger) => {
const ref = this.setRef(index);
return this.props.renderer({
index,
ref,
shiftLayout: this.shiftLayout,
scrollWindow: this.window,
});
};
render() { render() {
const { const {
startgap, startgap,
@ -295,34 +377,20 @@ onEndReached();
const { const {
origin = 'top', origin = 'top',
loadRows,
renderer, renderer,
style, style,
data
} = this.props; } = this.props;
const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse(); const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse();
const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)'; const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const render = (index: BigInteger) => {
const measure = (element: any) => {
if (element) {
this.cache.set(index, {
height: element.offsetHeight,
element
});
this.recalculateTotalHeight();
}
};
return renderer({ index, measure, scrollWindow: this.window });
};
return ( return (
<Box overflowY='scroll' ref={this.setWindow.bind(this)} onScroll={this.onScroll.bind(this)} style={{ ...style, ...{ transform } }}> <Box overflowY='scroll' ref={this.setWindow.bind(this)} onScroll={this.onScroll} style={{ ...style, ...{ transform } }}>
<Box ref={this.scrollContainer} style={{ transform, width: '100%' }}> <Box style={{ transform, width: '100%' }}>
<Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box> <Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box>
{indexesToRender.map(render)} {indexesToRender.map(this.renderItem)}
<Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box> <Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box>
</Box> </Box>
</Box> </Box>