diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1abf788e..d5118479 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,6 @@
+## 2.0.17
+ - Feat: Add Reanimated onScroll handler executed on the UI thread
+
## 2.0.16
- Feat: Add KeyboardAvoidingLegendList component for better keyboard handling integration
- Fix: Stale containers are not being removed and overlap with new data when using getItemType #335
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..8974a1e9
--- /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 type { LegendListRenderItemProps } from "@/types";
+import { countries, getEmojiFlag, type TCountryCode } from "countries-list";
+
+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/integrations/reanimated.tsx b/src/integrations/reanimated.tsx
index 6b74d74d..64f9a9d0 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,17 @@ type KeysToOmit =
| "renderItem"
| "onItemSizeChanged"
| "itemsAreEqual"
- | "ItemSeparatorComponent";
+ | "ItemSeparatorComponent"
+ | "onScroll";
type PropsBase = LegendListPropsBase>;
+type ReanimatedScrollHandler = | ((event: NativeSyntheticEvent) => void)
+| SharedValue<((event: NativeSyntheticEvent) => void) | undefined>;
+
export interface AnimatedLegendListPropsBase extends Omit, KeysToOmit> {
refScrollView?: React.Ref;
+ onScroll?: ReanimatedScrollHandler;
}
type OtherAnimatedLegendListProps = Pick, KeysToOmit>;
@@ -35,10 +43,13 @@ 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?: ReanimatedScrollHandler;
+ },
ref: React.Ref,
) {
- const { refLegendList, ...rest } = props;
+ const { refLegendList, onScroll, ...rest } = props;
const refFn = useCallback(
(r: LegendListRef) => {
@@ -47,7 +58,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 63b6ade5..1c6ca10d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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
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";