Skip to content

Commit

Permalink
Support automatic start offset calculation
Browse files Browse the repository at this point in the history
  • Loading branch information
inokawa committed May 25, 2024
1 parent a7ff084 commit 1e17c7b
Show file tree
Hide file tree
Showing 14 changed files with 197 additions and 107 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const App = () => {
return (
<div style={{ overflowY: "auto", height: 800 }}>
<div style={{ height: 40 }}>header</div>
<Virtualizer startMargin={40}>
<Virtualizer startOffset="static">
{Array.from({ length: 1000 }).map((_, i) => (
<div
key={i}
Expand Down
98 changes: 62 additions & 36 deletions src/core/scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
ACTION_BEFORE_MANUAL_SMOOTH_SCROLL,
ACTION_START_OFFSET_CHANGE,
} from "./store";
import { type ScrollToIndexOpts } from "./types";
import { type ScrollToIndexOpts, StartOffsetType } from "./types";
import { debounce, timeout, clamp, microtask } from "./utils";

/**
Expand All @@ -34,6 +34,32 @@ const normalizeOffset = (offset: number, isHorizontal: boolean): number => {
}
};

const calcOffsetToViewport = (
node: HTMLElement,
viewport: HTMLElement,
isHorizontal: boolean,
offset: number = 0
): number => {
// TODO calc offset only when it changes (maybe impossible)
const offsetSum =
offset +
(isHorizontal && isRTLDocument()
? viewport.offsetWidth - node.offsetLeft - node.offsetWidth
: node[isHorizontal ? "offsetLeft" : "offsetTop"]);

const parent = node.offsetParent;
if (node === viewport || !parent) {
return offsetSum;
}

return calcOffsetToViewport(
parent as HTMLElement,
viewport,
isHorizontal,
offsetSum
);
};

const createScrollObserver = (
store: VirtualStore,
viewport: HTMLElement | Window,
Expand Down Expand Up @@ -121,6 +147,10 @@ const createScrollObserver = (
}
};

if (getStartOffset) {
store._update(ACTION_START_OFFSET_CHANGE, getStartOffset());
}

viewport.addEventListener("scroll", onScroll);
viewport.addEventListener("wheel", onWheel, { passive: true });
viewport.addEventListener("touchstart", onTouchStart, { passive: true });
Expand Down Expand Up @@ -159,7 +189,10 @@ type ScrollObserver = ReturnType<typeof createScrollObserver>;
* @internal
*/
export type Scroller = {
_observe: (viewportElement: HTMLElement) => void;
_observe: (
viewportElement: HTMLElement,
containerElement: HTMLElement
) => void;
_dispose(): void;
_scrollTo: (offset: number) => void;
_scrollBy: (offset: number) => void;
Expand All @@ -172,7 +205,8 @@ export type Scroller = {
*/
export const createScroller = (
store: VirtualStore,
isHorizontal: boolean
isHorizontal: boolean,
startOffset?: StartOffsetType
): Scroller => {
let viewportElement: HTMLElement | undefined;
let scrollObserver: ScrollObserver | undefined;
Expand Down Expand Up @@ -264,9 +298,24 @@ export const createScroller = (
};

return {
_observe(viewport) {
_observe(viewport, container) {
viewportElement = viewport;

let getStartOffset: (() => number) | undefined;
if (startOffset === "dynamic") {
getStartOffset = () =>
calcOffsetToViewport(container, viewport, isHorizontal);
} else if (startOffset === "static") {
const staticStartOffset = calcOffsetToViewport(
container,
viewport,
isHorizontal
);
getStartOffset = () => staticStartOffset;
} else if (typeof startOffset === "number") {
getStartOffset = () => startOffset;
}

scrollObserver = createScrollObserver(
store,
viewport,
Expand All @@ -293,7 +342,8 @@ export const createScroller = (
} else {
viewport[scrollOffsetKey] += jump;
}
}
},
getStartOffset
);
},
_dispose() {
Expand Down Expand Up @@ -371,33 +421,6 @@ export const createWindowScroller = (
const window = getCurrentWindow(document);
const documentBody = document.body;

const calcOffsetToViewport = (
node: HTMLElement,
viewport: HTMLElement,
isHorizontal: boolean,
offset: number = 0
): number => {
// TODO calc offset only when it changes (maybe impossible)
const offsetKey = isHorizontal ? "offsetLeft" : "offsetTop";
const offsetSum =
offset +
(isHorizontal && isRTLDocument()
? window.innerWidth - node[offsetKey] - node.offsetWidth
: node[offsetKey]);

const parent = node.offsetParent;
if (node === viewport || !parent) {
return offsetSum;
}

return calcOffsetToViewport(
parent as HTMLElement,
viewport,
isHorizontal,
offsetSum
);
};

scrollObserver = createScrollObserver(
store,
window,
Expand Down Expand Up @@ -429,7 +452,10 @@ export const createWindowScroller = (
* @internal
*/
export type GridScroller = {
_observe: (viewportElement: HTMLElement) => void;
_observe: (
viewportElement: HTMLElement,
containerElement: HTMLElement
) => void;
_dispose(): void;
_scrollTo: (offsetX: number, offsetY: number) => void;
_scrollBy: (offsetX: number, offsetY: number) => void;
Expand All @@ -447,9 +473,9 @@ export const createGridScroller = (
const vScroller = createScroller(vStore, false);
const hScroller = createScroller(hStore, true);
return {
_observe(viewportElement) {
vScroller._observe(viewportElement);
hScroller._observe(viewportElement);
_observe(viewportElement, containerElement) {
vScroller._observe(viewportElement, containerElement);
hScroller._observe(viewportElement, containerElement);
},
_dispose() {
vScroller._dispose();
Expand Down
10 changes: 5 additions & 5 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,13 @@ export const createVirtualStore = (
itemSize: number = 40,
ssrCount: number = 0,
cacheSnapshot?: CacheSnapshot | undefined,
shouldAutoEstimateItemSize: boolean = false,
startSpacerSize: number = 0
shouldAutoEstimateItemSize: boolean = false
): VirtualStore => {
let isSSR = !!ssrCount;
let stateVersion: StateVersion = [];
let viewportSize = 0;
let scrollOffset = 0;
let startOffset = 0;
let jumpCount = 0;
let jump = 0;
let pendingJump = 0;
Expand All @@ -165,7 +165,7 @@ export const createVirtualStore = (
cacheSnapshot as unknown as InternalCacheSnapshot | undefined
);
const subscribers = new Set<[number, Subscriber]>();
const getRelativeScrollOffset = () => scrollOffset - startSpacerSize;
const getRelativeScrollOffset = () => scrollOffset - startOffset;
const getRange = (offset: number) => {
return computeRange(cache, offset, _prevRange[0], viewportSize);
};
Expand Down Expand Up @@ -240,7 +240,7 @@ export const createVirtualStore = (
return viewportSize;
},
_getStartSpacerSize() {
return startSpacerSize;
return startOffset;
},
_getTotalSize: getTotalSize,
_getJumpCount() {
Expand Down Expand Up @@ -418,7 +418,7 @@ export const createVirtualStore = (
break;
}
case ACTION_START_OFFSET_CHANGE: {
startSpacerSize = payload;
startOffset = payload;
break;
}
case ACTION_MANUAL_SCROLL: {
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,5 @@ export interface ScrollToIndexOpts {
*/
offset?: number;
}

export type StartOffsetType = "dynamic" | "static" | number;
5 changes: 4 additions & 1 deletion src/react/VGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ViewportComponentAttributes } from "./types";
import { flushSync } from "react-dom";
import { isRTLDocument } from "../core/environment";
import { useRerender } from "./useRerender";

const genKey = (i: number, j: number) => `${i}-${j}`;

/**
Expand Down Expand Up @@ -250,9 +251,11 @@ export const VGrid = forwardRef<VGridHandle, VGridProps>(
const height = getScrollSize(vStore);
const width = getScrollSize(hStore);
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

useIsomorphicLayoutEffect(() => {
const root = rootRef[refKey]!;
const container = containerRef[refKey]!;
// store must be subscribed first because others may dispatch update on init depending on implementation
const unsubscribeVStore = vStore._subscribe(
UPDATE_VIRTUAL_STATE,
Expand All @@ -275,7 +278,7 @@ export const VGrid = forwardRef<VGridHandle, VGridProps>(
}
);
resizer._observeRoot(root);
scroller._observe(root);
scroller._observe(root, container);
return () => {
unsubscribeVStore();
unsubscribeHStore();
Expand Down
22 changes: 14 additions & 8 deletions src/react/Virtualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import { useStatic } from "./useStatic";
import { useLatestRef } from "./useLatestRef";
import { createResizer } from "../core/resizer";
import { ListItem } from "./ListItem";
import { CacheSnapshot, ScrollToIndexOpts } from "../core/types";
import {
CacheSnapshot,
ScrollToIndexOpts,
StartOffsetType,
} from "../core/types";
import { flushSync } from "react-dom";
import { useRerender } from "./useRerender";
import { useChildren } from "./useChildren";
Expand Down Expand Up @@ -120,8 +124,10 @@ export interface VirtualizerProps {
cache?: CacheSnapshot;
/**
* If you put an element before virtualizer, you have to define its height with this prop.
*
* TODO
*/
startMargin?: number;
startOffset?: StartOffsetType;
/**
* A prop for SSR. If set, the specified amount of items will be mounted in the initial rendering regardless of the container size until hydrated.
*/
Expand Down Expand Up @@ -178,7 +184,7 @@ export const Virtualizer = forwardRef<VirtualizerHandle, VirtualizerProps>(
shift,
horizontal: horizontalProp,
cache,
startMargin,
startOffset,
ssrCount,
as: Element = "div",
item: ItemElement = "div",
Expand Down Expand Up @@ -207,13 +213,12 @@ export const Virtualizer = forwardRef<VirtualizerHandle, VirtualizerProps>(
itemSize,
ssrCount,
cache,
!itemSize,
startMargin
!itemSize
);
return [
_store,
createResizer(_store, _isHorizontal),
createScroller(_store, _isHorizontal),
createScroller(_store, _isHorizontal, startOffset),
_isHorizontal,
];
});
Expand Down Expand Up @@ -281,15 +286,16 @@ export const Virtualizer = forwardRef<VirtualizerHandle, VirtualizerProps>(
onScrollEnd[refKey] && onScrollEnd[refKey]();
}
);
const container = containerRef[refKey]!;
const assignScrollableElement = (e: HTMLElement) => {
resizer._observeRoot(e);
scroller._observe(e);
scroller._observe(e, container);
};
if (scrollRef) {
// parent's ref doesn't exist when useLayoutEffect is called
microtask(() => assignScrollableElement(scrollRef[refKey]!));
} else {
assignScrollableElement(containerRef[refKey]!.parentElement!);
assignScrollableElement(container.parentElement!);
}

return () => {
Expand Down
2 changes: 1 addition & 1 deletion src/solid/Virtualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ export const Virtualizer = <T,>(props: VirtualizerProps<T>): JSX.Element => {

const scrollable = containerRef!.parentElement!;
resizer._observeRoot(scrollable);
scroller._observe(scrollable);
scroller._observe(scrollable, containerRef!);

onCleanup(() => {
if (props.ref) {
Expand Down
3 changes: 2 additions & 1 deletion src/svelte/VList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@
);
onMount(() => {
const container = containerRef!;
const root = containerRef.parentElement!;
virtualizer[ON_MOUNT](root);
virtualizer[ON_MOUNT](root, container);
});
onDestroy(() => {
virtualizer[ON_UN_MOUNT]();
Expand Down
4 changes: 2 additions & 2 deletions src/svelte/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ export const createVirtualizer = (
);

return {
[ON_MOUNT]: (scrollable: HTMLElement) => {
[ON_MOUNT]: (scrollable: HTMLElement, container: HTMLElement) => {
resizer._observeRoot(scrollable);
scroller._observe(scrollable);
scroller._observe(scrollable, container);
},
[ON_UN_MOUNT]: () => {
unsubscribeStore();
Expand Down
Loading

0 comments on commit 1e17c7b

Please sign in to comment.