diff --git a/__tests__/integrations/infinite.test.tsx b/__tests__/integrations/infinite.test.tsx new file mode 100644 index 00000000..d54a899a --- /dev/null +++ b/__tests__/integrations/infinite.test.tsx @@ -0,0 +1,209 @@ +import { describe, expect, it } from "bun:test"; +import { useInfiniteMode } from "../../src/integrations/infinite"; +import type { LegendListRenderItemProps, OnViewableItemsChangedInfo, ViewToken } from "../../src/types.base"; +import TestRenderer, { act } from "../helpers/testRenderer"; +import "../setup"; + +const KEY_SEPARATOR = "โŸ"; + +type Item = { id: string; label: string }; + +const makeItems = (count: number): Item[] => + Array.from({ length: count }, (_, i) => ({ id: `item-${i}`, label: `Item ${i}` })); + +type HookProps = Parameters>[0]; +type HookResult = ReturnType>; + +function renderInfiniteMode( + props: Partial & { data: readonly Item[] }, + infiniteMode: boolean | { copies?: number } | undefined, +): HookResult { + const fullProps = { + renderItem: () => null, + ...props, + } as HookProps; + + let result: HookResult | undefined; + function Probe() { + result = useInfiniteMode(fullProps, infiniteMode, null); + return null; + } + + act(() => { + TestRenderer.create(); + }); + + return result!; +} + +describe("useInfiniteMode", () => { + it("passes props through unchanged when disabled", () => { + const data = makeItems(4); + const renderItem = () => null; + const { props } = renderInfiniteMode({ data, renderItem }, undefined); + + expect(props.data).toBe(data); + expect(props.renderItem).toBe(renderItem); + }); + + it("passes props through unchanged for empty data", () => { + const data: Item[] = []; + const { props } = renderInfiniteMode({ data }, true); + + expect(props.data).toBe(data); + }); + + it("repeats data into an odd number of copies of at least 9", () => { + const data = makeItems(8); + const { props } = renderInfiniteMode({ data }, true); + + expect(props.data.length).toBe(8 * 9); + expect(props.data[0]).toBe(data[0]); + expect(props.data[8 * 5 + 3]).toBe(data[3]); + }); + + it("scales copies up for short datasets", () => { + const { props } = renderInfiniteMode({ data: makeItems(2) }, true); + + // ceil(40 / 2) = 20, bumped to odd = 21 + expect(props.data.length).toBe(2 * 21); + }); + + it("bumps configured even copies to odd", () => { + const { props } = renderInfiniteMode({ data: makeItems(4) }, { copies: 10 }); + + expect(props.data.length).toBe(4 * 11); + }); + + it("keys each virtual copy uniquely with the real key as prefix", () => { + const data = makeItems(4); + const keyExtractor = (item: Item) => item.id; + const { props } = renderInfiniteMode({ data, keyExtractor }, true); + + expect(props.keyExtractor!(data[1], 1)).toBe(`item-1${KEY_SEPARATOR}0`); + expect(props.keyExtractor!(data[1], 5)).toBe(`item-1${KEY_SEPARATOR}1`); + expect(props.keyExtractor!(data[1], 5)).not.toBe(props.keyExtractor!(data[1], 9)); + }); + + it("maps virtual indices back to real indices in renderItem and adds infiniteIndex", () => { + const data = makeItems(4); + const received: LegendListRenderItemProps[] = []; + const { props } = renderInfiniteMode( + { + data, + renderItem: (itemProps: LegendListRenderItemProps) => { + received.push(itemProps); + return null; + }, + }, + true, + ); + + props.renderItem({ + data: props.data, + extraData: undefined, + index: 4 * 5 + 2, + item: data[2], + type: undefined, + } as LegendListRenderItemProps); + + expect(received).toHaveLength(1); + expect(received[0].index).toBe(2); + expect(received[0].infiniteIndex).toBe(4 * 5 + 2); + expect(received[0].data).toBe(data); + }); + + it("starts scroll at the middle copy, offset by initialScrollIndex", () => { + const data = makeItems(8); + const middleCopyBase = 8 * Math.floor(9 / 2); + + expect(renderInfiniteMode({ data }, true).props.initialScrollIndex).toBe(middleCopyBase); + expect(renderInfiniteMode({ data, initialScrollIndex: 2 }, true).props.initialScrollIndex).toBe( + middleCopyBase + 2, + ); + expect( + renderInfiniteMode({ data, initialScrollIndex: { index: 3, viewOffset: 10 } }, true).props + .initialScrollIndex, + ).toEqual({ + index: middleCopyBase + 3, + viewOffset: 10, + }); + }); + + it("wraps getItemType and getFixedItemSize with real indices", () => { + const data = makeItems(4); + const typeIndices: number[] = []; + const sizeIndices: number[] = []; + const { props } = renderInfiniteMode( + { + data, + getFixedItemSize: (_item: Item, index: number) => { + sizeIndices.push(index); + return 100; + }, + getItemType: (_item: Item, index: number) => { + typeIndices.push(index); + return "row"; + }, + }, + true, + ); + + props.getItemType!(data[3], 4 * 2 + 3); + props.getFixedItemSize!(data[1], 4 * 7 + 1, "row"); + + expect(typeIndices).toEqual([3]); + expect(sizeIndices).toEqual([1]); + }); + + it("strips onStartReached and onEndReached", () => { + const { props } = renderInfiniteMode( + { + data: makeItems(4), + onEndReached: () => {}, + onStartReached: () => {}, + }, + true, + ); + + expect(props.onEndReached).toBeUndefined(); + expect(props.onStartReached).toBeUndefined(); + }); + + it("maps viewability callbacks back to real indices and keys", () => { + const data = makeItems(4); + const received: OnViewableItemsChangedInfo[] = []; + const { props } = renderInfiniteMode( + { + data, + keyExtractor: (item: Item) => item.id, + onViewableItemsChanged: (info: OnViewableItemsChangedInfo) => { + received.push(info); + }, + }, + true, + ); + + const virtualToken: ViewToken = { + containerId: 0, + index: 4 * 5 + 1, + isViewable: true, + item: data[1], + key: `item-1${KEY_SEPARATOR}5`, + }; + props.onViewableItemsChanged!({ + changed: [virtualToken], + end: 4 * 5 + 2, + endBuffered: 4 * 5 + 3, + start: 4 * 5 + 1, + startBuffered: 4 * 5, + viewableItems: [virtualToken], + }); + + expect(received).toHaveLength(1); + expect(received[0].viewableItems[0].index).toBe(1); + expect(received[0].viewableItems[0].key).toBe("item-1"); + expect(received[0].start).toBe(1); + expect(received[0].end).toBe(2); + }); +}); diff --git a/bunfig.toml b/bunfig.toml index c38ecc16..288cbebe 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,4 +2,5 @@ saveTextLockfile = true [test] +root = "./__tests__" preload = ["./__tests__/setup.ts"] \ No newline at end of file diff --git a/example/metro.config.js b/example/metro.config.js index ccf8e2b2..f6206e67 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -15,6 +15,7 @@ config.watchFolders = [listRoot, sharedExamplesRoot]; config.resolver.nodeModulesPaths = [path.resolve(projectRoot, 'node_modules'), path.resolve(listRoot, 'node_modules')]; const defaultResolveRequest = config.resolver.resolveRequest; const listEntrypoints = { + '@legendapp/list/infinite': path.join(listRoot, 'integrations/infinite'), '@legendapp/list/keyboard': path.join(listRoot, 'integrations/keyboard'), '@legendapp/list/react': path.join(listRoot, 'react'), '@legendapp/list/react-native': path.join(listRoot, 'react-native'), diff --git a/example/package.json b/example/package.json index 6ae8a019..a3cef90d 100644 --- a/example/package.json +++ b/example/package.json @@ -15,7 +15,6 @@ "web": "EXPO_PUBLIC_LEGEND_LIST_MODE=examples expo start --web", "web:fixtures": "EXPO_PUBLIC_LEGEND_LIST_MODE=fixtures expo start --web", "test": "jest --watchAll", - "lint": "expo lint", "prebuild:clean": "rm -rf ios android && npx expo prebuild --clean && npx expo prebuild" }, "jest": { diff --git a/example/screens/fixtures/infinite-carousel-state.tsx b/example/screens/fixtures/infinite-carousel-state.tsx new file mode 100644 index 00000000..361fd33b --- /dev/null +++ b/example/screens/fixtures/infinite-carousel-state.tsx @@ -0,0 +1,241 @@ +import { useCallback, useState } from "react"; +import { Pressable, StyleSheet, Text, useWindowDimensions, View } from "react-native"; + +import { InfiniteLegendList } from "@legendapp/list/infinite"; +import { useRecyclingState } from "@legendapp/list/react-native"; + +type CarouselItem = { + id: string; + title: string; + emoji: string; + color: string; + goals: number; +}; + +const ITEMS: CarouselItem[] = [ + { color: "#3C3B6E", emoji: "๐Ÿ‡บ๐Ÿ‡ธ", goals: 0, id: "usa", title: "USA" }, + { color: "#006847", emoji: "๐Ÿ‡ฒ๐Ÿ‡ฝ", goals: 0, id: "mexico", title: "Mexico" }, + { color: "#D52B1E", emoji: "๐Ÿ‡จ๐Ÿ‡ฆ", goals: 0, id: "canada", title: "Canada" }, + { color: "#74ACDF", emoji: "๐Ÿ‡ฆ๐Ÿ‡ท", goals: 0, id: "argentina", title: "Argentina" }, + { color: "#009C3B", emoji: "๐Ÿ‡ง๐Ÿ‡ท", goals: 0, id: "brazil", title: "Brazil" }, + { color: "#0055A4", emoji: "๐Ÿ‡ซ๐Ÿ‡ท", goals: 0, id: "france", title: "France" }, +]; + +const CAROUSEL_HEIGHT = 420; + +const CounterRow = ({ + label, + sublabel, + value, + onPress, + tone, +}: { + label: string; + sublabel: string; + value: number; + onPress: () => void; + tone: "good" | "warn" | "bad"; +}) => ( + + + {label} + {sublabel} + + {value} + +); + +const Card = ({ + item, + index, + infiniteIndex, + itemWidth, + onGoal, +}: { + item: CarouselItem; + index: number; + infiniteIndex: number; + itemWidth: number; + onGoal: (id: string) => void; +}) => { + const goalCount = item.goals; + const [copyCount, setCopyCount] = useRecyclingState(() => 0); + const [containerCount, setContainerCount] = useState(0); + + return ( + + {item.emoji} + {item.title} + + item {index} ยท copy {Math.floor(infiniteIndex / ITEMS.length)} + + + onGoal(item.id)} + sublabel="stored in the data item โ€” persists everywhere" + tone="good" + value={goalCount} + /> + setCopyCount((prev: number) => prev + 1)} + sublabel="per container key โ€” resets on other copies" + tone="warn" + value={copyCount} + /> + setContainerCount((prev) => prev + 1)} + sublabel="per container โ€” bleeds across items!" + tone="bad" + value={containerCount} + /> + + + ); +}; + +export default function InfiniteCarouselStateFixtureScreen() { + const { width: windowWidth } = useWindowDimensions(); + const itemWidth = Math.round(windowWidth * 0.8); + const sidePadding = (windowWidth - itemWidth) / 2; + + const [items, setItems] = useState(ITEMS); + const handleGoal = useCallback((id: string) => { + setItems((prev) => prev.map((item) => (item.id === id ? { ...item, goals: item.goals + 1 } : item))); + }, []); + + return ( + + + itemWidth} + horizontal + keyExtractor={(item) => item.id} + recycleItems + renderItem={({ item, index, infiniteIndex }) => ( + + )} + showsHorizontalScrollIndicator={false} + snapToInterval={itemWidth} + style={{ height: CAROUSEL_HEIGHT }} + /> + + + Local state in an infinite (recycled) carousel + + Tap the counters on a card, then swipe a full loop back to it (or just keep swiping one direction). + The card header shows which virtual copy you are looking at. + + + โšฝ Goals persist โ€” they live in the data item itself, updated immutably at the screen level (the + mutable-cells pattern). Every copy renders the same item object.{"\n"} + โš ๏ธ useRecyclingState is scoped to one virtual copy โ€” it resets when you reach the item via another + copy.{"\n"}โŒ useState sticks to the recycled container and shows up on unrelated items. + + + + ); +} + +const styles = StyleSheet.create({ + card: { + alignItems: "center", + borderRadius: 20, + height: CAROUSEL_HEIGHT - 40, + marginHorizontal: 8, + marginTop: 16, + paddingHorizontal: 14, + paddingTop: 18, + }, + cardEmoji: { + fontSize: 44, + }, + cardIndex: { + color: "rgba(255,255,255,0.8)", + fontSize: 13, + fontWeight: "600", + marginTop: 2, + }, + cardTitle: { + color: "#FFFFFF", + fontSize: 20, + fontWeight: "700", + marginTop: 6, + }, + container: { + backgroundColor: "#F8F5F2", + flex: 1, + paddingTop: 12, + }, + counterLabel: { + color: "#1f1f1f", + fontSize: 14, + fontWeight: "700", + }, + counterRow: { + alignItems: "center", + borderRadius: 12, + flexDirection: "row", + justifyContent: "space-between", + paddingHorizontal: 12, + paddingVertical: 8, + }, + counterRow_bad: { + backgroundColor: "rgba(255,255,255,0.72)", + borderColor: "#B3261E", + borderWidth: 1, + }, + counterRow_good: { + backgroundColor: "rgba(255,255,255,0.92)", + }, + counterRow_warn: { + backgroundColor: "rgba(255,255,255,0.82)", + }, + counterSublabel: { + color: "#5B5650", + fontSize: 11, + marginTop: 1, + }, + counters: { + alignSelf: "stretch", + gap: 8, + marginTop: 14, + }, + counterText: { + flex: 1, + paddingRight: 8, + }, + counterValue: { + color: "#1f1f1f", + fontSize: 22, + fontWeight: "800", + minWidth: 34, + textAlign: "right", + }, + explainer: { + paddingHorizontal: 20, + paddingTop: 16, + }, + explainerText: { + color: "#5B5650", + fontSize: 13, + lineHeight: 19, + marginTop: 8, + }, + explainerTitle: { + color: "#1f1f1f", + fontSize: 16, + fontWeight: "700", + }, +}); diff --git a/example/screens/fixtures/infinite-carousel.tsx b/example/screens/fixtures/infinite-carousel.tsx new file mode 100644 index 00000000..c9dd49fd --- /dev/null +++ b/example/screens/fixtures/infinite-carousel.tsx @@ -0,0 +1,367 @@ +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { Pressable, StyleSheet, Text, useWindowDimensions, View } from "react-native"; +import type { SharedValue } from "react-native-reanimated"; +import Animated, { + Extrapolation, + interpolate, + interpolateColor, + useAnimatedStyle, + useDerivedValue, + useSharedValue, +} from "react-native-reanimated"; + +import { InfiniteLegendList } from "@legendapp/list/infinite"; +import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; +import { AnimatedLegendList } from "@legendapp/list/reanimated"; + +type CarouselItem = { + id: string; + title: string; + emoji: string; + color: string; +}; + +const TEAMS: CarouselItem[] = [ + { color: "#3C3B6E", emoji: "๐Ÿ‡บ๐Ÿ‡ธ", id: "usa", title: "USA" }, + { color: "#006847", emoji: "๐Ÿ‡ฒ๐Ÿ‡ฝ", id: "mexico", title: "Mexico" }, + { color: "#D52B1E", emoji: "๐Ÿ‡จ๐Ÿ‡ฆ", id: "canada", title: "Canada" }, + { color: "#74ACDF", emoji: "๐Ÿ‡ฆ๐Ÿ‡ท", id: "argentina", title: "Argentina" }, + { color: "#009C3B", emoji: "๐Ÿ‡ง๐Ÿ‡ท", id: "brazil", title: "Brazil" }, + { color: "#262626", emoji: "๐Ÿ‡ฉ๐Ÿ‡ช", id: "germany", title: "Germany" }, + { color: "#0055A4", emoji: "๐Ÿ‡ซ๐Ÿ‡ท", id: "france", title: "France" }, + { color: "#BC002D", emoji: "๐Ÿ‡ฏ๐Ÿ‡ต", id: "japan", title: "Japan" }, + { color: "#AA151B", emoji: "๐Ÿ‡ช๐Ÿ‡ธ", id: "spain", title: "Spain" }, + { color: "#21366C", emoji: "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ", id: "england", title: "England" }, + { color: "#046A38", emoji: "๐Ÿ‡ต๐Ÿ‡น", id: "portugal", title: "Portugal" }, + { color: "#F36C21", emoji: "๐Ÿ‡ณ๐Ÿ‡ฑ", id: "netherlands", title: "Netherlands" }, + { color: "#C8102E", emoji: "๐Ÿ‡ง๐Ÿ‡ช", id: "belgium", title: "Belgium" }, + { color: "#1D3F94", emoji: "๐Ÿ‡ญ๐Ÿ‡ท", id: "croatia", title: "Croatia" }, + { color: "#55A8CE", emoji: "๐Ÿ‡บ๐Ÿ‡พ", id: "uruguay", title: "Uruguay" }, + { color: "#C99700", emoji: "๐Ÿ‡จ๐Ÿ‡ด", id: "colombia", title: "Colombia" }, + { color: "#23407E", emoji: "๐Ÿ‡ช๐Ÿ‡จ", id: "ecuador", title: "Ecuador" }, + { color: "#B9314F", emoji: "๐Ÿ‡ต๐Ÿ‡พ", id: "paraguay", title: "Paraguay" }, + { color: "#00205B", emoji: "๐Ÿ‡ณ๐Ÿ‡ด", id: "norway", title: "Norway" }, + { color: "#005293", emoji: "๐Ÿ‡ธ๐Ÿ‡ช", id: "sweden", title: "Sweden" }, + { color: "#8B1A1A", emoji: "๐Ÿ‡จ๐Ÿ‡ญ", id: "switzerland", title: "Switzerland" }, + { color: "#9E1B32", emoji: "๐Ÿ‡ฆ๐Ÿ‡น", id: "austria", title: "Austria" }, + { color: "#123C7D", emoji: "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ", id: "scotland", title: "Scotland" }, + { color: "#7A1220", emoji: "๐Ÿ‡น๐Ÿ‡ท", id: "turkiye", title: "Tรผrkiye" }, + { color: "#11457E", emoji: "๐Ÿ‡จ๐Ÿ‡ฟ", id: "czechia", title: "Czechia" }, + { color: "#002F6C", emoji: "๐Ÿ‡ง๐Ÿ‡ฆ", id: "bosnia", title: "Bosnia and Herzegovina" }, + { color: "#7C1F24", emoji: "๐Ÿ‡ฒ๐Ÿ‡ฆ", id: "morocco", title: "Morocco" }, + { color: "#00853F", emoji: "๐Ÿ‡ธ๐Ÿ‡ณ", id: "senegal", title: "Senegal" }, + { color: "#A31621", emoji: "๐Ÿ‡ช๐Ÿ‡ฌ", id: "egypt", title: "Egypt" }, + { color: "#006233", emoji: "๐Ÿ‡ฉ๐Ÿ‡ฟ", id: "algeria", title: "Algeria" }, + { color: "#A02128", emoji: "๐Ÿ‡น๐Ÿ‡ณ", id: "tunisia", title: "Tunisia" }, + { color: "#B08900", emoji: "๐Ÿ‡ฌ๐Ÿ‡ญ", id: "ghana", title: "Ghana" }, + { color: "#E06D10", emoji: "๐Ÿ‡จ๐Ÿ‡ฎ", id: "ivory-coast", title: "Ivory Coast" }, + { color: "#003893", emoji: "๐Ÿ‡จ๐Ÿ‡ป", id: "cape-verde", title: "Cape Verde" }, + { color: "#007749", emoji: "๐Ÿ‡ฟ๐Ÿ‡ฆ", id: "south-africa", title: "South Africa" }, + { color: "#0085CA", emoji: "๐Ÿ‡จ๐Ÿ‡ฉ", id: "dr-congo", title: "DR Congo" }, + { color: "#7D1128", emoji: "๐Ÿ‡ฐ๐Ÿ‡ท", id: "south-korea", title: "South Korea" }, + { color: "#2E7D32", emoji: "๐Ÿ‡ฎ๐Ÿ‡ท", id: "iran", title: "Iran" }, + { color: "#556B2F", emoji: "๐Ÿ‡ฎ๐Ÿ‡ถ", id: "iraq", title: "Iraq" }, + { color: "#165B33", emoji: "๐Ÿ‡ธ๐Ÿ‡ฆ", id: "saudi-arabia", title: "Saudi Arabia" }, + { color: "#8A1538", emoji: "๐Ÿ‡ถ๐Ÿ‡ฆ", id: "qatar", title: "Qatar" }, + { color: "#365314", emoji: "๐Ÿ‡ฏ๐Ÿ‡ด", id: "jordan", title: "Jordan" }, + { color: "#0099B5", emoji: "๐Ÿ‡บ๐Ÿ‡ฟ", id: "uzbekistan", title: "Uzbekistan" }, + { color: "#B8860B", emoji: "๐Ÿ‡ฆ๐Ÿ‡บ", id: "australia", title: "Australia" }, + { color: "#101820", emoji: "๐Ÿ‡ณ๐Ÿ‡ฟ", id: "new-zealand", title: "New Zealand" }, + { color: "#002B7F", emoji: "๐Ÿ‡จ๐Ÿ‡ผ", id: "curacao", title: "Curaรงao" }, + { color: "#00209F", emoji: "๐Ÿ‡ญ๐Ÿ‡น", id: "haiti", title: "Haiti" }, + { color: "#26428B", emoji: "๐Ÿ‡ต๐Ÿ‡ฆ", id: "panama", title: "Panama" }, +]; + +const CAROUSEL_HEIGHT = 340; + +let mountedCards = 0; +const mountListeners = new Set<() => void>(); +const notifyMountListeners = () => { + for (const listener of mountListeners) { + listener(); + } +}; +const subscribeMounted = (listener: () => void) => { + mountListeners.add(listener); + return () => mountListeners.delete(listener); +}; +const useMountedCardsCount = () => + useSyncExternalStore( + subscribeMounted, + () => mountedCards, + () => mountedCards, + ); + +const Card = ({ + item, + index, + infiniteIndex, + scrollOffset, + itemWidth, + period, +}: { + item: CarouselItem; + index: number; + infiniteIndex: number; + scrollOffset: SharedValue; + itemWidth: number; + period: number; +}) => { + useEffect(() => { + mountedCards++; + notifyMountListeners(); + return () => { + mountedCards--; + notifyMountListeners(); + }; + }, []); + + const animatedStyle = useAnimatedStyle(() => { + const raw = scrollOffset.value - infiniteIndex * itemWidth; + const wrapped = raw - Math.round(raw / period) * period; + const distance = Math.abs(wrapped / itemWidth); + + return { + opacity: interpolate(distance, [0, 1, 2], [1, 0.55, 0.4], Extrapolation.CLAMP), + transform: [ + { scale: interpolate(distance, [0, 1, 2], [1, 0.86, 0.8], Extrapolation.CLAMP) }, + { translateY: interpolate(distance, [0, 1], [0, 24], Extrapolation.CLAMP) }, + ], + }; + }, [infiniteIndex, itemWidth, period]); + + return ( + + {item.emoji} + {item.title} + item {index} + + ); +}; + +const Dot = ({ index, position, totalCount }: { index: number; position: SharedValue; totalCount: number }) => { + const animatedStyle = useAnimatedStyle(() => { + const distance = Math.abs(position.value - index); + const wrappedDistance = Math.min(distance, totalCount - distance); + + return { + backgroundColor: interpolateColor(wrappedDistance, [0, 1], ["#1f1f1f", "#D0D0D0"]), + width: interpolate(wrappedDistance, [0, 1], [20, 8], Extrapolation.CLAMP), + }; + }, [index, totalCount]); + + return ; +}; + +const ITEM_COUNT_OPTIONS = [2, 8, 16, 32, 48]; + +export default function InfiniteCarouselFixtureScreen() { + const { width: windowWidth } = useWindowDimensions(); + const itemWidth = windowWidth; + const sidePadding = (windowWidth - itemWidth) / 2; + + const refList = useRef(null); + const scrollOffset = useSharedValue(0); + const [itemCount, setItemCount] = useState(8); + const [loadTimeMs, setLoadTimeMs] = useState(undefined); + const mountedCardsCount = useMountedCardsCount(); + + const items = useMemo(() => TEAMS.slice(0, itemCount), [itemCount]); + const period = itemWidth * items.length; + + const position = useDerivedValue(() => { + const rawPosition = scrollOffset.value / itemWidth; + return ((rawPosition % itemCount) + itemCount) % itemCount; + }, [itemWidth, itemCount]); + + return ( + + String(count)} + recycleItems={false} + renderItem={({ item: count }) => ( + { + setLoadTimeMs(undefined); + setItemCount(count); + }} + style={[styles.countButton, itemCount === count ? styles.countButtonActive : undefined]} + > + + {count} team{count === 1 ? "" : "s"} + + + )} + showsHorizontalScrollIndicator={false} + style={styles.countScroll} + /> + + itemWidth} + horizontal + key={itemCount} + keyExtractor={(item) => item.id} + ListComponent={AnimatedLegendList} + onLoad={({ elapsedTimeInMs }) => setLoadTimeMs(elapsedTimeInMs)} + recycleItems + ref={refList} + renderItem={({ item, index, infiniteIndex }) => ( + + )} + sharedValues={{ scrollOffset }} + showsHorizontalScrollIndicator={false} + snapToInterval={itemWidth} + style={{ height: CAROUSEL_HEIGHT }} + /> + + + {items.length <= 16 && ( + + {items.map((item, index) => ( + + ))} + + )} + + + {loadTimeMs !== undefined ? `onLoad: ${Math.round(loadTimeMs)}ms ยท ${items.length} teams ยท ` : ""} + mounted cards: {mountedCardsCount} + + + Swipe endlessly in either direction, or jump with shortest-path wrap: + + {items.map((item, index) => ( + refList.current?.scrollToIndex({ animated: true, index })} + style={[styles.jumpButton, { backgroundColor: item.color }]} + > + {item.emoji} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + buttonsRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + justifyContent: "center", + marginTop: 12, + paddingHorizontal: 16, + }, + card: { + alignItems: "center", + borderRadius: 20, + height: CAROUSEL_HEIGHT - 60, + justifyContent: "center", + marginHorizontal: 8, + marginTop: 16, + }, + cardEmoji: { + fontSize: 64, + }, + cardIndex: { + color: "rgba(255,255,255,0.8)", + fontSize: 14, + fontWeight: "600", + marginTop: 4, + }, + cardTitle: { + color: "#FFFFFF", + fontSize: 24, + fontWeight: "700", + marginTop: 12, + }, + container: { + backgroundColor: "#F8F5F2", + flex: 1, + paddingTop: 12, + }, + countButton: { + borderColor: "#C9C4BE", + borderRadius: 16, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 6, + }, + countButtonActive: { + backgroundColor: "#1f1f1f", + borderColor: "#1f1f1f", + }, + countButtonText: { + color: "#1f1f1f", + fontSize: 13, + fontWeight: "600", + }, + countButtonTextActive: { + color: "#FFFFFF", + }, + countRow: { + gap: 8, + paddingHorizontal: 16, + }, + countScroll: { + flexGrow: 0, + height: 34, + marginBottom: 8, + }, + dot: { + backgroundColor: "#D0D0D0", + borderRadius: 4, + height: 8, + width: 8, + }, + dotsRow: { + flexDirection: "row", + gap: 6, + justifyContent: "center", + marginTop: 8, + }, + hint: { + color: "#6B6B6B", + fontSize: 13, + marginTop: 20, + paddingHorizontal: 24, + textAlign: "center", + }, + jumpButton: { + alignItems: "center", + borderRadius: 18, + height: 36, + justifyContent: "center", + width: 36, + }, + jumpButtonText: { + fontSize: 18, + }, + loadLabel: { + color: "#8A8580", + fontSize: 12, + marginTop: 4, + minHeight: 16, + textAlign: "center", + }, +}); diff --git a/example/screens/routes.tsx b/example/screens/routes.tsx index 51ad4fa7..4bfdface 100644 --- a/example/screens/routes.tsx +++ b/example/screens/routes.tsx @@ -46,6 +46,8 @@ import ExtraDataFixture from "~/screens/fixtures/extra-data"; import FilterElementsFixture from "~/screens/fixtures/filter-elements"; import HorizontalAlignItemsFixture from "~/screens/fixtures/horizontal-align-items"; import HorizontalCrossAxisFixture from "~/screens/fixtures/horizontal-cross-axis"; +import InfiniteCarouselFixture from "~/screens/fixtures/infinite-carousel"; +import InfiniteCarouselStateFixture from "~/screens/fixtures/infinite-carousel-state"; import InitialScrollAtEndEmptyFixture from "~/screens/fixtures/initial-scroll-at-end-empty"; import InitialScrollIndexFixture from "~/screens/fixtures/initial-scroll-index"; import InitialScrollIndexFreeHeightFixture from "~/screens/fixtures/initial-scroll-index-free-height"; @@ -546,6 +548,25 @@ export const FIXTURE_ROUTES: FixtureRouteDefinition[] = [ slug: "product-shelf-fixture", title: "Product Shelf Fixture", }, + { + component: InfiniteCarouselFixture, + description: + "Circular carousel powered by infiniteMode with snap, progress animations, and wrap-around scrollToIndex.", + groupKey: "comparison", + groupTitle: "Comparisons & Media", + kind: "fixture", + slug: "infinite-carousel", + title: "Infinite Carousel", + }, + { + component: InfiniteCarouselStateFixture, + description: "Shows how per-item state should be stored in an infinite recycled carousel.", + groupKey: "comparison", + groupTitle: "Comparisons & Media", + kind: "fixture", + slug: "infinite-carousel-state", + title: "Infinite Carousel State", + }, { component: RTLHorizontalFixture, description: "Horizontal RTL list for validating native scroll coordinate behavior.", diff --git a/example/tsconfig.json b/example/tsconfig.json index aa9a6610..b8f6c020 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -31,6 +31,9 @@ "@legendapp/list/section-list": [ "../src/section-list" ], + "@legendapp/list/infinite": [ + "../src/integrations/infinite" + ], "@legendapp/list/keyboard": [ "../src/integrations/keyboard" ], diff --git a/package.json b/package.json index 68e0f306..86e25515 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,12 @@ "require": "./animated.js", "default": "./animated.js" }, + "./infinite": { + "types": "./infinite.d.ts", + "import": "./infinite.mjs", + "require": "./infinite.js", + "default": "./infinite.js" + }, "./keyboard": { "types": "./keyboard.d.ts", "import": "./keyboard.mjs", diff --git a/src/integrations/infinite.tsx b/src/integrations/infinite.tsx new file mode 100644 index 00000000..c0dfeab7 --- /dev/null +++ b/src/integrations/infinite.tsx @@ -0,0 +1,422 @@ +import * as React from "react"; +import { type ForwardedRef, useCallback, useImperativeHandle, useMemo, useRef } from "react"; +import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; + +import { + LegendList, + type LegendListProps, + type LegendListRef, + type LegendListRenderItemProps, + type OnViewableItemsChanged, + type OnViewableItemsChangedInfo, + type ViewabilityConfigCallbackPairs, + type ViewToken, +} from "@legendapp/list/react-native"; + +const INFINITE_KEY_SEPARATOR = "โŸ"; + +export interface InfiniteModeConfig { + /** + * How many times the data is repeated to create the virtual scroll space. + * Odd values keep a well-defined center copy. Defaults to at least 9 copies, + * scaled up automatically for very short datasets. + */ + copies?: number; +} + +interface InfiniteModeProps { + data: ReadonlyArray; + renderItem: (props: LegendListRenderItemProps & { infiniteIndex: number }) => React.ReactNode; + keyExtractor?: (item: T, index: number) => string; + getItemType?: (item: T, index: number) => any; + getFixedItemSize?: (item: T, index: number, type: any) => number | undefined; + overrideItemLayout?: ( + layout: { span?: number }, + item: T, + index: number, + maxColumns: number, + extraData?: any, + ) => void; + horizontal?: boolean; + initialScrollIndex?: number | { index: number; viewOffset?: number; viewPosition?: number }; + onEndReached?: unknown; + onStartReached?: unknown; + onScroll?: (event: NativeSyntheticEvent) => void; + onMomentumScrollEnd?: (event: NativeSyntheticEvent) => void; + onViewableItemsChanged?: OnViewableItemsChanged | undefined; + viewabilityConfigCallbackPairs?: ViewabilityConfigCallbackPairs; + onFirstVisibleItemChanged?: (info: { index: number; item: T; key: string }) => void; +} + +function resolveCopies(infiniteMode: boolean | InfiniteModeConfig, dataLength: number): number { + const configured = typeof infiniteMode === "object" ? infiniteMode.copies : undefined; + let copies = configured ?? Math.max(9, Math.ceil(40 / dataLength)); + copies = Math.max(3, copies); + if (copies % 2 === 0) { + copies++; + } + return copies; +} + +export function useInfiniteMode>( + props: TProps, + infiniteMode: boolean | InfiniteModeConfig | undefined, + forwardedRef: ForwardedRef, +): { props: TProps; refTarget: React.RefObject } { + const { + data, + renderItem, + keyExtractor, + getItemType, + getFixedItemSize, + overrideItemLayout, + horizontal, + initialScrollIndex: initialScrollIndexProp, + onScroll: onScrollProp, + onMomentumScrollEnd: onMomentumScrollEndProp, + onViewableItemsChanged, + viewabilityConfigCallbackPairs, + onFirstVisibleItemChanged, + } = props; + + const dataLength = data.length; + const enabled = !!infiniteMode && dataLength > 0; + const copies = enabled ? resolveCopies(infiniteMode!, dataLength) : 1; + const middleCopyBase = Math.floor(copies / 2) * dataLength; + + const refInner = useRef(null); + + const virtualData = useMemo(() => { + if (!enabled) { + return data; + } + const totalLength = dataLength * copies; + const virtual = new Array(totalLength); + for (let i = 0; i < totalLength; i++) { + virtual[i] = data[i % dataLength]; + } + return virtual; + }, [enabled, data, dataLength, copies]); + + const getCycleSize = useCallback((): number => { + const inner = refInner.current; + if (!inner) { + return 0; + } + const state = inner.getState(); + const measured = state.positionAtIndex(dataLength) - state.positionAtIndex(0); + if (Number.isFinite(measured) && measured > 0) { + return measured; + } + return state.contentLength > 0 ? state.contentLength / copies : 0; + }, [dataLength, copies]); + + const recenter = useCallback( + (thresholdCycles: number) => { + const inner = refInner.current; + if (!inner) { + return; + } + const state = inner.getState(); + const cycle = getCycleSize(); + if (!cycle || state.scrollLength <= 0 || state.contentLength <= state.scrollLength) { + return; + } + const center = (state.contentLength - state.scrollLength) / 2; + const drift = state.scroll - center; + if (Math.abs(drift) < cycle * thresholdCycles) { + return; + } + const cyclesToTeleport = Math.round(drift / cycle); + if (cyclesToTeleport !== 0) { + inner.scrollToOffset({ animated: false, offset: state.scroll - cyclesToTeleport * cycle }); + } + }, + [getCycleSize], + ); + + const recenterThreshold = Math.max(1, Math.floor(copies / 4)); + + const wrappedOnMomentumScrollEnd = useCallback( + (event: NativeSyntheticEvent) => { + recenter(recenterThreshold); + onMomentumScrollEndProp?.(event); + }, + [onMomentumScrollEndProp, recenter, recenterThreshold], + ); + + const wrappedOnScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const offset = horizontal ? contentOffset.x : contentOffset.y; + const total = horizontal ? contentSize.width : contentSize.height; + const viewport = horizontal ? layoutMeasurement.width : layoutMeasurement.height; + const approxCycle = total / copies; + if (total > 0 && (offset < approxCycle || offset > total - viewport - approxCycle)) { + recenter(1); + } + onScrollProp?.(event); + }, + [copies, horizontal, onScrollProp, recenter], + ); + + const wrappedRenderItem = useCallback( + (itemProps: LegendListRenderItemProps) => { + const virtualIndex = itemProps.index; + return renderItem({ + ...itemProps, + data, + index: virtualIndex % dataLength, + infiniteIndex: virtualIndex, + }); + }, + [renderItem, data, dataLength], + ); + + const wrappedKeyExtractor = useCallback( + (item: T, virtualIndex: number) => { + const realIndex = virtualIndex % dataLength; + const baseKey = keyExtractor ? keyExtractor(item, realIndex) : realIndex.toString(); + return `${baseKey}${INFINITE_KEY_SEPARATOR}${Math.floor(virtualIndex / dataLength)}`; + }, + [keyExtractor, dataLength], + ); + + const wrappedGetItemType = useMemo( + () => + getItemType ? (item: T, virtualIndex: number) => getItemType(item, virtualIndex % dataLength) : undefined, + [getItemType, dataLength], + ); + + const wrappedGetFixedItemSize = useMemo( + () => + getFixedItemSize + ? (item: T, virtualIndex: number, type: any) => getFixedItemSize(item, virtualIndex % dataLength, type) + : undefined, + [getFixedItemSize, dataLength], + ); + + const wrappedOverrideItemLayout = useMemo( + () => + overrideItemLayout + ? (layout: { span?: number }, item: T, virtualIndex: number, maxColumns: number, extraData?: any) => + overrideItemLayout(layout, item, virtualIndex % dataLength, maxColumns, extraData) + : undefined, + [overrideItemLayout, dataLength], + ); + + const mapViewToken = useCallback( + (token: ViewToken): ViewToken => { + const separatorIndex = token.key?.lastIndexOf(INFINITE_KEY_SEPARATOR) ?? -1; + return { + ...token, + index: token.index != null && token.index >= 0 ? token.index % dataLength : token.index, + key: separatorIndex >= 0 ? token.key.slice(0, separatorIndex) : token.key, + }; + }, + [dataLength], + ); + + const mapViewabilityInfo = useCallback( + (info: OnViewableItemsChangedInfo): OnViewableItemsChangedInfo => ({ + ...info, + changed: info.changed.map(mapViewToken), + end: info.end >= 0 ? info.end % dataLength : info.end, + endBuffered: info.endBuffered >= 0 ? info.endBuffered % dataLength : info.endBuffered, + start: info.start >= 0 ? info.start % dataLength : info.start, + startBuffered: info.startBuffered >= 0 ? info.startBuffered % dataLength : info.startBuffered, + viewableItems: info.viewableItems.map(mapViewToken), + }), + [mapViewToken, dataLength], + ); + + const wrappedOnViewableItemsChanged = useMemo( + () => + onViewableItemsChanged + ? (info: OnViewableItemsChangedInfo) => onViewableItemsChanged(mapViewabilityInfo(info)) + : undefined, + [onViewableItemsChanged, mapViewabilityInfo], + ); + + const wrappedViewabilityConfigCallbackPairs = useMemo( + () => + viewabilityConfigCallbackPairs?.map((pair) => ({ + ...pair, + onViewableItemsChanged: pair.onViewableItemsChanged + ? (info: OnViewableItemsChangedInfo) => pair.onViewableItemsChanged!(mapViewabilityInfo(info)) + : pair.onViewableItemsChanged, + })) as ViewabilityConfigCallbackPairs | undefined, + [viewabilityConfigCallbackPairs, mapViewabilityInfo], + ); + + const wrappedOnFirstVisibleItemChanged = useMemo( + () => + onFirstVisibleItemChanged + ? (info: { index: number; item: T; key: string }) => { + const separatorIndex = info.key?.lastIndexOf(INFINITE_KEY_SEPARATOR) ?? -1; + onFirstVisibleItemChanged({ + index: info.index % dataLength, + item: info.item, + key: separatorIndex >= 0 ? info.key.slice(0, separatorIndex) : info.key, + }); + } + : undefined, + [onFirstVisibleItemChanged, dataLength], + ); + + const initialScrollIndex = useMemo(() => { + if (!enabled) { + return initialScrollIndexProp; + } + if (initialScrollIndexProp == null) { + return middleCopyBase; + } + if (typeof initialScrollIndexProp === "object") { + return { ...initialScrollIndexProp, index: middleCopyBase + (initialScrollIndexProp.index ?? 0) }; + } + return middleCopyBase + initialScrollIndexProp; + }, [enabled, initialScrollIndexProp, middleCopyBase]); + + const toNearestVirtualIndex = useCallback( + (realIndex: number) => { + const normalizedTarget = ((realIndex % dataLength) + dataLength) % dataLength; + const state = refInner.current?.getState(); + const currentVirtual = + state && state.start >= 0 ? Math.round((state.start + state.end) / 2) : middleCopyBase; + const currentReal = ((currentVirtual % dataLength) + dataLength) % dataLength; + let diff = normalizedTarget - currentReal; + if (Math.abs(diff) > dataLength / 2) { + diff += diff > 0 ? -dataLength : dataLength; + } + return currentVirtual + diff; + }, + [dataLength, middleCopyBase], + ); + + useImperativeHandle(forwardedRef, () => { + const inner = refInner.current!; + if (!enabled) { + return inner; + } + const wrapped: LegendListRef = { + ...inner, + scrollIndexIntoView: (params) => + inner.scrollIndexIntoView({ ...params, index: toNearestVirtualIndex(params.index) }), + scrollItemIntoView: ({ item, ...rest }) => { + const index = data.indexOf(item as T); + return index >= 0 + ? inner.scrollIndexIntoView({ ...rest, index: toNearestVirtualIndex(index) }) + : Promise.resolve(); + }, + scrollToIndex: (params) => inner.scrollToIndex({ ...params, index: toNearestVirtualIndex(params.index) }), + scrollToItem: ({ item, ...rest }) => { + const index = data.indexOf(item as T); + return index >= 0 + ? inner.scrollToIndex({ ...rest, index: toNearestVirtualIndex(index) }) + : Promise.resolve(); + }, + }; + return wrapped; + }, [enabled, data, toNearestVirtualIndex]); + + const transformedProps = enabled + ? { + ...props, + data: virtualData, + getFixedItemSize: wrappedGetFixedItemSize, + getItemType: wrappedGetItemType, + initialScrollIndex, + keyExtractor: wrappedKeyExtractor, + onEndReached: undefined, + onFirstVisibleItemChanged: wrappedOnFirstVisibleItemChanged, + onMomentumScrollEnd: wrappedOnMomentumScrollEnd, + onScroll: wrappedOnScroll, + onStartReached: undefined, + onViewableItemsChanged: wrappedOnViewableItemsChanged, + overrideItemLayout: wrappedOverrideItemLayout, + renderItem: wrappedRenderItem, + viewabilityConfigCallbackPairs: wrappedViewabilityConfigCallbackPairs, + } + : props; + + return { props: transformedProps as TProps, refTarget: refInner }; +} + +type UnsupportedInfiniteProps = + | "alignItemsAtEnd" + | "anchoredEndSpace" + | "children" + | "columnWrapperStyle" + | "initialScrollAtEnd" + | "ListFooterComponent" + | "ListFooterComponentStyle" + | "ListHeaderComponent" + | "ListHeaderComponentStyle" + | "maintainScrollAtEnd" + | "maintainScrollAtEndThreshold" + | "numColumns" + | "onEndReached" + | "onEndReachedThreshold" + | "onStartReached" + | "onStartReachedThreshold" + | "stickyHeaderConfig" + | "stickyHeaderIndices"; + +export interface InfiniteLegendListRenderItemProps extends LegendListRenderItemProps { + /** + * The item's index in the virtual (repeated) scroll space. Combine with the scroll offset + * to drive carousel progress animations; `index` stays the index in the real data array. + */ + infiniteIndex: number; +} + +type PropsOf = TComponent extends React.ComponentType ? TProps : never; + +export type InfiniteLegendListProps = typeof LegendList> = Omit< + LegendListProps, + UnsupportedInfiniteProps | "data" | "renderItem" +> & { + data: ReadonlyArray; + + renderItem: (props: InfiniteLegendListRenderItemProps) => React.ReactNode; + + /** + * How many times the data is repeated to create the virtual scroll space. + * Odd values keep a well-defined center copy. Defaults to at least 9 copies, + * scaled up automatically for very short datasets. + */ + copies?: number; + + /** + * The underlying list component to render. Defaults to LegendList. + * Pass AnimatedLegendList from `@legendapp/list/reanimated` to get a UI-thread + * scroll offset shared value for progress animations, or the RN Animated variant + * from `@legendapp/list/animated`. + */ + ListComponent?: TList; +} & Omit, keyof LegendListProps | "ListComponent" | "copies">; + +/** + * A circular, endlessly-scrollable list โ€” LegendList preconfigured as an infinite carousel. + * + * The data loops in both directions, scroll recentering is invisible, `renderItem` receives + * real data indices plus a required `infiniteIndex`, and ref scroll methods wrap around via + * the shortest path. + */ +// biome-ignore lint/nursery/noShadow: const function name shadowing is intentional +export const InfiniteLegendList = React.forwardRef(function InfiniteLegendList( + props: { copies?: number; ListComponent?: React.ComponentType } & Record, + ref: React.Ref, +) { + const { copies, ListComponent = LegendList as React.ComponentType, ...rest } = props; + const infiniteMode = useMemo(() => (copies !== undefined ? { copies } : true), [copies]); + const { props: transformedProps, refTarget } = useInfiniteMode( + rest as unknown as InfiniteModeProps, + infiniteMode, + ref, + ); + + return ; +}) as unknown as = typeof LegendList>( + props: InfiniteLegendListProps & { ref?: React.Ref }, +) => React.ReactElement | null; diff --git a/tsconfig.json b/tsconfig.json index 35d63adb..30fbd70a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,6 +53,9 @@ ], "@legendapp/list/reanimated": [ "./src/integrations/reanimated" + ], + "@legendapp/list/infinite": [ + "./src/integrations/infinite" ] }, "resolveJsonModule": true, diff --git a/tsup.config.ts b/tsup.config.ts index 5ae38f0e..7e519654 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -18,6 +18,7 @@ const webEntryPoints: Record = { const nativeEntryPoints = { animated: "src/integrations/animated.tsx", + infinite: "src/integrations/infinite.tsx", keyboard: "src/integrations/keyboard.tsx", "keyboard-legacy": "src/integrations/keyboard-legacy.tsx", "react-native": "src/react-native.ts",