diff --git a/example/app/(tabs)/index.tsx b/example/app/(tabs)/index.tsx index 99ef7c82..52b01b56 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: "Animated LegendList", + url: "/animated-legend-list", + }, ].map( (v, i) => ({ diff --git a/example/app/animated-legend-list/index.tsx b/example/app/animated-legend-list/index.tsx new file mode 100644 index 00000000..32e917fc --- /dev/null +++ b/example/app/animated-legend-list/index.tsx @@ -0,0 +1,222 @@ +import { AnimatedLegendList } from "@legendapp/list"; +import { type TCountryCode, countries, getEmojiFlag } from "countries-list"; +import { memo, useCallback, useState } from "react"; +import { SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native"; +import Animated, { + CurvedTransition, + EntryExitTransition, + FadeIn, + FadeOut, + FadingTransition, + JumpingTransition, + LayoutAnimationConfig, + LinearTransition, + SequencedTransition, +} from "react-native-reanimated"; + +type Country = { + id: string; + name: string; + flag: string; +}; + +const ORIGINAL_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)) + .slice(0, 30); + +const LAYOUT_TRANSITIONS = [ + LinearTransition, + FadingTransition, + SequencedTransition, + JumpingTransition, + CurvedTransition, + EntryExitTransition, +]; + +const CountryItem = memo(({ item, onPress }: { item: Country; onPress: (id: string) => void }) => ( + onPress(item.id)} style={styles.listItem}> + + {item.flag} + + + {item.name} + ({item.id}) - Tap to remove + + +)); + +const App = () => { + const [countriesData, setCountriesData] = useState(ORIGINAL_DATA); + + const [search, setSearch] = useState(""); + + const [currentTransitionIndex, setCurrentTransitionIndex] = useState(0); + + const [layoutTransitionEnabled, setLayoutTransitionEnabled] = useState(true); + + const layout = layoutTransitionEnabled ? LAYOUT_TRANSITIONS[currentTransitionIndex] : undefined; + + const removeItem = (id: string) => { + setCountriesData((prev) => prev.filter((c) => c.id !== id)); + }; + + const addItem = () => { + const unused = ORIGINAL_DATA.find((c) => !countriesData.some((d) => d.id === c.id)); + if (unused) { + setCountriesData((prev) => [unused, ...prev]); + } + }; + + const reorderItems = () => { + setCountriesData((prev) => [...prev].sort(() => Math.random() - 0.5)); + }; + + const resetItems = () => { + setCountriesData(ORIGINAL_DATA); + setSearch(""); + }; + + const renderItem = useCallback( + ({ item }: { item: Country }) => , + [removeItem], + ); + + const keyExtractor = useCallback((item: Country) => item.id, []); + + const filteredData = countriesData.filter( + (c) => c.name.toLowerCase().includes(search.toLowerCase()) || c.id.toLowerCase().includes(search.toLowerCase()), + ); + + return ( + + + + + + setLayoutTransitionEnabled((v) => !v)}> + Layout: {layoutTransitionEnabled ? "On" : "Off"} + + + {layout && ( + + Current: {layout?.presetName ?? "Unknown"} + + setCurrentTransitionIndex((prev) => (prev + 1) % LAYOUT_TRANSITIONS.length) + } + > + Change + + + )} + + + + + + + Add Country + + + Reorder + + + Reset + + + + + ); +}; + +export default App; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#fff", + }, + controls: { + padding: 12, + alignItems: "center", + gap: 12, + }, + row: { + flexDirection: "row", + gap: 16, + alignItems: "center", + }, + input: { + width: "90%", + height: 40, + backgroundColor: "#f0f0f0", + borderRadius: 8, + paddingHorizontal: 10, + }, + buttonText: { + fontSize: 16, + fontWeight: "600", + color: "#7a42f4", + }, + transitionText: { + fontSize: 16, + color: "#333", + }, + listContainer: { + padding: 12, + gap: 8, + }, + listItem: { + padding: 16, + backgroundColor: "#e3d7fb", + borderRadius: 10, + flexDirection: "row", + alignItems: "center", + }, + flagContainer: { + marginRight: 12, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "#fff", + justifyContent: "center", + alignItems: "center", + }, + flag: { + fontSize: 26, + }, + contentContainer: { + flex: 1, + }, + title: { + fontSize: 16, + fontWeight: "500", + color: "#222", + }, + subtitle: { + fontSize: 13, + color: "#666", + }, +}); diff --git a/example/bun.lock b/example/bun.lock index 82fa33c3..78a11dc0 100644 --- a/example/bun.lock +++ b/example/bun.lock @@ -2693,4 +2693,4 @@ "logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, ""], } -} +} \ No newline at end of file diff --git a/src/Container.tsx b/src/Container.tsx index 88f83c6c..8df4d805 100644 --- a/src/Container.tsx +++ b/src/Container.tsx @@ -14,6 +14,7 @@ export const Container = ({ getRenderedItem, updateItemSize, ItemSeparatorComponent, + CellRendererComponent, }: { id: number; recycleItems?: boolean; @@ -21,6 +22,7 @@ export const Container = ({ getRenderedItem: (key: string) => { index: number; item: ItemT; renderedItem: React.ReactNode } | null; updateItemSize: (itemKey: string, size: number) => void; ItemSeparatorComponent?: React.ComponentType<{ leadingItem: ItemT }>; + CellRendererComponent?: React.ComponentType | null; }) => { const ctx = useStateContext(); const columnWrapperStyle = ctx.columnWrapperStyle; @@ -167,10 +169,18 @@ export const Container = ({ return { containerId: id, itemKey, index: index!, value: data, triggerLayout }; }, [id, itemKey, index, data]); + const content = CellRendererComponent ? ( + + {renderedItem} + + ) : ( + renderedItem + ); + const contentFragment = ( - {renderedItem} + {content} {renderedItemInfo && ItemSeparatorComponent && !lastItemKeys.includes(itemKey) && ( )} diff --git a/src/Containers.tsx b/src/Containers.tsx index ed1a652e..3a8733a5 100644 --- a/src/Containers.tsx +++ b/src/Containers.tsx @@ -13,6 +13,7 @@ interface ContainersProps { waitForInitialLayout: boolean | undefined; updateItemSize: (itemKey: string, size: number) => void; getRenderedItem: (key: string) => { index: number; item: ItemT; renderedItem: React.ReactNode } | null; + CellRendererComponent?: React.ComponentType | null; } export const Containers = typedMemo(function Containers({ @@ -22,6 +23,7 @@ export const Containers = typedMemo(function Containers({ waitForInitialLayout, updateItemSize, getRenderedItem, + CellRendererComponent, }: ContainersProps) { const ctx = useStateContext(); const columnWrapperStyle = ctx.columnWrapperStyle; @@ -42,6 +44,7 @@ export const Containers = typedMemo(function Containers({ // specifying inline separator makes Containers rerender on each data change // should we do memo of ItemSeparatorComponent? ItemSeparatorComponent={ItemSeparatorComponent} + CellRendererComponent={CellRendererComponent} />, ); } diff --git a/src/LegendList.tsx b/src/LegendList.tsx index cfb3fdb8..d39cf748 100644 --- a/src/LegendList.tsx +++ b/src/LegendList.tsx @@ -101,6 +101,7 @@ const LegendListInner = typedForwardRef(function LegendListInner( viewabilityConfig, viewabilityConfigCallbackPairs, onViewableItemsChanged, + CellRendererComponent, ...rest } = props; @@ -943,7 +944,10 @@ const LegendListInner = typedForwardRef(function LegendListInner( const setPaddingTop = ({ stylePaddingTop, alignItemsPaddingTop, - }: { stylePaddingTop?: number; alignItemsPaddingTop?: number }) => { + }: { + stylePaddingTop?: number; + alignItemsPaddingTop?: number; + }) => { if (stylePaddingTop !== undefined) { const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; if (stylePaddingTop < prevStylePaddingTop) { @@ -1867,6 +1871,7 @@ const LegendListInner = typedForwardRef(function LegendListInner( } style={style} contentContainerStyle={contentContainerStyle} + CellRendererComponent={CellRendererComponent} /> {__DEV__ && ENABLE_DEBUG_VIEW && } diff --git a/src/ListComponent.tsx b/src/ListComponent.tsx index 8a3a27df..7515299a 100644 --- a/src/ListComponent.tsx +++ b/src/ListComponent.tsx @@ -124,6 +124,7 @@ export const ListComponent = typedMemo(function ListComponent({ onRefresh, refreshing, progressViewOffset, + CellRendererComponent, ...rest }: ListComponentProps) { const ctx = useStateContext(); @@ -198,6 +199,7 @@ export const ListComponent = typedMemo(function ListComponent({ waitForInitialLayout={waitForInitialLayout} getRenderedItem={getRenderedItem} ItemSeparatorComponent={ItemSeparatorComponent} + CellRendererComponent={CellRendererComponent} updateItemSize={updateItemSize} /> {ListFooterComponent && ( diff --git a/src/index.ts b/src/index.ts index 57bdbcac..08efb184 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { LegendList } from "./LegendList"; +export { AnimatedLegendList } from "./reanimated"; export { useRecyclingEffect, useRecyclingState, useViewability, useViewabilityAmount } from "./ContextContainer"; export type * from "./types"; diff --git a/src/reanimated.tsx b/src/reanimated.tsx index 782d8dd0..8b3194ac 100644 --- a/src/reanimated.tsx +++ b/src/reanimated.tsx @@ -1,6 +1,10 @@ import { LegendList, type LegendListProps, type LegendListPropsBase, type LegendListRef } from "@legendapp/list"; -import React, { type ComponentProps } from "react"; +import React, { useMemo, useRef, type ComponentProps } from "react"; +import type { LayoutChangeEvent, StyleProp, ViewStyle } from "react-native"; import Animated from "react-native-reanimated"; +import type { ILayoutAnimationBuilder } from "react-native-reanimated"; +import { LayoutAnimationConfig } from "react-native-reanimated"; +import type { AnimatedStyle } from "react-native-reanimated"; import { useCombinedRef } from "./useCombinedRef"; type KeysToOmit = @@ -15,6 +19,9 @@ type PropsBase = LegendListPropsBase extends Omit, KeysToOmit> { refScrollView?: React.Ref; + itemLayoutAnimation?: ILayoutAnimationBuilder; + skipEnteringExitingAnimations?: boolean; + CellRendererComponent?: never; } type OtherAnimatedLegendListProps = Pick, KeysToOmit>; @@ -37,8 +44,6 @@ const LegendListForwardedRef = React.forwardRef(function LegendListForwardedRef< ); }); -const AnimatedLegendListComponent = Animated.createAnimatedComponent(LegendListForwardedRef); - type AnimatedLegendListProps = Omit, "refLegendList"> & OtherAnimatedLegendListProps; @@ -47,18 +52,74 @@ type AnimatedLegendListDefinition = ( OtherAnimatedLegendListProps & { ref?: React.Ref }, ) => React.ReactElement | null; -// A component that has the shape of LegendList which passes the ref down as refLegendList +interface CellRendererComponentProps { + onLayout?: (event: LayoutChangeEvent) => void; + children: React.ReactNode; + style?: StyleProp>; +} + +function createCellRendererComponent( + itemLayoutAnimationRef: React.MutableRefObject, +) { + const CellRendererComponent = (props: CellRendererComponentProps) => { + return ( + + {props.children} + + ); + }; + + return CellRendererComponent; +} + +const BaseAnimatedLegendList = Animated.createAnimatedComponent(LegendListForwardedRef); + const AnimatedLegendList = React.forwardRef(function AnimatedLegendList( props: AnimatedLegendListProps, ref: React.Ref, ) { - const { refScrollView, ...rest } = props as AnimatedLegendListPropsBase; + const { refScrollView, itemLayoutAnimation, skipEnteringExitingAnimations, ...rest } = + props as AnimatedLegendListPropsBase; - const refLegendList = React.useRef(null); + // Set default scrollEventThrottle, because user expects + // to have continuous scroll events and + // react-native defaults it to 50 for FlatLists. + // We set it to 1, so we have peace until + // there are 960 fps screens. + if (!("scrollEventThrottle" in rest)) { + rest.scrollEventThrottle = 1; + } + const refLegendList = useRef(null); const combinedRef = useCombinedRef(refLegendList, ref); - return ; + const itemLayoutAnimationRef = useRef(itemLayoutAnimation); + + itemLayoutAnimationRef.current = itemLayoutAnimation; + + const CellRendererComponent = useMemo( + () => createCellRendererComponent(itemLayoutAnimationRef), + [itemLayoutAnimationRef], + ); + + const animatedList = ( + + ); + + if (skipEnteringExitingAnimations === undefined) { + return animatedList; + } + + return ( + + {animatedList} + + ); }) as AnimatedLegendListDefinition; export { AnimatedLegendList, type AnimatedLegendListProps }; diff --git a/src/types.ts b/src/types.ts index 5af5a77b..8a0baa84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -239,6 +239,21 @@ export type LegendListPropsBase< * @default false */ waitForInitialLayout?: boolean; + + /** + * CellRendererComponent allows customizing how cells rendered by + * `renderItem`/`ListItemComponent` are wrapped when placed into the + * underlying ScrollView. This component must accept event handlers which + * notify VirtualizedList of changes within the cell. + */ + CellRendererComponent?: + | React.ComponentType<{ + children: React.ReactNode; + index: number; + item: ItemT; + }> + | null + | undefined; }; export interface ColumnWrapperStyle { @@ -378,10 +393,7 @@ export type LegendListRef = { * @param params.animated - If true, animates the scroll. Default: true. * @param params.index - The index to scroll to. */ - scrollIndexIntoView(params: { - animated?: boolean | undefined; - index: number; - }): void; + scrollIndexIntoView(params: { animated?: boolean | undefined; index: number }): void; /** * Scrolls a specific index into view. @@ -389,10 +401,7 @@ export type LegendListRef = { * @param params.animated - If true, animates the scroll. Default: true. * @param params.item - The item to scroll to. */ - scrollItemIntoView(params: { - animated?: boolean | undefined; - item: any; - }): void; + scrollItemIntoView(params: { animated?: boolean | undefined; item: any }): void; /** * Scrolls to the end of the list.