diff --git a/example/app/(tabs)/index.tsx b/example/app/(tabs)/index.tsx index 99ef7c82..2fd1d66b 100644 --- a/example/app/(tabs)/index.tsx +++ b/example/app/(tabs)/index.tsx @@ -107,6 +107,10 @@ const data: ListElement[] = [ title: "Cards FlatList", url: "/cards-flatlist", }, + { + 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..e3fdfc21 --- /dev/null +++ b/example/app/reanimated-scroll-state/index.tsx @@ -0,0 +1,141 @@ +import { AnimatedLegendList } from "@legendapp/list/reanimated"; +import { type TCountryCode, countries, getEmojiFlag } from "countries-list"; +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 type {} from "react-native-safe-area-context"; + +type Country = { + id: string; + name: string; + flag: string; +}; + +const DATA: Country[] = Object.entries(countries) + .map(([code, country]) => ({ + id: code, + name: country.name, + flag: getEmojiFlag(code as TCountryCode), + })) + .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 }: { item: Country }) => { + const isSelected = item.id === selectedId; + return setSelectedId(item.id)} isSelected={isSelected} />; + }, + [selectedId], + ); + + return ( + + + + + + ); +}; + +export default App; + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginTop: StatusBar.currentHeight || 0, + backgroundColor: "#f5f5f5", + }, + item: { + paddingHorizontal: 16, + paddingVertical: 6, + flexDirection: "row", + alignItems: "center", + backgroundColor: "#fff", + borderRadius: 12, + }, + selectedItem: {}, + pressedItem: {}, + flagContainer: { + marginRight: 16, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "#f8f9fa", + alignItems: "center", + justifyContent: "center", + }, + flag: { + fontSize: 28, + }, + contentContainer: { + flex: 1, + justifyContent: "center", + }, + title: { + fontSize: 16, + color: "#333", + fontWeight: "500", + }, + selectedText: { + color: "#1976d2", + fontWeight: "600", + }, + countryCode: { + fontSize: 14, + color: "#666", + fontWeight: "400", + }, +}); diff --git a/src/LegendList.tsx b/src/LegendList.tsx index cfb3fdb8..0c039b31 100644 --- a/src/LegendList.tsx +++ b/src/LegendList.tsx @@ -16,7 +16,7 @@ import { DebugView } from "./DebugView"; import { ListComponent } from "./ListComponent"; import { ScrollAdjustHandler } from "./ScrollAdjustHandler"; import { ANCHORED_POSITION_OUT_OF_VIEW, ENABLE_DEBUG_VIEW, IsNewArchitecture, POSITION_OUT_OF_VIEW } from "./constants"; -import { comparatorByDistance, comparatorDefault, roundSize, warnDevOnce } from "./helpers"; +import { comparatorByDistance, comparatorDefault, isReanimatedScroll, roundSize, warnDevOnce } from "./helpers"; import { StateProvider, getContentSize, peek$, set$, useStateContext } from "./state"; import type { AnchoredPosition, @@ -1727,8 +1727,14 @@ const LegendListInner = typedForwardRef(function LegendListInner( checkAtBottom(); checkAtTop(); - if (!fromSelf) { - state.onScroll?.(event as NativeSyntheticEvent); + if (!fromSelf && state.onScroll) { + const scrollHandler = state.onScroll; + + if (isReanimatedScroll(scrollHandler)) { + scrollHandler?.value?.(event as NativeSyntheticEvent); + } else if (typeof scrollHandler === "function") { + scrollHandler(event as NativeSyntheticEvent); + } } }, [], diff --git a/src/helpers.ts b/src/helpers.ts index 8ce4e8a3..99e8e51f 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,3 +1,8 @@ +import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; +import type { SharedValue } from "react-native-reanimated"; + +type ScrollHandler = (e: NativeSyntheticEvent) => void; + // biome-ignore lint/complexity/noBannedTypes: export function isFunction(obj: unknown): obj is Function { return typeof obj === "function"; @@ -30,3 +35,12 @@ export function comparatorDefault(a: number, b: number) { export function byIndex(a: { index: number }) { return a.index; } + +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"; + } + + return false; +} diff --git a/src/types.ts b/src/types.ts index 5af5a77b..137f2d4c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,7 @@ import type { } from "react-native"; import type { ScrollView, StyleProp, ViewStyle } from "react-native"; import type Animated from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import type { ScrollAdjustHandler } from "./ScrollAdjustHandler"; export type LegendListPropsBase< @@ -317,7 +318,9 @@ export interface InternalState { avg: number; } >; - onScroll: ((event: NativeSyntheticEvent) => void) | undefined; + onScroll?: + | ((event: NativeSyntheticEvent) => void) + | SharedValue<((event: NativeSyntheticEvent) => void) | undefined>; } export interface ViewableRange {