From 18926ee6449d6cc574ce55206e0c7ba437b6f7fd Mon Sep 17 00:00:00 2001 From: VladyslavMartynov10 Date: Mon, 5 May 2025 10:20:57 +0300 Subject: [PATCH 1/2] feat: reanimated scroll on ui --- example/app/(tabs)/index.tsx | 4 + example/app/reanimated-scroll-state/index.tsx | 141 ++++++++++++++++++ src/LegendList.tsx | 14 +- src/types.ts | 5 +- 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 example/app/reanimated-scroll-state/index.tsx 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..7ccc24e6 100644 --- a/src/LegendList.tsx +++ b/src/LegendList.tsx @@ -1727,8 +1727,18 @@ const LegendListInner = typedForwardRef(function LegendListInner( checkAtBottom(); checkAtTop(); - if (!fromSelf) { - state.onScroll?.(event as NativeSyntheticEvent); + if (!fromSelf && state.onScroll) { + const scrollHandler = state.onScroll; + + if ( + typeof scrollHandler === "object" && + "value" in scrollHandler && + typeof scrollHandler.value === "function" + ) { + scrollHandler.value(event as NativeSyntheticEvent); + } else if (typeof scrollHandler === "function") { + scrollHandler(event as NativeSyntheticEvent); + } } }, [], 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 { From aed35cc207b8d46b071511fce37b9f0df1d5067c Mon Sep 17 00:00:00 2001 From: VladyslavMartynov10 Date: Mon, 5 May 2025 16:03:33 +0300 Subject: [PATCH 2/2] feat: normal ts typeguard --- src/LegendList.tsx | 10 +++------- src/helpers.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/LegendList.tsx b/src/LegendList.tsx index 7ccc24e6..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, @@ -1730,12 +1730,8 @@ const LegendListInner = typedForwardRef(function LegendListInner( if (!fromSelf && state.onScroll) { const scrollHandler = state.onScroll; - if ( - typeof scrollHandler === "object" && - "value" in scrollHandler && - typeof scrollHandler.value === "function" - ) { - scrollHandler.value(event as NativeSyntheticEvent); + 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; +}