diff --git a/src/components/List/ListScrollProvider.tsx b/src/components/List/ListScrollProvider.tsx new file mode 100644 index 0000000000..bdabe38484 --- /dev/null +++ b/src/components/List/ListScrollProvider.tsx @@ -0,0 +1,72 @@ +import {createContext, useCallback, useContext, useMemo} from 'react' +import {type NativeScrollEvent} from 'react-native' +import {type ScrollHandlers} from 'react-native-reanimated' + +export type NormalizedScrollHandlers = { + /** + * Web + Native — needs to be a `worklet` on native, but can be either on web + */ + onScroll?: ScrollHandlers['onScroll'] + /** + * Native only + */ + onScrollBeginDrag?: ScrollHandlers['onBeginDrag'] + /** + * Native only + */ + onScrollEndDrag?: ScrollHandlers['onEndDrag'] + /** + * Native only + */ + onMomentumScrollBegin?: ScrollHandlers['onMomentumBegin'] + /** + * Native only + */ + onMomentumScrollEnd?: ScrollHandlers['onMomentumEnd'] +} + +const ListScrollContext = createContext({ + onScroll: undefined, + onScrollBeginDrag: undefined, + onScrollEndDrag: undefined, + onMomentumScrollBegin: undefined, + onMomentumScrollEnd: undefined, +}) +ListScrollContext.displayName = 'ListScrollContext' + +export function ListScrollProvider({ + children, + onScrollBeginDrag, + onScrollEndDrag, + onScroll, + onMomentumScrollBegin, + onMomentumScrollEnd, +}: {children: React.ReactNode} & NormalizedScrollHandlers) { + const handlers = useMemo( + () => ({ + onScroll, + onScrollBeginDrag: onScrollBeginDrag, + onScrollEndDrag: onScrollEndDrag, + onMomentumScrollBegin: onMomentumScrollBegin, + onMomentumScrollEnd: onMomentumScrollEnd, + }), + [ + onScrollBeginDrag, + onScrollEndDrag, + onScroll, + onMomentumScrollBegin, + onMomentumScrollEnd, + ], + ) + return ( + + {children} + + ) +} + +export function useListScrollContext(): NormalizedScrollHandlers { + return useContext(ListScrollContext) +} + +export const useListScrollHandler = useCallback<(e: NativeScrollEvent) => void> diff --git a/src/components/List/index.tsx b/src/components/List/index.tsx new file mode 100644 index 0000000000..0797c1a999 --- /dev/null +++ b/src/components/List/index.tsx @@ -0,0 +1,66 @@ +import {forwardRef} from 'react' +import {type FlatList, type FlatListProps} from 'react-native' +import Animated, { + type FlatListPropsWithLayout, + useAnimatedScrollHandler, +} from 'react-native-reanimated' + +import {atoms as a, web} from '#/alf' +import {useListScrollContext} from '#/components/List/ListScrollProvider' + +export { + ListScrollProvider, + useListScrollHandler, +} from '#/components/List/ListScrollProvider' + +/** + * Cleaned up FlatList without some problematic props. + * + * - `contentOffset` - Doesn't work, use padding on `contentContainerStyle` instead. + */ +type ListProps = Omit, 'contentOffset'> + +export const List = forwardRef(function List( + props: ListProps, + ref: React.Ref>, +) { + const scrollHandlers = useListScrollContext() + const onScroll = useAnimatedScrollHandler({ + onScroll(e, ctx) { + scrollHandlers.onScroll?.(e, ctx) + }, + onBeginDrag(e, ctx) { + scrollHandlers.onScrollBeginDrag?.(e, ctx) + }, + onEndDrag(e, ctx) { + scrollHandlers.onScrollEndDrag?.(e, ctx) + }, + onMomentumBegin(e, ctx) { + scrollHandlers.onMomentumScrollBegin?.(e, ctx) + }, + /* + * Note: adding onMomentumBegin here makes simulator scroll lag on Android. + * So either don't add it, or figure out why. - sfn + */ + onMomentumEnd(e, ctx) { + scrollHandlers.onMomentumScrollEnd?.(e, ctx) + }, + }) + + return ( + + ref={ref} + {...(props as FlatListPropsWithLayout)} + style={[ + /* + * On web, the List should always fill its container, otherwise + * `onScroll` will not work due to the entire page scrolling. + */ + web(a.h_full), + ]} + onScroll={onScroll} + /> + ) +}) as ( + props: ListProps & {ref?: React.Ref>}, +) => React.ReactElement diff --git a/src/view/screens/StorybookLists/index.tsx b/src/view/screens/StorybookLists/index.tsx index 819acc3551..bd5cfbc22e 100644 --- a/src/view/screens/StorybookLists/index.tsx +++ b/src/view/screens/StorybookLists/index.tsx @@ -1,13 +1,10 @@ -import React from 'react' import {View} from 'react-native' -import {useNavigation} from '@react-navigation/native' +import {runOnJS} from 'react-native-reanimated' -import {type NavigationProp} from '#/lib/routes/types' -import {useSetThemePrefs} from '#/state/shell' -import {ListContained} from '#/view/screens/Storybook/ListContained' -import {atoms as a, ThemeProvider} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {atoms as a} from '#/alf' import * as Layout from '#/components/Layout' +import {List, ListScrollProvider, useListScrollHandler} from '#/components/List' +import {Text} from '#/components/Typography' export function StorybookLists() { return ( @@ -20,12 +17,43 @@ export function StorybookLists() { - + ) } -function StorybookInner() { - return null +const items = Array.from({length: 100}).map((_, i) => ({ + key: `item-${i + 1}`, + title: `Item ${i + 1}`, +})) + +type Item = { + key: string + title: string +} + +const log = (msg: any) => console.log(msg) + +export function Inner() { + const onScrollWorklet = useListScrollHandler(e => { + 'worklet' + runOnJS(log)(`Scroll Y: ${e.contentOffset.y}`) + }, []) + + return ( + + + + data={items} + renderItem={({item}) => ( + + {item.title} + + )} + style={[a.debug]} + /> + + + ) }