+
{items[index]}
)}
@@ -42,18 +56,21 @@ export default function App() {
}
```
+### React Native
+
```tsx
import { View, Text } from "react-native";
+import { VirtualList } from "@scrolloop/react-native";
-const items = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
+function App() {
+ const items = Array.from({ length: 1000 }, (_, i) => `Item #${i}`);
-export default function App() {
return (
(
-
+
{items[index]}
)}
@@ -62,91 +79,12 @@ export default function App() {
}
```
-### InfiniteList
-
-```tsx
-interface User {
- id: string;
- name: string;
- email: string;
-}
-
-export default function UserList() {
- return (
-
- fetchPage={fetchFunction()}
- pageSize={20}
- itemSize={60}
- height={800}
- renderItem={(user, index, style) => (
-
-
{user.name}
-
{user.email}
-
- )}
- renderLoading={() => 로딩 중...
}
- renderError={(error, retry) => (
-
-
{error.message}
-
-
- )}
- renderEmpty={() => 데이터가 없습니다.
}
- />
- );
-}
-```
-
-```tsx
-import { View, Text, TouchableOpacity } from "react-native";
-
-interface User {
- id: string;
- name: string;
- email: string;
-}
+## Packages
-export default function UserList() {
- return (
-
- fetchPage={fetchFunction()}
- pageSize={20}
- itemSize={60}
- height={800}
- renderItem={(user, index, style) => (
-
- {user.name}
- {user.email}
-
- )}
- renderLoading={() => 로딩 중...}
- renderError={(error, retry) => (
-
- {error.message}
-
- 재시도
-
-
- )}
- renderEmpty={() => 데이터가 없습니다.}
- />
- );
-}
-```
+- **@scrolloop/core**: Platform-agnostic virtual scrolling logic
+- **@scrolloop/react**: React implementation
+- **@scrolloop/react-native**: React Native implementation
-## license
+## License
MIT
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index bc9ddfe..827f26e 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -8,7 +8,7 @@ export default defineConfig({
base: "/scrolloop/",
head: [
- ["link", { rel: "icon", href: "/favicon.ico" }],
+ ["link", { rel: "icon", href: "/favicon.svg" }],
["meta", { name: "theme-color", content: "#7c3aed" }],
],
diff --git a/docs/.vitepress/theme/Hero.vue b/docs/.vitepress/theme/Hero.vue
index 618e1e4..d1b8a44 100644
--- a/docs/.vitepress/theme/Hero.vue
+++ b/docs/.vitepress/theme/Hero.vue
@@ -1,5 +1,6 @@
@@ -181,71 +185,49 @@ watch(
overflow: hidden;
}
-.digit-item {
+.digit-strip {
position: absolute;
- inset: 0;
+ top: 0;
+ left: 0;
+ width: 100%;
display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.slot-enter-up {
- animation: slotEnterUp ease-out forwards;
-}
-
-.slot-enter-down {
- animation: slotEnterDown ease-out forwards;
+ flex-direction: column;
+ transform: translateY(0);
+ will-change: transform;
}
-.slot-leave-up {
- animation: slotLeaveUp ease-out forwards;
+.digit-item {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 0.75em;
+ flex-shrink: 0;
+ line-height: 1;
}
-.slot-leave-down {
- animation: slotLeaveDown ease-out forwards;
+.strip-move-up {
+ animation: stripMoveUp cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
-@keyframes slotEnterUp {
- from {
- transform: translateY(100%);
- opacity: 0;
- }
- to {
- transform: translateY(0);
- opacity: 1;
- }
+.strip-move-down {
+ animation: stripMoveDown cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
-@keyframes slotLeaveUp {
+@keyframes stripMoveUp {
from {
transform: translateY(0);
- opacity: 1;
}
to {
- transform: translateY(-100%);
- opacity: 0;
+ transform: translateY(-0.75em);
}
}
-@keyframes slotEnterDown {
+@keyframes stripMoveDown {
from {
- transform: translateY(-100%);
- opacity: 0;
+ transform: translateY(-0.75em);
}
to {
transform: translateY(0);
- opacity: 1;
- }
-}
-
-@keyframes slotLeaveDown {
- from {
- transform: translateY(0);
- opacity: 1;
- }
- to {
- transform: translateY(100%);
- opacity: 0;
}
}
diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg
new file mode 100644
index 0000000..ddce305
--- /dev/null
+++ b/docs/public/favicon.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 0a03e1f..c51af9d 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"type": "git",
"url": "git+https://github.com/976520/scrolloop.git"
},
- "description": "Just a virtual scrolling library for React",
+ "description": "The modern scrolling component for React and React Native",
"type": "module",
"workspaces": [
"packages/*"
diff --git a/packages/core/src/strategies/layout/FixedLayoutStrategy.ts b/packages/core/src/strategies/layout/FixedLayoutStrategy.ts
index 2d4309b..46a0f66 100644
--- a/packages/core/src/strategies/layout/FixedLayoutStrategy.ts
+++ b/packages/core/src/strategies/layout/FixedLayoutStrategy.ts
@@ -1,20 +1,24 @@
-import type { Range } from '../../types';
-import type { LayoutStrategy } from './LayoutStrategy';
-import { clamp } from '../../utils/clamp';
+import type { Range } from "../../types";
+import type { LayoutStrategy } from "./LayoutStrategy";
+import { clamp } from "../../utils/clamp";
export class FixedLayoutStrategy implements LayoutStrategy {
- constructor(private itemSize: number) {}
+ readonly #itemSize: number;
+
+ constructor(itemSize: number) {
+ this.#itemSize = itemSize;
+ }
getItemOffset(index: number): number {
- return index * this.itemSize;
+ return index * this.#itemSize;
}
getItemSize(_index: number): number {
- return this.itemSize;
+ return this.#itemSize;
}
getTotalSize(count: number): number {
- return count * this.itemSize;
+ return count * this.#itemSize;
}
getVisibleRange(
@@ -22,16 +26,11 @@ export class FixedLayoutStrategy implements LayoutStrategy {
viewportSize: number,
count: number
): Range {
- const startIndex = clamp(
- 0,
- Math.floor(scrollOffset / this.itemSize),
- count - 1
- );
+ const startIndex = clamp(0, (scrollOffset / this.#itemSize) | 0, count - 1);
- const visibleCount = Math.ceil(viewportSize / this.itemSize);
+ const visibleCount = Math.ceil(viewportSize / this.#itemSize);
const endIndex = Math.min(count - 1, startIndex + visibleCount);
return { startIndex, endIndex };
}
}
-
diff --git a/packages/core/src/strategies/scroll/VirtualScrollSource.ts b/packages/core/src/strategies/scroll/VirtualScrollSource.ts
index 08a9ac6..88a7add 100644
--- a/packages/core/src/strategies/scroll/VirtualScrollSource.ts
+++ b/packages/core/src/strategies/scroll/VirtualScrollSource.ts
@@ -1,40 +1,40 @@
import type { ScrollSource } from "./ScrollSource";
export class VirtualScrollSource implements ScrollSource {
- private scrollOffset = 0;
- private viewportSize = 0;
- private listeners = new Set<(offset: number) => void>();
+ #scrollOffset = 0;
+ #viewportSize = 0;
+ #listeners = new Set<(offset: number) => void>();
getScrollOffset(): number {
- return this.scrollOffset;
+ return this.#scrollOffset;
}
getViewportSize(): number {
- return this.viewportSize;
+ return this.#viewportSize;
}
setScrollOffset(offset: number): void {
- if (this.scrollOffset !== offset) {
- this.scrollOffset = offset;
- this.notifyListeners();
+ if (this.#scrollOffset !== offset) {
+ this.#scrollOffset = offset;
+ this.#notifyListeners();
}
}
setViewportSize(size: number): void {
- if (this.viewportSize !== size) {
- this.viewportSize = size;
- this.notifyListeners();
+ if (this.#viewportSize !== size) {
+ this.#viewportSize = size;
+ this.#notifyListeners();
}
}
subscribe(callback: (offset: number) => void): () => void {
- this.listeners.add(callback);
+ this.#listeners.add(callback);
return () => {
- this.listeners.delete(callback);
+ this.#listeners.delete(callback);
};
}
- private notifyListeners(): void {
- this.listeners.forEach((listener) => listener(this.scrollOffset));
+ #notifyListeners(): void {
+ this.#listeners.forEach((listener) => listener(this.#scrollOffset));
}
}
diff --git a/packages/core/src/utils/calculateVirtualRange.ts b/packages/core/src/utils/calculateVirtualRange.ts
index 1537ef1..39b1479 100644
--- a/packages/core/src/utils/calculateVirtualRange.ts
+++ b/packages/core/src/utils/calculateVirtualRange.ts
@@ -8,7 +8,7 @@ export function calculateVirtualRange(
overscan: number,
prevScrollOffset?: number
): VirtualRange {
- const startIndex = Math.max(0, Math.floor(scrollOffset / itemSize));
+ const startIndex = Math.max(0, (scrollOffset / itemSize) | 0);
const endIndex = Math.min(
totalCount - 1,
startIndex + Math.ceil(viewportSize / itemSize)
@@ -22,7 +22,7 @@ export function calculateVirtualRange(
const overscanStart = isScrollingUp ? overscan * 1.5 : overscan;
const overscanEnd = isScrollingDown ? overscan * 1.5 : overscan;
- const renderStart = Math.max(0, Math.floor(startIndex - overscanStart));
+ const renderStart = Math.max(0, (startIndex - overscanStart) | 0);
const renderEnd = Math.min(totalCount - 1, Math.ceil(endIndex + overscanEnd));
return {
diff --git a/packages/core/src/virtualizer/Virtualizer.ts b/packages/core/src/virtualizer/Virtualizer.ts
index d127bde..22ec056 100644
--- a/packages/core/src/virtualizer/Virtualizer.ts
+++ b/packages/core/src/virtualizer/Virtualizer.ts
@@ -2,108 +2,135 @@ import type {
VirtualItem,
VirtualizerState,
VirtualizerOptions,
-} from '../types';
-import type { LayoutStrategy } from '../strategies/layout/LayoutStrategy';
-import type { ScrollSource } from '../strategies/scroll/ScrollSource';
-import type { Plugin } from '../plugins/Plugin';
+} from "../types";
+import type { LayoutStrategy } from "../strategies/layout/LayoutStrategy";
+import type { ScrollSource } from "../strategies/scroll/ScrollSource";
+import type { Plugin } from "../plugins/Plugin";
export class Virtualizer {
- private count: number;
- private overscan: number;
- private plugins: Plugin[];
- private onChange?: (state: VirtualizerState) => void;
+ #count: number;
+ readonly #overscan: number;
+ #plugins: Plugin[];
+ readonly #onChange?: (state: VirtualizerState) => void;
- private layoutStrategy: LayoutStrategy;
- private scrollSource: ScrollSource;
+ readonly #layoutStrategy: LayoutStrategy;
+ readonly #scrollSource: ScrollSource;
- private state: VirtualizerState;
- private unsubscribe?: () => void;
+ #state: VirtualizerState;
+ readonly #unsubscribe?: () => void;
+
+ #prevRenderRange?: { startIndex: number; endIndex: number };
+ #prevVirtualItems?: VirtualItem[];
constructor(
layoutStrategy: LayoutStrategy,
scrollSource: ScrollSource,
options: VirtualizerOptions
) {
- this.layoutStrategy = layoutStrategy;
- this.scrollSource = scrollSource;
- this.count = options.count;
- this.overscan = options.overscan ?? 4;
- this.onChange = options.onChange;
- this.plugins = [];
+ this.#layoutStrategy = layoutStrategy;
+ this.#scrollSource = scrollSource;
+ this.#count = options.count;
+ this.#overscan = options.overscan ?? 4;
+ this.#onChange = options.onChange;
+ this.#plugins = [];
- this.state = this.calculateState();
+ this.#state = this.#calculateState();
- this.plugins.forEach((plugin) => plugin.onInit?.());
+ this.#plugins.forEach((plugin) => plugin.onInit?.());
- this.unsubscribe = this.scrollSource.subscribe(() => {
+ this.#unsubscribe = this.#scrollSource.subscribe(() => {
this.update();
});
}
addPlugin(plugin: Plugin): void {
- this.plugins.push(plugin);
+ this.#plugins.push(plugin);
plugin.onInit?.();
+ this.#prevRenderRange = undefined;
+ this.#prevVirtualItems = undefined;
}
getState(): VirtualizerState {
- return this.state;
+ return this.#state;
}
setCount(count: number): void {
- if (this.count !== count) {
- this.count = count;
+ if (this.#count !== count) {
+ this.#count = count;
+ this.#prevRenderRange = undefined;
+ this.#prevVirtualItems = undefined;
this.update();
}
}
update(): void {
- const newState = this.calculateState();
+ const newState = this.#calculateState();
let finalState = newState;
- for (const plugin of this.plugins) {
+ for (const plugin of this.#plugins) {
const result = plugin.beforeStateChange?.(finalState);
if (result) finalState = result;
}
- this.state = finalState;
+ this.#state = finalState;
- this.plugins.forEach((plugin) => plugin.afterStateChange?.(this.state));
+ this.#plugins.forEach((plugin) => plugin.afterStateChange?.(this.#state));
- this.onChange?.(this.state);
+ this.#onChange?.(this.#state);
}
- private calculateState(): VirtualizerState {
- const scrollOffset = this.scrollSource.getScrollOffset();
- const viewportSize = this.scrollSource.getViewportSize();
- const totalSize = this.layoutStrategy.getTotalSize(this.count);
+ #calculateState(): VirtualizerState {
+ const scrollOffset = this.#scrollSource.getScrollOffset();
+ const viewportSize = this.#scrollSource.getViewportSize();
+ const totalSize = this.#layoutStrategy.getTotalSize(this.#count);
- let visibleRange = this.layoutStrategy.getVisibleRange(
+ let visibleRange = this.#layoutStrategy.getVisibleRange(
scrollOffset,
viewportSize,
- this.count
+ this.#count
);
let renderRange = {
- startIndex: Math.max(0, visibleRange.startIndex - this.overscan),
- endIndex: Math.min(this.count - 1, visibleRange.endIndex + this.overscan),
+ startIndex: (visibleRange.startIndex - this.#overscan) | 0,
+ endIndex: (visibleRange.endIndex + this.#overscan) | 0,
};
-
- for (const plugin of this.plugins) {
+
+ if (renderRange.startIndex < 0) renderRange.startIndex = 0;
+ if (renderRange.endIndex > this.#count - 1)
+ renderRange.endIndex = this.#count - 1;
+
+ for (const plugin of this.#plugins) {
if (plugin.onRangeCalculated) {
- renderRange = plugin.onRangeCalculated(visibleRange, this.count);
+ renderRange = plugin.onRangeCalculated(visibleRange, this.#count);
}
}
- const virtualItems: VirtualItem[] = [];
- for (let i = renderRange.startIndex; i <= renderRange.endIndex; i++) {
- const start = this.layoutStrategy.getItemOffset(i);
- const size = this.layoutStrategy.getItemSize(i);
- virtualItems.push({
- index: i,
- start,
- size,
- end: start + size,
- });
+ let virtualItems: VirtualItem[];
+ if (
+ this.#prevRenderRange &&
+ this.#prevVirtualItems &&
+ this.#prevRenderRange.startIndex === renderRange.startIndex &&
+ this.#prevRenderRange.endIndex === renderRange.endIndex
+ ) {
+ virtualItems = this.#prevVirtualItems;
+ } else {
+ virtualItems = [];
+ const startIdx = renderRange.startIndex;
+ const endIdx = renderRange.endIndex;
+
+ for (let i = startIdx; i <= endIdx; i++) {
+ const start = this.#layoutStrategy.getItemOffset(i);
+ const size = this.#layoutStrategy.getItemSize(i);
+ virtualItems.push({
+ index: i,
+ start,
+ size,
+ end: start + size,
+ });
+ }
+
+ this.#prevRenderRange = renderRange;
+ this.#prevVirtualItems = virtualItems;
}
return {
@@ -117,8 +144,7 @@ export class Virtualizer {
}
destroy(): void {
- this.unsubscribe?.();
- this.plugins.forEach((plugin) => plugin.onDestroy?.());
+ this.#unsubscribe?.();
+ this.#plugins.forEach((plugin) => plugin.onDestroy?.());
}
}
-
diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts
index 3a82893..5b3cb55 100644
--- a/packages/core/tsup.config.ts
+++ b/packages/core/tsup.config.ts
@@ -10,10 +10,15 @@ export default defineConfig({
minify: "terser",
terserOptions: {
compress: {
- passes: 2,
+ passes: 3,
drop_console: true,
drop_debugger: true,
pure_funcs: ["console.log", "console.debug"],
+ unsafe: false,
+ unsafe_arrows: false,
+ unsafe_methods: true,
+ booleans_as_integers: false,
+ ecma: 2020,
},
mangle: {
safari10: false,
@@ -22,7 +27,7 @@ export default defineConfig({
comments: false,
},
},
- target: "es2020",
+ target: "es2022",
outExtension({ format }) {
return {
js: format === "esm" ? ".mjs" : ".cjs",
diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts
index a6675ff..61f9654 100644
--- a/packages/react-native/tsup.config.ts
+++ b/packages/react-native/tsup.config.ts
@@ -10,10 +10,15 @@ export default defineConfig({
minify: "terser",
terserOptions: {
compress: {
- passes: 2,
+ passes: 3,
drop_console: true,
drop_debugger: true,
pure_funcs: ["console.log", "console.debug"],
+ unsafe: false,
+ unsafe_arrows: false,
+ unsafe_methods: true,
+ booleans_as_integers: false,
+ ecma: 2020,
},
mangle: {
safari10: false,
@@ -22,9 +27,8 @@ export default defineConfig({
comments: false,
},
},
- target: "es2024",
- external: ["react", "react-native"],
- noExternal: ["@scrolloop/core", "@scrolloop/shared"],
+ target: "es2020",
+ external: ["react", "react-native", "@scrolloop/core", "@scrolloop/shared"],
outExtension({ format }) {
return {
js: format === "esm" ? ".mjs" : ".cjs",
diff --git a/packages/react/src/components/InfiniteList.tsx b/packages/react/src/components/InfiniteList.tsx
index 59a1852..c90d494 100644
--- a/packages/react/src/components/InfiniteList.tsx
+++ b/packages/react/src/components/InfiniteList.tsx
@@ -37,99 +37,63 @@ function InfiniteListInner(props: InfiniteListProps) {
);
const containerRef = useRef(null);
+ const scrollTopRef = useRef(0);
- const initialPagesRef = useRef