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";