From 3350237686dcf8c4769ee9b3ab59619d072eebf7 Mon Sep 17 00:00:00 2001 From: VladyslavMartynov10 Date: Sat, 22 Nov 2025 14:53:02 +0200 Subject: [PATCH 1/5] feat: reanimated scroll support --- CHANGELOG.md | 3 + example/app/(tabs)/index.tsx | 4 + example/app/reanimated-scroll-state/index.tsx | 143 ++++++++++++++++++ src/components/LegendList.tsx | 4 +- src/core/onScroll.ts | 9 +- src/types.ts | 7 +- src/utils/helpers.ts | 13 +- 7 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 example/app/reanimated-scroll-state/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d74ac8a..78b09924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 2.0.16 +- Feat: Reanimated scroll support + ## 2.0.15 - Fix: Container allocation for sticky headers could duplicate containers, causing rendering issues - Fix: Sticky positioned components scrolling out of viewport after scrolling distance exceeded 5000 diff --git a/example/app/(tabs)/index.tsx b/example/app/(tabs)/index.tsx index 251ab22c..d19fb978 100644 --- a/example/app/(tabs)/index.tsx +++ b/example/app/(tabs)/index.tsx @@ -148,6 +148,10 @@ const data: ListElement[] = [ title: "Accurate scrollToHuge", url: "/accurate-scrollto-huge", }, + { + title: "Reanimated scroll state on UI", + url: "/reanimated-scroll-state", + }, ].map( (v, i) => ({ diff --git a/example/app/reanimated-scroll-state/index.tsx b/example/app/reanimated-scroll-state/index.tsx new file mode 100644 index 00000000..95f7a8b0 --- /dev/null +++ b/example/app/reanimated-scroll-state/index.tsx @@ -0,0 +1,143 @@ +import { useCallback, useState } from "react"; +import { Pressable, SafeAreaView, StatusBar, StyleSheet, Text, View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { useAnimatedScrollHandler, useSharedValue } from "react-native-reanimated"; + +import { AnimatedLegendList } from "@legendapp/list/reanimated"; +import { countries, getEmojiFlag, type TCountryCode } from "countries-list"; +import { LegendListRenderItemProps } from "@/types"; + +type Country = { + id: string; + name: string; + flag: string; +}; + +const DATA: Country[] = Object.entries(countries) + .map(([code, country]) => ({ + flag: getEmojiFlag(code as TCountryCode), + id: code, + name: country.name, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + +type ItemProps = { + item: Country; + onPress: () => void; + isSelected: boolean; +}; + +const Item = ({ item, onPress, isSelected }: ItemProps) => ( + [styles.item, isSelected && styles.selectedItem, pressed && styles.pressedItem]} + > + + {item.flag} + + + + {item.name} + ({item.id}) + + + +); + +const App = () => { + const [selectedId, setSelectedId] = useState(); + + const scrollY = useSharedValue(0); + + const onScroll = useAnimatedScrollHandler({ + onScroll: (event) => { + console.log("onScroll", event); + + scrollY.value = event.contentOffset.y; + }, + }); + + const tapGesture = Gesture.Tap().onStart(({ absoluteY }) => { + "worklet"; + const adjustedY = absoluteY + scrollY.value; + console.log("📍 Tap at scroll-adjusted Y:", adjustedY); + }); + + const keyExtractor = useCallback((item: Country) => item.id, []); + + const renderItem = useCallback( + ({ item }: LegendListRenderItemProps) => { + const isSelected = item.id === selectedId; + + return setSelectedId(item.id)} />; + }, + [selectedId], + ); + + return ( + + + + + + ); +}; + +export default App; + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#f5f5f5", + flex: 1, + marginTop: StatusBar.currentHeight || 0, + }, + contentContainer: { + flex: 1, + justifyContent: "center", + }, + countryCode: { + color: "#666", + fontSize: 14, + fontWeight: "400", + }, + flag: { + fontSize: 28, + }, + flagContainer: { + alignItems: "center", + backgroundColor: "#f8f9fa", + borderRadius: 20, + height: 40, + justifyContent: "center", + marginRight: 16, + width: 40, + }, + item: { + alignItems: "center", + backgroundColor: "#fff", + borderRadius: 12, + flexDirection: "row", + paddingHorizontal: 16, + paddingVertical: 6, + }, + pressedItem: {}, + selectedItem: {}, + selectedText: { + color: "#1976d2", + fontWeight: "600", + }, + title: { + color: "#333", + fontSize: 16, + fontWeight: "500", + }, +}); diff --git a/src/components/LegendList.tsx b/src/components/LegendList.tsx index cecf6d73..13f6e244 100644 --- a/src/components/LegendList.tsx +++ b/src/components/LegendList.tsx @@ -257,7 +257,9 @@ const LegendListInner = typedForwardRef(function LegendListInner( state.dataChangeNeedsScrollUpdate = true; } const throttleScrollFn = - scrollEventThrottle && onScrollProp ? useThrottledOnScroll(onScrollProp, scrollEventThrottle) : onScrollProp; + scrollEventThrottle && onScrollProp && typeof onScrollProp === "function" + ? useThrottledOnScroll(onScrollProp, scrollEventThrottle) + : onScrollProp; state.props = { alignItemsAtEnd, diff --git a/src/core/onScroll.ts b/src/core/onScroll.ts index c8ce7f37..b78b15c6 100644 --- a/src/core/onScroll.ts +++ b/src/core/onScroll.ts @@ -5,6 +5,7 @@ import type { StateContext } from "@/state/state"; import type { InternalState } from "@/types"; import { checkAtBottom } from "@/utils/checkAtBottom"; import { checkAtTop } from "@/utils/checkAtTop"; +import { isReanimatedScroll } from "@/utils/helpers"; export function onScroll(ctx: StateContext, state: InternalState, event: NativeSyntheticEvent) { const { @@ -24,7 +25,13 @@ export function onScroll(ctx: StateContext, state: InternalState, event: NativeS updateScroll(ctx, state, newScroll); - onScrollProp?.(event as NativeSyntheticEvent); + if (onScrollProp) { + if (isReanimatedScroll(onScrollProp)) { + onScrollProp.value?.(event as NativeSyntheticEvent); + } else if (typeof onScrollProp === "function") { + onScrollProp(event as NativeSyntheticEvent); + } + } } function updateScroll(ctx: StateContext, state: InternalState, newScroll: number) { diff --git a/src/types.ts b/src/types.ts index 63b6ade5..95a45882 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ import type { ViewStyle, } from "react-native"; import type Reanimated from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import type { ScrollAdjustHandler } from "@/core/ScrollAdjustHandler"; @@ -226,7 +227,9 @@ interface LegendListSpecificProps { */ onRefresh?: () => void; - onScroll?: (event: NativeSyntheticEvent) => void; + onScroll?: + | ((event: NativeSyntheticEvent) => void) + | SharedValue<((event: NativeSyntheticEvent) => void) | undefined>; /** * Called when scrolling reaches the start within onStartReachedThreshold. @@ -241,7 +244,7 @@ interface LegendListSpecificProps { /** * Called when the sticky header changes. - */ + */ onStickyHeaderChange?: (info: { index: number; item: any }) => void; /** diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 8fa216fc..5516b5a1 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,4 +1,15 @@ -import type { ViewStyle } from "react-native"; +import type { NativeScrollEvent, NativeSyntheticEvent, ViewStyle } from "react-native"; +import type { SharedValue } from "react-native-reanimated"; + +type ScrollHandler = (e: NativeSyntheticEvent) => void; + +export function isReanimatedScroll(value: unknown): value is SharedValue { + if (typeof value === "object" && value !== null && "value" in value) { + const val = (value as { value: unknown }).value; + return typeof val === "function" || val === undefined; + } + return false; +} export function isFunction(obj: unknown): obj is (...args: any[]) => any { return typeof obj === "function"; From 3a02d90ff4e44e0b25813214feecbf625f7b3524 Mon Sep 17 00:00:00 2001 From: VladyslavMartynov10 Date: Tue, 25 Nov 2025 21:04:17 +0200 Subject: [PATCH 2/5] feat: reanimated scroll integration --- example/app/reanimated-scroll-state/index.tsx | 2 +- src/components/LegendList.tsx | 4 +-- src/core/onScroll.ts | 7 +--- src/integrations/reanimated.tsx | 33 ++++++++++++++++--- src/types.ts | 7 ++-- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/example/app/reanimated-scroll-state/index.tsx b/example/app/reanimated-scroll-state/index.tsx index 95f7a8b0..8974a1e9 100644 --- a/example/app/reanimated-scroll-state/index.tsx +++ b/example/app/reanimated-scroll-state/index.tsx @@ -4,8 +4,8 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { useAnimatedScrollHandler, useSharedValue } from "react-native-reanimated"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; +import type { LegendListRenderItemProps } from "@/types"; import { countries, getEmojiFlag, type TCountryCode } from "countries-list"; -import { LegendListRenderItemProps } from "@/types"; type Country = { id: string; diff --git a/src/components/LegendList.tsx b/src/components/LegendList.tsx index 13f6e244..cecf6d73 100644 --- a/src/components/LegendList.tsx +++ b/src/components/LegendList.tsx @@ -257,9 +257,7 @@ const LegendListInner = typedForwardRef(function LegendListInner( state.dataChangeNeedsScrollUpdate = true; } const throttleScrollFn = - scrollEventThrottle && onScrollProp && typeof onScrollProp === "function" - ? useThrottledOnScroll(onScrollProp, scrollEventThrottle) - : onScrollProp; + scrollEventThrottle && onScrollProp ? useThrottledOnScroll(onScrollProp, scrollEventThrottle) : onScrollProp; state.props = { alignItemsAtEnd, diff --git a/src/core/onScroll.ts b/src/core/onScroll.ts index b78b15c6..c54bc2af 100644 --- a/src/core/onScroll.ts +++ b/src/core/onScroll.ts @@ -5,7 +5,6 @@ import type { StateContext } from "@/state/state"; import type { InternalState } from "@/types"; import { checkAtBottom } from "@/utils/checkAtBottom"; import { checkAtTop } from "@/utils/checkAtTop"; -import { isReanimatedScroll } from "@/utils/helpers"; export function onScroll(ctx: StateContext, state: InternalState, event: NativeSyntheticEvent) { const { @@ -26,11 +25,7 @@ export function onScroll(ctx: StateContext, state: InternalState, event: NativeS updateScroll(ctx, state, newScroll); if (onScrollProp) { - if (isReanimatedScroll(onScrollProp)) { - onScrollProp.value?.(event as NativeSyntheticEvent); - } else if (typeof onScrollProp === "function") { - onScrollProp(event as NativeSyntheticEvent); - } + onScrollProp(event as NativeSyntheticEvent); } } diff --git a/src/integrations/reanimated.tsx b/src/integrations/reanimated.tsx index 6b74d74d..7868ab4a 100644 --- a/src/integrations/reanimated.tsx +++ b/src/integrations/reanimated.tsx @@ -1,5 +1,7 @@ import * as React from "react"; import { type ComponentProps, memo, useCallback } from "react"; +import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; +import type { SharedValue } from "react-native-reanimated"; import Animated from "react-native-reanimated"; import { @@ -10,6 +12,7 @@ import { type TypedMemo, } from "@legendapp/list"; import { useCombinedRef } from "@/hooks/useCombinedRef"; +import { isReanimatedScroll } from "@/utils/helpers"; type KeysToOmit = | "getEstimatedItemSize" @@ -20,12 +23,16 @@ type KeysToOmit = | "renderItem" | "onItemSizeChanged" | "itemsAreEqual" - | "ItemSeparatorComponent"; + | "ItemSeparatorComponent" + | "onScroll"; type PropsBase = LegendListPropsBase>; export interface AnimatedLegendListPropsBase extends Omit, KeysToOmit> { refScrollView?: React.Ref; + onScroll?: + | ((event: NativeSyntheticEvent) => void) + | SharedValue<((event: NativeSyntheticEvent) => void) | undefined>; } type OtherAnimatedLegendListProps = Pick, KeysToOmit>; @@ -35,10 +42,15 @@ const typedMemo = memo as TypedMemo; // A component that receives a ref for the Animated.ScrollView and passes it to the LegendList const LegendListForwardedRef = typedMemo( React.forwardRef(function LegendListForwardedRef( - props: LegendListProps & { refLegendList: (r: LegendListRef | null) => void }, + props: LegendListProps & { + refLegendList: (r: LegendListRef | null) => void; + onScroll?: + | ((event: NativeSyntheticEvent) => void) + | SharedValue<((event: NativeSyntheticEvent) => void) | undefined>; + }, ref: React.Ref, ) { - const { refLegendList, ...rest } = props; + const { refLegendList, onScroll, ...rest } = props; const refFn = useCallback( (r: LegendListRef) => { @@ -47,7 +59,20 @@ const LegendListForwardedRef = typedMemo( [refLegendList], ); - return ; + const scrollHandler = useCallback( + (event: NativeSyntheticEvent) => { + if (onScroll) { + if (isReanimatedScroll(onScroll)) { + onScroll.value?.(event); + } else { + onScroll(event); + } + } + }, + [onScroll], + ); + + return ; }), ); diff --git a/src/types.ts b/src/types.ts index 95a45882..3a79d9d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { type ComponentProps, forwardRef, memo, type ReactNode, type Key } from "react"; +import { type ComponentProps, forwardRef, type Key, memo, type ReactNode } from "react"; import type { Animated, LayoutRectangle, @@ -12,7 +12,6 @@ import type { ViewStyle, } from "react-native"; import type Reanimated from "react-native-reanimated"; -import type { SharedValue } from "react-native-reanimated"; import type { ScrollAdjustHandler } from "@/core/ScrollAdjustHandler"; @@ -227,9 +226,7 @@ interface LegendListSpecificProps { */ onRefresh?: () => void; - onScroll?: - | ((event: NativeSyntheticEvent) => void) - | SharedValue<((event: NativeSyntheticEvent) => void) | undefined>; + onScroll?: (event: NativeSyntheticEvent) => void; /** * Called when scrolling reaches the start within onStartReachedThreshold. From e31e86d3dacfd1cb0b0f11728cc91308e5b2ab04 Mon Sep 17 00:00:00 2001 From: VladyslavMartynov10 Date: Tue, 25 Nov 2025 21:11:44 +0200 Subject: [PATCH 3/5] chore: minors --- src/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/types.ts b/src/types.ts index 3a79d9d4..1c6ca10d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { type ComponentProps, forwardRef, type Key, memo, type ReactNode } from "react"; +import { type ComponentProps, forwardRef, memo, type ReactNode, type Key } from "react"; import type { Animated, LayoutRectangle, @@ -241,7 +241,7 @@ interface LegendListSpecificProps { /** * Called when the sticky header changes. - */ + */ onStickyHeaderChange?: (info: { index: number; item: any }) => void; /** @@ -697,4 +697,4 @@ export interface ScrollIndexWithOffsetPosition extends ScrollIndexWithOffset { } export type GetRenderedItemResult = { index: number; item: ItemT; renderedItem: React.ReactNode }; -export type GetRenderedItem = (key: string) => GetRenderedItemResult | null; +export type GetRenderedItem = (key: string) => GetRenderedItemResult | null; \ No newline at end of file From d223f5795eb8776220170438c6fb9bcd74d4fee1 Mon Sep 17 00:00:00 2001 From: VladyslavMartynov10 Date: Tue, 25 Nov 2025 21:14:29 +0200 Subject: [PATCH 4/5] chore: revert onScrollProp --- src/core/onScroll.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/onScroll.ts b/src/core/onScroll.ts index c54bc2af..c8ce7f37 100644 --- a/src/core/onScroll.ts +++ b/src/core/onScroll.ts @@ -24,9 +24,7 @@ export function onScroll(ctx: StateContext, state: InternalState, event: NativeS updateScroll(ctx, state, newScroll); - if (onScrollProp) { - onScrollProp(event as NativeSyntheticEvent); - } + onScrollProp?.(event as NativeSyntheticEvent); } function updateScroll(ctx: StateContext, state: InternalState, newScroll: number) { From 986712cd46708e0fee718ba6685497bca78b2a65 Mon Sep 17 00:00:00 2001 From: VladyslavMartynov10 Date: Tue, 25 Nov 2025 21:17:59 +0200 Subject: [PATCH 5/5] chore: types --- src/integrations/reanimated.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/integrations/reanimated.tsx b/src/integrations/reanimated.tsx index 7868ab4a..64f9a9d0 100644 --- a/src/integrations/reanimated.tsx +++ b/src/integrations/reanimated.tsx @@ -28,11 +28,12 @@ type KeysToOmit = type PropsBase = LegendListPropsBase>; +type ReanimatedScrollHandler = | ((event: NativeSyntheticEvent) => void) +| SharedValue<((event: NativeSyntheticEvent) => void) | undefined>; + export interface AnimatedLegendListPropsBase extends Omit, KeysToOmit> { refScrollView?: React.Ref; - onScroll?: - | ((event: NativeSyntheticEvent) => void) - | SharedValue<((event: NativeSyntheticEvent) => void) | undefined>; + onScroll?: ReanimatedScrollHandler; } type OtherAnimatedLegendListProps = Pick, KeysToOmit>; @@ -44,9 +45,7 @@ const LegendListForwardedRef = typedMemo( React.forwardRef(function LegendListForwardedRef( props: LegendListProps & { refLegendList: (r: LegendListRef | null) => void; - onScroll?: - | ((event: NativeSyntheticEvent) => void) - | SharedValue<((event: NativeSyntheticEvent) => void) | undefined>; + onScroll?: ReanimatedScrollHandler; }, ref: React.Ref, ) {