refactor of virtual list

This commit is contained in:
Roland Osborne 2022-05-04 00:50:31 -07:00
parent 641b553999
commit 5202a19b52
3 changed files with 160 additions and 153 deletions

View File

@ -29,12 +29,13 @@ export function useConversation() {
useEffect(() => { useEffect(() => {
conversation.actions.setConversationId(cardId, channelId); conversation.actions.setConversationId(cardId, channelId);
updateState({ cardId, channelId });
}, [cardId, channelId]); }, [cardId, channelId]);
useEffect(() => { useEffect(() => {
updateState({ updateState({
init: conversation.state.init, init: conversation.state.init,
cardId: conversation.state.cardId,
channelId: conversation.state.channelId,
topics: Array.from(conversation.state.topics.values()), topics: Array.from(conversation.state.topics.values()),
}); });
}, [conversation]); }, [conversation]);

View File

@ -4,10 +4,10 @@ import ReactResizeDetector from 'react-resize-detector';
export function VirtualList({ id, items, itemRenderer }) { export function VirtualList({ id, items, itemRenderer }) {
const REDZONE = 256; // recenter on canvas if in canvas edge redzone const REDZONE = 1024; // recenter on canvas if in canvas edge redzone
const HOLDZONE = 1024; // drop slots outside of holdzone of view const HOLDZONE = 2048; // drop slots outside of holdzone of view
const OVERSCAN = 256; // add slots in overscan of view const OVERSCAN = 1024; // add slots in overscan of view
const DEFAULT_ITEM_HEIGHT = 64; const DEFAULT_ITEM_HEIGHT = 256;
const DEFAULT_LIST_HEIGHT = 4096; const DEFAULT_LIST_HEIGHT = 4096;
const GUTTER = 6; const GUTTER = 6;
@ -16,10 +16,10 @@ export function VirtualList({ id, items, itemRenderer }) {
const [ slots, setSlots ] = useState(new Map()); const [ slots, setSlots ] = useState(new Map());
const [ scroll, setScroll ] = useState('hidden'); const [ scroll, setScroll ] = useState('hidden');
let update = useRef(0);
let latch = useRef(true); let latch = useRef(true);
let scrollTop = useRef(0); let scrollTop = useRef(0);
let containers = useRef([]); let containers = useRef([]);
let anchorBottom = useRef(true);
let listRef = useRef(); let listRef = useRef();
let key = useRef(null); let key = useRef(null);
@ -32,7 +32,7 @@ export function VirtualList({ id, items, itemRenderer }) {
} }
const removeSlot = (id) => { const removeSlot = (id) => {
setSlots((m) => { m.delete(id); return new Map(m); }) setSlots((m) => { m.delete(id); return new Map(m); });
} }
const clearSlots = () => { const clearSlots = () => {
@ -52,7 +52,7 @@ export function VirtualList({ id, items, itemRenderer }) {
if (viewHeight * 3 > canvasHeight) { if (viewHeight * 3 > canvasHeight) {
growCanvasHeight(viewHeight * 3); growCanvasHeight(viewHeight * 3);
} }
setItems(); updateCanvas();
}, [viewHeight]); }, [viewHeight]);
useEffect(() => { useEffect(() => {
@ -60,48 +60,63 @@ export function VirtualList({ id, items, itemRenderer }) {
key.current = id; key.current = id;
latch.current = true; latch.current = true;
containers.current = []; containers.current = [];
anchorBottom.current = true;
scrollTop.current = 0;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
clearSlots(); clearSlots();
} }
setItems(); setItems();
}, [items, id]); }, [items, id]);
useEffect(() => { useEffect(() => {
if (latch.current) { updateCanvas();
alignSlots();
}
}, [viewHeight, canvasHeight]); }, [viewHeight, canvasHeight]);
const onScrollWheel = (e) => { const onScrollWheel = (e) => {
if (e.deltaY < 0 && latch.current) { if (e.deltaY < 0 && latch.current) {
scrollTop.current -= 32;
listRef.current.scrollTo({ top: scrollTop.current, left: 0, behavior: 'smooth' });
setScroll('auto');
latch.current = false; latch.current = false;
} }
} }
const onScrollView = (e) => { const onScrollView = (e) => {
scrollTop.current = e.target.scrollTop; scrollTop.current = e.target.scrollTop;
loadNextSlot();
dropSlots();
centerCanvas();
limitScroll();
}
if (!latch.current) { const updateCanvas = () => {
let view = getPlacement(); alignSlots();
loadNextSlot();
dropSlots();
centerCanvas();
limitScroll();
latchScroll();
}
const limitScroll = () => {
let view = getPlacement();
if (view && containers.current[containers.current.length - 1].index == items.length - 1) {
if (view?.overscan?.bottom <= 0) {
if (view.position.height < viewHeight) {
if (scrollTop.current != view.position.top) {
scrollTop.current = view.position.top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
}
else {
if (scrollTop.current != view.position.bottom - viewHeight) {
scrollTop.current = view.position.bottom - viewHeight;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
}
latch.current = true;
}
}
if (view && containers.current[0].index == 0) {
if (view?.overscan?.top <= 0) { if (view?.overscan?.top <= 0) {
scrollTop.current = containers.current[0].top; scrollTop.current = containers.current[0].top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 }); listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
} }
if (view?.overscan?.bottom <= 0) {
setScroll('hidden');
latch.current = true;
alignSlots();
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
} }
loadNextSlot();
} }
const loadNextSlot = () => { const loadNextSlot = () => {
@ -119,14 +134,7 @@ export function VirtualList({ id, items, itemRenderer }) {
} }
containers.current.unshift(container); containers.current.unshift(container);
addSlot(container.id, getSlot(container)) addSlot(container.id, getSlot(container))
anchorBottom.current = true; loadNextSlot();
if (containers.current[containers.current.length - 1].top > scrollTop.current + viewHeight + HOLDZONE) {
removeSlot(containers.current[containers.current.length - 1].id);
containers.current.pop();
}
alignSlots();
} }
} }
if (view.overscan.bottom < OVERSCAN) { if (view.overscan.bottom < OVERSCAN) {
@ -141,127 +149,113 @@ export function VirtualList({ id, items, itemRenderer }) {
} }
containers.current.push(container); containers.current.push(container);
addSlot(container.id, getSlot(container)) addSlot(container.id, getSlot(container))
anchorBottom.current = false; loadNextSlot();
if (containers.current[0].top + containers.current[0].height + 2 * GUTTER + HOLDZONE < scrollTop.current) {
removeSlot(containers.current[0].id);
containers.current.shift();
}
alignSlots();
} }
} }
} }
} }
const dropSlots = () => {
while (containers.current.length > 0 &&
containers.current[0].top + containers.current[0].height + 2 * GUTTER + HOLDZONE < scrollTop.current) {
removeSlot(containers.current[0].id);
containers.current.shift();
}
while (containers.current.length > 0 &&
containers.current[containers.current.length - 1].top > scrollTop.current + viewHeight + HOLDZONE) {
removeSlot(containers.current[containers.current.length - 1].id);
containers.current.pop();
}
}
const alignSlots = () => { const alignSlots = () => {
if (containers.current.length > 0) { if (containers.current.length > 1) {
let mid = Math.floor(containers.current.length / 2);
if (anchorBottom.current) { let alignTop = containers.current[mid].top + containers.current[mid].height + 2 * GUTTER;
let pos = containers.current[containers.current.length - 1].top; for (let i = mid + 1; i < containers.current.length; i++) {
for (let i = containers.current.length - 2; i >= 0; i--) { if (containers.current[i].top != alignTop) {
pos -= (containers.current[i].height + 2 * GUTTER); containers.current[i].top = alignTop;
if (containers.current[i].top != pos) { updateSlot(containers.current[i].id, getSlot(containers.current[i]));
containers.current[i].top = pos;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
}
if (pos < REDZONE) {
let shift = canvasHeight / 2;
for (let i = 0; i < containers.current.length; i++) {
containers.current[i].top += shift;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
scrollTop.current += shift;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
let view = getPlacement();
if (view.position.bottom + REDZONE > canvasHeight) {
growCanvasHeight(view.position.bottom + REDZONE);
}
}
}
else {
let pos = containers.current[0].top + containers.current[0].height + 2 * GUTTER;
for (let i = 1; i < containers.current.length; i++) {
if (containers.current[i].top != pos) {
containers.current[i].top = pos;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
pos += containers.current[i].height + 2 * GUTTER;
}
if (pos + REDZONE > canvasHeight) {
let shift = canvasHeight / 2;
let view = getPlacement();
if (view.position.top < shift + REDZONE) {
growCanvasHeight(view.position.bottom + REDZONE);
}
else {
for (let i = 0; i < containers.current.length; i++) {
containers.current[i].top -= shift;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
scrollTop.current -= shift;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
} }
alignTop += containers.current[i].height + 2 * GUTTER;
} }
let alignBottom = containers.current[mid].top;
for (let i = mid - 1; i >= 0; i--) {
alignBottom -= (containers.current[i].height + 2 * GUTTER);
if (containers.current[i].top != alignBottom) {
containers.current[i].top = alignBottom;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
}
}
};
const centerCanvas = () => {
let view = getPlacement();
if (view) {
let height = canvasHeight;
let resize = view.position.height * 3 + (2 * REDZONE) + (2 * HOLDZONE);
if (resize > canvasHeight) {
height = 2 * resize;
growCanvasHeight(height);
}
if (view.position.top < REDZONE) {
let shift = height / 2;
for (let i = 0; i < containers.current.length; i++) {
containers.current[i].top += shift;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
scrollTop.current += shift;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
else if (view.position.bottom + REDZONE > height) {
let shift = height / 2;
for (let i = 0; i < containers.current.length; i++) {
containers.current[i].top -= shift;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
scrollTop.current -= shift;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
}
}
const latchScroll = () => {
if (latch.current) {
let view = getPlacement(); let view = getPlacement();
if (latch.current) { if (view) {
if (view.position.height < viewHeight) { if (view.position.height < viewHeight) {
if (scrollTop.current != view.position.top) { if (scrollTop.current != view.position.top) {
listRef.current.scrollTo({ top: view.position.top, left: 0, behavior: 'smooth' });
scrollTop.current = view.position.top; scrollTop.current = view.position.top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0, behavior: 'smooth' });
} }
} }
else { else {
if (scrollTop.current != view.position.bottom - viewHeight) { if (scrollTop.current != view.position.bottom - viewHeight) {
listRef.current.scrollTo({ top: view.position.bottom - viewHeight, left: 0, behavior: 'smooth' });
scrollTop.current = view.position.bottom - viewHeight; scrollTop.current = view.position.bottom - viewHeight;
listRef.current.scrollTo({ top: scrollTop.current, left: 0, behavior: 'smooth' });
} }
} }
} }
} }
loadNextSlot();
} }
const setItems = () => { const setItems = () => {
// update or removed any affected slots for (let i = 0; i < containers.current.length; i++) {
if (anchorBottom.current) { let container = containers.current[i];
for (let i = containers.current.length - 1; i >= 0; i--) { if (items.length <= container.index || items[container.index].id != container.id) {
let container = containers.current[i]; for (let j = i; j < containers.current.length; j++) {
if (items.length <= container.index || items[container.index].id != container.id) { let popped = containers.current.pop();
for (let j = 0; j <= i; j++) { removeSlot(popped.id);
let shifted = containers.current.shift();
removeSlot(shifted.id);
}
break;
}
else if (items[container.index].revision != container.revision) {
updateSlot(container.id, getSlot(containers.current[i]));
containers.revision = items[container.index].revision;
} }
break;
} }
} else if (items[container.index].revision != container.revision) {
else { updateSlot(container.id, getSlot(containers.current[i]));
for (let i = 0; i < containers.current.length; i++) { containers.revision = items[container.index].revision;
let container = containers.current[i];
if (items.length <= container.index || items[container.index].id != container.id) {
for (let j = i; j < containers.current.length; j++) {
let popped = containers.current.pop();
removeSlot(popped.id);
}
break;
}
else if (items[container.index].revision != container.revision) {
updateSlot(container.id, getSlot(containers.current[i]));
containers.revision = items[container.index].revision;
}
} }
} }
@ -281,21 +275,17 @@ export function VirtualList({ id, items, itemRenderer }) {
revision: items[items.length - 1].revision, revision: items[items.length - 1].revision,
} }
anchorBottom.current = true;
containers.current.push(container); containers.current.push(container);
addSlot(container.id, getSlot(container)); addSlot(container.id, getSlot(container));
listRef.current.scrollTo({ top: container.top, left: 0, behavior: 'smooth' });
}
else {
loadNextSlot();
} }
} }
updateCanvas();
} }
const onItemHeight = (container, height) => { const onItemHeight = (container, height) => {
container.height = height; container.height = height;
alignSlots(); updateCanvas();
} }
const getSlot = (container) => { const getSlot = (container) => {
@ -333,7 +323,7 @@ export function VirtualList({ id, items, itemRenderer }) {
setViewHeight(height); setViewHeight(height);
return ( return (
<VirtualListWrapper onScroll={onScrollView} onWheel={onScrollWheel}> <VirtualListWrapper onScroll={onScrollView} onWheel={onScrollWheel}>
<div class="rollview" style={{ overflowY: scroll }} ref={listRef} onScroll={onScrollView}> <div class="rollview" style={{ overflowY: 'auto' }} ref={listRef} onScroll={onScrollView}>
<div class="roll" style={{ height: canvasHeight }}> <div class="roll" style={{ height: canvasHeight }}>
{ slots.values() } { slots.values() }
</div> </div>

View File

@ -6,6 +6,8 @@ export function useConversationContext() {
const [state, setState] = useState({ const [state, setState] = useState({
init: false, init: false,
cardId: null,
channelId: null,
topics: new Map(), topics: new Map(),
}); });
@ -15,6 +17,7 @@ export function useConversationContext() {
const revision = useRef(null); const revision = useRef(null);
const count = useRef(0); const count = useRef(0);
const conversationId = useRef(null); const conversationId = useRef(null);
const view = useRef(0);
const updateState = (value) => { const updateState = (value) => {
setState((s) => ({ ...s, ...value })); setState((s) => ({ ...s, ...value }));
@ -22,11 +25,13 @@ export function useConversationContext() {
const setTopics = async () => { const setTopics = async () => {
const { cardId, channelId } = conversationId.current; const { cardId, channelId } = conversationId.current;
const curRevision = revision.current;
const curView = view.current;
if (cardId) { if (cardId) {
let rev = card.actions.getChannelRevision(cardId, channelId); let deltaRevision = card.actions.getChannelRevision(cardId, channelId);
if (revision.current != rev) { if (curRevision != deltaRevision) {
let delta = await card.actions.getChannelTopics(cardId, channelId, revision.current); let delta = await card.actions.getChannelTopics(cardId, channelId, curRevision);
for (let topic of delta) { for (let topic of delta) {
if (topic.data == null) { if (topic.data == null) {
topics.current.delete(topic.id); topics.current.delete(topic.id);
@ -51,17 +56,22 @@ export function useConversationContext() {
topics.current.set(topic.id, cur); topics.current.set(topic.id, cur);
} }
} }
updateState({ if (curView == view.current) {
init: true, updateState({
topics: topics.current, init: true,
}); topics: topics.current,
revision.current = rev; });
revision.current = deltaRevision;
}
else {
topics.current = new Map();
}
} }
} }
else { else {
let rev = channel.actions.getChannelRevision(channelId); let deltaRevision = channel.actions.getChannelRevision(channelId);
if (revision.current != rev) { if (curRevision != deltaRevision) {
let delta = await channel.actions.getChannelTopics(channelId, revision.current); let delta = await channel.actions.getChannelTopics(channelId, curRevision);
for (let topic of delta) { for (let topic of delta) {
if (topic.data == null) { if (topic.data == null) {
topics.current.delete(topic.id); topics.current.delete(topic.id);
@ -86,11 +96,16 @@ export function useConversationContext() {
topics.current.set(topic.id, cur); topics.current.set(topic.id, cur);
} }
} }
updateState({ if (curView == view.current) {
init: true, updateState({
topics: topics.current, init: true,
}); topics: topics.current,
revision.current = rev; });
revision.current = deltaRevision;
}
else {
topics.current = new Map();
}
} }
} }
} }
@ -133,10 +148,11 @@ export function useConversationContext() {
const actions = { const actions = {
setConversationId: (cardId, channelId) => { setConversationId: (cardId, channelId) => {
view.current += 1;
conversationId.current = { cardId, channelId }; conversationId.current = { cardId, channelId };
revision.current = null; revision.current = null;
topics.current = new Map(); topics.current = new Map();
updateState({ init: false, topics: topics.current }); updateState({ init: false, cardId, channelId, topics: topics.current });
updateConversation(); updateConversation();
}, },
getAssetUrl: (topicId, assetId) => { getAssetUrl: (topicId, assetId) => {