demo-react-animated-list/src/useAnimatedListItems.ts

113 lines
3.1 KiB
TypeScript
Raw Normal View History

2024-08-06 16:26:30 -07:00
import { useLayoutEffect, useRef, useState } from 'react';
interface ItemTops {
2024-08-06 16:36:00 -07:00
[id: string]: number | undefined;
2024-08-06 16:26:30 -07:00
}
interface ListItemRefsById {
2024-08-06 16:36:00 -07:00
[id: string]: HTMLLIElement | undefined;
2024-08-06 16:26:30 -07:00
}
interface ItemOffsets {
2024-08-06 16:36:00 -07:00
[id: string]: number | undefined;
2024-08-06 16:26:30 -07:00
}
interface ItemStyles {
position: 'relative';
top: number;
transition: string;
}
interface UseAnimatedListItemArgs {
/**
* **IMPORTANT - This list must be memoized. The array reference should only change when the
* list of items changes.**
*
* The list of item keys. Each key should uniquely identify a `<ListItem>` component, and
* should be the same value passed to the `ListItem`'s `key` prop.
*/
2024-08-06 16:36:00 -07:00
keys: string[] | undefined;
2024-08-06 16:26:30 -07:00
}
interface UseAnimatedListItemReturns {
/**
* Item styles that should be passed to the `sx` prop of each `ListItem`. The styles are
* mapped per item key.
*/
2024-08-06 16:36:00 -07:00
itemStyles: { [key: string]: ItemStyles | undefined };
2024-08-06 16:26:30 -07:00
/**
* Function that must be called within the `ref` callback prop of each `ListItem`.
*
2024-08-06 16:36:00 -07:00
* @param {string} key - the `ListItem`'s unique key.
2024-08-06 16:26:30 -07:00
* @param {HTMLLIElement} li - the list item element reference.
* @returns {void}
*/
2024-08-06 16:36:00 -07:00
updateItemRef: (key: string, li: HTMLLIElement | null) => void;
2024-08-06 16:26:30 -07:00
/**
* Function that must be called within each and every callback that causes the list of items
* to refresh or update. This is used to take a "snapshot" of the items current positions prior
* to any change.
*
* @returns {void}
*/
updateItemPositions: () => void;
}
/**
* This hook can be plugged into a MUI <List> to animate the <ListItem> components whenever
* change their order.
*/
export const useAnimatedListItems = ({
keys,
}: UseAnimatedListItemArgs): UseAnimatedListItemReturns => {
const itemRefs = useRef<ListItemRefsById>({});
const itemTops = useRef<ItemTops>({});
const [itemOffsets, setItemOffsets] = useState<ItemOffsets>({});
useLayoutEffect(() => {
if (!keys) return;
const newItemOffsets: ItemOffsets = {};
keys.forEach((key) => {
const itemRef = itemRefs.current[key];
if (itemRef) {
const currentTop = itemRef.getBoundingClientRect().top;
const prevTop = itemTops.current[key] || 0;
const offset = -(currentTop - prevTop);
newItemOffsets[key] = offset;
}
});
setItemOffsets(newItemOffsets);
requestAnimationFrame(() => {
setItemOffsets({});
});
}, [keys]);
let itemStyles: UseAnimatedListItemReturns['itemStyles'] = {};
keys?.forEach((key) => {
itemStyles[key] = {
position: 'relative',
top: itemOffsets[key] || 0,
transition:
!itemTops.current[key] || itemOffsets[key] ? 'top 0s' : 'top 1s',
};
});
return {
itemStyles,
updateItemRef: (key, li) => {
li === null ? delete itemRefs.current[key] : (itemRefs.current[key] = li);
},
updateItemPositions: () => {
keys?.forEach((key) => {
const itemRef = itemRefs.current[key];
if (itemRef) {
itemTops.current[key] = itemRef.getBoundingClientRect().top;
}
});
},
};
};