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.