Skip to content

Commit

Permalink
Add Virtualizer and WindowVirtualizer for Solid
Browse files Browse the repository at this point in the history
  • Loading branch information
inokawa committed Feb 5, 2024
1 parent bf2eeba commit 3fea091
Show file tree
Hide file tree
Showing 7 changed files with 655 additions and 281 deletions.
12 changes: 12 additions & 0 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@
"import": "{ VList }",
"limit": "4 kB"
},
{
"name": "solid Virtualizer",
"path": "lib/solid/index.mjs",
"import": "{ Virtualizer }",
"limit": "4 kB"
},
{
"name": "solid WindowVirtualizer",
"path": "lib/solid/index.mjs",
"import": "{ WindowVirtualizer }",
"limit": "4 kB"
},
{
"name": "react-virtuoso Virtuoso",
"path": "node_modules/react-virtuoso/dist/index.mjs",
Expand Down
315 changes: 34 additions & 281 deletions src/solid/VList.tsx
Original file line number Diff line number Diff line change
@@ -1,138 +1,42 @@
/**
* @jsxImportSource solid-js
*/
import {
onMount,
onCleanup,
createEffect,
createSignal,
createMemo,
JSX,
on,
createComputed,
} from "solid-js";
import {
SCROLL_IDLE,
UPDATE_SCROLL_STATE,
UPDATE_SCROLL_EVENT,
UPDATE_SCROLL_END_EVENT,
UPDATE_SIZE_STATE,
overscanStartIndex,
overscanEndIndex,
createVirtualStore,
ACTION_ITEMS_LENGTH_CHANGE,
getScrollSize,
getMinContainerSize,
ItemsRange,
} from "../core/store";
import { createResizer } from "../core/resizer";
import { createScroller } from "../core/scroller";
import { ScrollToIndexOpts } from "../core/types";
import { JSX } from "solid-js";
import { ViewportComponentAttributes } from "./types";
import { ListItem } from "./ListItem";
import { RangedFor } from "./RangedFor";
import { exists } from "../core/utils";
import {
Virtualizer,
VirtualizerHandle,
VirtualizerProps,
} from "./Virtualizer";

/**
* Methods of {@link VList}.
*/
export interface VListHandle {
/**
* Get current scrollTop or scrollLeft.
*/
readonly scrollOffset: number;
/**
* Get current scrollHeight or scrollWidth.
*/
readonly scrollSize: number;
/**
* Get current offsetHeight or offsetWidth.
*/
readonly viewportSize: number;
/**
* Scroll to the item specified by index.
* @param index index of item
* @param opts options
*/
scrollToIndex(index: number, opts?: ScrollToIndexOpts): void;
/**
* Scroll to the given offset.
* @param offset offset from start
*/
scrollTo(offset: number): void;
/**
* Scroll by the given offset.
* @param offset offset from current position
*/
scrollBy(offset: number): void;
}
export interface VListHandle extends VirtualizerHandle {}

/**
* Props of {@link VList}.
*/
export interface VListProps<T> extends ViewportComponentAttributes {
/**
* Get reference to {@link VListHandle}.
*/
ref?: (handle?: VListHandle) => void;
/**
* The data items rendered by this component.
*/
data: T[];
/**
* The elements renderer function.
*/
children: (data: T, index: number) => JSX.Element;
/**
* Number of items to render above/below the visible bounds of the list. Lower value will give better performance but you can increase to avoid showing blank items in fast scrolling.
* @defaultValue 4
*/
overscan?: number;
/**
* Item size hint for unmeasured items. It will help to reduce scroll jump when items are measured if used properly.
*
* - If not set, initial item sizes will be automatically estimated from measured sizes. This is recommended for most cases.
* - If set, you can opt out estimation and use the value as initial item size.
*/
itemSize?: number;
/**
* While true is set, scroll position will be maintained from the end not usual start when items are added to/removed from start. It's recommended to set false if you add to/remove from mid/end of the list because it can cause unexpected behavior. This prop is useful for reverse infinite scrolling.
*/
shift?: boolean;
/**
* If true, rendered as a horizontally scrollable list. Otherwise rendered as a vertically scrollable list.
*/
horizontal?: boolean;
/**
* Callback invoked whenever scroll offset changes.
* @param offset Current scrollTop or scrollLeft.
*/
onScroll?: (offset: number) => void;
/**
* Callback invoked when scrolling stops.
*/
onScrollEnd?: () => void;
/**
* Callback invoked when visible items range changes.
*/
onRangeChange?: (
/**
* The start index of viewable items.
*/
startIndex: number,
/**
* The end index of viewable items.
*/
endIndex: number
) => void;
}
export interface VListProps<T>
extends Pick<
VirtualizerProps<T>,
| "ref"
| "data"
| "children"
| "overscan"
| "itemSize"
| "shift"
| "horizontal"
| "onScroll"
| "onScrollEnd"
| "onRangeChange"
>,
ViewportComponentAttributes {}

/**
* Virtualized list component. See {@link VListProps} and {@link VListHandle}.
*/
export const VList = <T,>(props: VListProps<T>): JSX.Element => {
let rootRef: HTMLDivElement | undefined;

const {
ref: _ref,
data: _data,
Expand All @@ -148,134 +52,9 @@ export const VList = <T,>(props: VListProps<T>): JSX.Element => {
...attrs
} = props;

const store = createVirtualStore(
props.data.length,
itemSize ?? 40,
undefined,
undefined,
!itemSize
);
const resizer = createResizer(store, horizontal);
const scroller = createScroller(store, horizontal);

const [rerender, setRerender] = createSignal(store._getStateVersion());

const unsubscribeStore = store._subscribe(
UPDATE_SCROLL_STATE + UPDATE_SIZE_STATE,
() => {
setRerender(store._getStateVersion());
}
);

const unsubscribeOnScroll = store._subscribe(UPDATE_SCROLL_EVENT, () => {
props.onScroll?.(store._getScrollOffset());
});
const unsubscribeOnScrollEnd = store._subscribe(
UPDATE_SCROLL_END_EVENT,
() => {
props.onScrollEnd?.();
}
);

const isSameRange = (prev: ItemsRange, next: ItemsRange): boolean => {
return prev[0] === next[0] && prev[1] === next[1];
};

const range = createMemo<ItemsRange>((prev) => {
rerender();
const next = store._getRange();
if (prev && isSameRange(prev, next)) {
return prev;
}
return next;
});
const scrollDirection = createMemo(
() => rerender() && store._getScrollDirection()
);
const totalSize = createMemo(() => rerender() && store._getTotalSize());
// https://github.com/inokawa/virtua/issues/252#issuecomment-1822861368
const minSize = createMemo(() => rerender() && getMinContainerSize(store));

const jumpCount = createMemo(() => rerender() && store._getJumpCount());

const overscanedRange = createMemo<ItemsRange>((prev) => {
const overscan = props.overscan ?? 4;
const [startIndex, endIndex] = range();
const next: ItemsRange = [
overscanStartIndex(startIndex, overscan, scrollDirection()),
overscanEndIndex(
endIndex,
overscan,
scrollDirection(),
props.data.length
),
];
if (prev && isSameRange(prev, next)) {
return prev;
}
return next;
});

onMount(() => {
if (props.ref) {
props.ref({
get scrollOffset() {
return store._getScrollOffset();
},
get scrollSize() {
return getScrollSize(store);
},
get viewportSize() {
return store._getViewportSize();
},
scrollToIndex: scroller._scrollToIndex,
scrollTo: scroller._scrollTo,
scrollBy: scroller._scrollBy,
});
}

resizer._observeRoot(rootRef!);
scroller._observe(rootRef!);

onCleanup(() => {
if (props.ref) {
props.ref();
}

unsubscribeStore();
unsubscribeOnScroll();
unsubscribeOnScrollEnd();
resizer._dispose();
scroller._dispose();
});
});

createComputed(
on(
() => props.data.length,
(len, prevLen) => {
if (exists(prevLen) && len !== prevLen) {
store._update(ACTION_ITEMS_LENGTH_CHANGE, [len, props.shift]);
}
}
)
);

createEffect(
on(jumpCount, () => {
scroller._fixScrollJump();
})
);

createEffect(() => {
const next = range();
props.onRangeChange && props.onRangeChange(next[0], next[1]);
});

return (
<div
{...attrs}
ref={rootRef}
style={{
display: horizontal ? "inline-block" : "block",
[horizontal ? "overflow-x" : "overflow-y"]: "auto",
Expand All @@ -285,45 +64,19 @@ export const VList = <T,>(props: VListProps<T>): JSX.Element => {
...props.style,
}}
>
<div
style={{
// contain: "content",
"overflow-anchor": "none", // opt out browser's scroll anchoring because it will conflict to scroll anchoring of virtualizer
flex: "none", // flex style on parent can break layout
position: "relative",
visibility: "hidden",
width: horizontal ? totalSize() + "px" : "100%",
height: horizontal ? "100%" : totalSize() + "px",
[horizontal ? "min-width" : "min-height"]: minSize() + "px",
"pointer-events": scrollDirection() !== SCROLL_IDLE ? "none" : "auto",
}}
<Virtualizer
ref={props.ref}
data={props.data}
overscan={props.overscan}
itemSize={props.itemSize}
shift={props.shift}
horizontal={horizontal}
onScroll={props.onScroll}
onScrollEnd={props.onScrollEnd}
onRangeChange={props.onRangeChange}
>
<RangedFor
_each={props.data}
_range={overscanedRange()}
_render={(data, index) => {
const offset = createMemo(() => {
rerender();
return store._getItemOffset(index);
});
const hide = createMemo(() => {
rerender();
return store._isUnmeasuredItem(index);
});

return (
<ListItem
_index={index}
_resizer={resizer._observeItem}
_offset={offset()}
_hide={hide()}
_children={props.children(data, index)}
_isHorizontal={horizontal}
/>
);
}}
/>
</div>
{props.children}
</Virtualizer>
</div>
);
};
Loading

0 comments on commit 3fea091

Please sign in to comment.