diff --git a/README.md b/README.md index ea1fcac..b4fa2c5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Full documentation lives here: - Scroll-driven animated headers - Shared header state across tabs, pagers, and multiple scrollables - Navigation-rendered headers in Expo Router or React Navigation -- Custom scrollables via `createHeaderMotionScrollable()` +- Custom scrollables via `createHeaderMotionScrollable()` and `ScrollablePresets` - Optional header panning ## What it is not diff --git a/docs/docs/api/create-header-motion-scrollable.md b/docs/docs/api/create-header-motion-scrollable.md index cb9ade0..7509385 100644 --- a/docs/docs/api/create-header-motion-scrollable.md +++ b/docs/docs/api/create-header-motion-scrollable.md @@ -9,6 +9,8 @@ Factory function for creating reusable Header Motion-aware wrappers around custo This is the recommended way to integrate third-party scrollables like FlashList or LegendList. +For common integrations, the library also exports `ScrollablePresets` so you do not need to remember the recommended option combinations yourself. + ## Signature ```tsx @@ -20,38 +22,55 @@ function createHeaderMotionScrollable( ## Options -| Option | Type | Default | Description | -| ---------------------- | --------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `displayName` | `string` | Auto-generated | Component name shown in React DevTools. | -| `isComponentAnimated` | `boolean` | `false` | Set to `true` when the component is already animated (skips `Animated.createAnimatedComponent`). | -| `contentContainerMode` | `'children' \| 'renderScrollComponent'` | `'renderScrollComponent'` | How Header Motion injects content offsetting. Use `'children'` for ScrollView-like, `'renderScrollComponent'` for FlatList-like. | +| Option | Type | Default | Description | +| ---------------------- | --------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `displayName` | `string` | Auto-generated | Component name shown in React DevTools. | +| `isComponentAnimated` | `boolean` | `false` | Set to `true` when the component is already animated (skips `Animated.createAnimatedComponent`). | +| `contentContainerMode` | `'children' \| 'renderScrollComponent'` | `'renderScrollComponent'` | How Header Motion injects content offsetting. Use `'children'` for ScrollView-like, `'renderScrollComponent'` for FlatList-like. | +| `managedRefTarget` | `'outer' \| 'inner'` | `'outer'` | Which ref Header Motion uses for imperative sync. Only applies in `renderScrollComponent` mode. Use `'inner'` when the wrapped list's outer ref is not the actual scroll view. | ## Examples -### FlashList +### FlashList preset ```tsx import { FlashList } from '@shopify/flash-list'; -import { createHeaderMotionScrollable } from 'react-native-header-motion'; +import { + createHeaderMotionScrollable, + ScrollablePresets, +} from 'react-native-header-motion'; -const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { - displayName: 'HeaderMotionFlashList', -}); +const HeaderMotionFlashList = createHeaderMotionScrollable( + FlashList, + ScrollablePresets.FlashList +); ``` -### LegendList +### LegendList preset ```tsx import { AnimatedLegendList } from '@legendapp/list/reanimated'; -import { createHeaderMotionScrollable } from 'react-native-header-motion'; +import { + createHeaderMotionScrollable, + ScrollablePresets, +} from 'react-native-header-motion'; const HeaderMotionLegendList = createHeaderMotionScrollable( AnimatedLegendList, - { - displayName: 'HeaderMotionLegendList', - isComponentAnimated: true, - } + ScrollablePresets.AnimatedLegendList ); ``` +### Manual options + +```tsx +const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { + displayName: 'HeaderMotionFlashList', + contentContainerMode: 'renderScrollComponent', + managedRefTarget: 'inner', +}); +``` + +Use `managedRefTarget: 'inner'` when the wrapped component exposes a controller ref on the outside but owns the actual native scroll view internally. FlashList and LegendList both need this. React Native `FlatList` does not, so the default `'outer'` behavior remains correct there. + The returned component accepts all the original component's props plus Header Motion-specific props (`scrollId`, `headerOffsetStrategy`, `ensureScrollableContentMinHeight`, `animatedRef`). diff --git a/docs/docs/guides/custom-scrollables.md b/docs/docs/guides/custom-scrollables.md index 3bd8e20..14afc53 100644 --- a/docs/docs/guides/custom-scrollables.md +++ b/docs/docs/guides/custom-scrollables.md @@ -7,15 +7,21 @@ title: Custom scrollables Header Motion ships with `HeaderMotion.ScrollView` and `HeaderMotion.FlatList`, but you can integrate **any** scrollable component via the `createHeaderMotionScrollable()` factory. The resulting component works just like the built-in ones β€” it accepts all the original props plus Header Motion-specific ones like `scrollId` and `headerOffsetStrategy`. +For popular third-party lists, the library also exports `ScrollablePresets` with the recommended option combinations. + ## FlashList ```tsx import { FlashList } from '@shopify/flash-list'; -import { createHeaderMotionScrollable } from 'react-native-header-motion'; +import { + createHeaderMotionScrollable, + ScrollablePresets, +} from 'react-native-header-motion'; -const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { - displayName: 'HeaderMotionFlashList', -}); +const HeaderMotionFlashList = createHeaderMotionScrollable( + FlashList, + ScrollablePresets.FlashList +); ``` Use it anywhere you'd use a regular `FlashList`: @@ -35,17 +41,40 @@ LegendList exports a pre-animated variant, `AnimatedLegendList`, which is what y ```tsx import { AnimatedLegendList } from '@legendapp/list/reanimated'; -import { createHeaderMotionScrollable } from 'react-native-header-motion'; +import { + createHeaderMotionScrollable, + ScrollablePresets, +} from 'react-native-header-motion'; const HeaderMotionLegendList = createHeaderMotionScrollable( AnimatedLegendList, - { - displayName: 'HeaderMotionLegendList', - isComponentAnimated: true, - } + ScrollablePresets.AnimatedLegendList ); ``` +## Presets vs manual options + +`ScrollablePresets` is the easiest path when you are integrating supported third-party lists: + +```tsx +import { ScrollablePresets } from 'react-native-header-motion'; + +ScrollablePresets.FlashList; +ScrollablePresets.AnimatedLegendList; +``` + +If you prefer to configure things manually, these are the equivalent options: + +```tsx +const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { + displayName: 'HeaderMotionFlashList', + contentContainerMode: 'renderScrollComponent', + managedRefTarget: 'inner', +}); +``` + +Use `managedRefTarget: 'inner'` when a third-party list exposes a controller ref on the outside but keeps the real native scroll view one layer deeper. FlashList and LegendList both fall into that category. React Native `FlatList` does not, so it should keep the default `'outer'`. + ## Factory options | Option | Type | Default | Description | @@ -53,6 +82,7 @@ const HeaderMotionLegendList = createHeaderMotionScrollable( | `displayName` | `string` | Auto-derived from the component name | Sets the React `displayName` for dev tools. | | `isComponentAnimated` | `boolean` | `false` | When `true`, the factory skips `Animated.createAnimatedComponent()`. Set this when the component is already animated. | | `contentContainerMode` | `'children' \| 'renderScrollComponent'` | `'renderScrollComponent'` | `'children'` wraps children in an inner `Animated.View` (ScrollView-like). `'renderScrollComponent'` injects a custom scroll component that wraps the content (FlatList-like). | +| `managedRefTarget` | `'outer' \| 'inner'` | `'outer'` | Controls which ref Header Motion uses for imperative sync. Only applies in `renderScrollComponent` mode. Use `'inner'` for list abstractions like FlashList and LegendList. | ## What's next? diff --git a/docs/docs/guides/default-scrollables.md b/docs/docs/guides/default-scrollables.md index a43fcb2..0341d44 100644 --- a/docs/docs/guides/default-scrollables.md +++ b/docs/docs/guides/default-scrollables.md @@ -66,7 +66,7 @@ You can also pass standard scroll event handlers like `onScroll`, `onScrollBegin ## Custom scrollables -For third-party scrollable components like [FlashList](https://shopify.github.io/flash-list/) or [LegendList](https://github.com/LegendApp/legend-list), use the `createHeaderMotionScrollable()` factory. For more information, check the [Custom scrollables](./custom-scrollables) guide. +For third-party scrollable components like [FlashList](https://shopify.github.io/flash-list/) or [LegendList](https://github.com/LegendApp/legend-list), use the `createHeaderMotionScrollable()` factory. For those two common integrations, you can also start from the exported `ScrollablePresets`. For more information, check the [Custom scrollables](./custom-scrollables) guide. For even more control, you can manage the scroll integration yourself using `HeaderMotion.ScrollManager` or the `useScrollManager()` hook. See [Using ScrollManager](./using-scroll-manager) for details. diff --git a/docs/docs/other/faq.md b/docs/docs/other/faq.md index 7ddd6a1..9b37883 100644 --- a/docs/docs/other/faq.md +++ b/docs/docs/other/faq.md @@ -22,7 +22,7 @@ Header panning is built on Gesture Handler's pan gesture. Even if you don't use ## Can I use this with FlashList / LegendList / other custom scrollables? -Yes. Use `createHeaderMotionScrollable()` to wrap any scrollable component. See the [Custom scrollables](../guides/custom-scrollables) guide. +Yes. Use `createHeaderMotionScrollable()` to wrap any scrollable component. For FlashList and LegendList, you can also use the built-in `ScrollablePresets` export so the recommended factory options are already filled in. See the [Custom scrollables](../guides/custom-scrollables) guide. ## Does pull to refresh work? diff --git a/example/src/app/flashlist-pager.tsx b/example/src/app/flashlist-pager.tsx index 1e35f97..fe2ba89 100644 --- a/example/src/app/flashlist-pager.tsx +++ b/example/src/app/flashlist-pager.tsx @@ -6,6 +6,7 @@ import { } from '@/components'; import HeaderMotion, { createHeaderMotionScrollable, + ScrollablePresets, useActiveScrollId, useMotionProgress, } from 'react-native-header-motion'; @@ -23,9 +24,10 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FlashList } from '@shopify/flash-list'; -const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { - displayName: 'HeaderMotionFlashList', -}); +const HeaderMotionFlashList = createHeaderMotionScrollable( + FlashList, + ScrollablePresets.FlashList +); const indexToKey = new Map([ [0, 'A'], diff --git a/example/src/app/flashlist.tsx b/example/src/app/flashlist.tsx index 8971027..17c95d6 100644 --- a/example/src/app/flashlist.tsx +++ b/example/src/app/flashlist.tsx @@ -2,6 +2,7 @@ import { ContentCard, ShowcaseCollapsibleHeader } from '@/components'; import { FlashList } from '@shopify/flash-list'; import HeaderMotion, { createHeaderMotionScrollable, + ScrollablePresets, } from 'react-native-header-motion'; import { Stack } from 'expo-router'; @@ -10,9 +11,10 @@ type ListRow = { label: string; }; -const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { - displayName: 'HeaderMotionFlashList', -}); +const HeaderMotionFlashList = createHeaderMotionScrollable( + FlashList, + ScrollablePresets.FlashList +); export default function Screen() { return ( diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx index 59e03f9..5447cce 100644 --- a/example/src/app/index.tsx +++ b/example/src/app/index.tsx @@ -96,6 +96,21 @@ const SECTIONS: ShowcaseSection[] = [ href: '/scroll-to-button', icon: '🎯', }, + { + title: 'Scroll To Button (external ref) | FlatList', + href: '/scroll-to-button', + icon: 'πŸ“‹πŸŽ―', + }, + { + title: 'Scroll To Button (external ref) | FlashList', + href: '/scroll-to-button', + icon: '⚑️🎯', + }, + { + title: 'Scroll To Button (external ref) | LegendList', + href: '/scroll-to-button', + icon: '🧾🎯', + }, ], }, { diff --git a/example/src/app/legendlist-pager.tsx b/example/src/app/legendlist-pager.tsx index 4469ce3..5509df5 100644 --- a/example/src/app/legendlist-pager.tsx +++ b/example/src/app/legendlist-pager.tsx @@ -6,6 +6,7 @@ import { } from '@/components'; import HeaderMotion, { createHeaderMotionScrollable, + ScrollablePresets, useActiveScrollId, useMotionProgress, } from 'react-native-header-motion'; @@ -25,10 +26,7 @@ import { AnimatedLegendList } from '@legendapp/list/reanimated'; const HeaderMotionLegendList = createHeaderMotionScrollable( AnimatedLegendList, - { - displayName: 'HeaderMotionLegendList', - isComponentAnimated: true, - } + ScrollablePresets.AnimatedLegendList ); const indexToKey = new Map([ diff --git a/example/src/app/legendlist.tsx b/example/src/app/legendlist.tsx index 3717f0e..e11308c 100644 --- a/example/src/app/legendlist.tsx +++ b/example/src/app/legendlist.tsx @@ -1,6 +1,7 @@ import { ContentCard, ShowcaseCollapsibleHeader } from '@/components'; import HeaderMotion, { createHeaderMotionScrollable, + ScrollablePresets, } from 'react-native-header-motion'; import { Stack } from 'expo-router'; @@ -13,10 +14,7 @@ type ListRow = { const HeaderMotionLegendList = createHeaderMotionScrollable( AnimatedLegendList, - { - displayName: 'HeaderMotionLegendList', - isComponentAnimated: true, - } + ScrollablePresets.AnimatedLegendList ); export default function Screen() { diff --git a/example/src/app/scroll-to-button-flashlist.tsx b/example/src/app/scroll-to-button-flashlist.tsx new file mode 100644 index 0000000..19c1d2e --- /dev/null +++ b/example/src/app/scroll-to-button-flashlist.tsx @@ -0,0 +1,183 @@ +import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; +import HeaderMotion, { + createHeaderMotionScrollable, + ScrollablePresets, + useMotionProgress, +} from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { + Extrapolation, + interpolate, + scrollTo, + useAnimatedRef, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { scheduleOnUI } from 'react-native-worklets'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { FlashList } from '@shopify/flash-list'; + +const HeaderMotionFlashList = createHeaderMotionScrollable( + FlashList, + ScrollablePresets.FlashList +); + +export default function Screen() { + const scrollRef = useAnimatedRef(); + + const handleScrollToTop = () => { + scheduleOnUI(() => { + 'worklet'; + scrollTo(scrollRef, 0, 0, true); + }); + }; + + return ( + + + {(value) => ( + ( + + + + ), + }} + /> + )} + + `${item.index}`} + renderItem={({ item }) => ( + + )} + /> + + + TOP + + + ); +} + +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); + const insets = useSafeAreaInsets(); + + const containerStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.value, + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const titleStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.value, + [0, 1], + [0, threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const boxSectionStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const parallaxTranslateY = interpolate( + progress.value, + [0, 1], + [0, threshold * 0.5], + Extrapolation.CLAMP + ); + const opacity = interpolate( + progress.value, + [0, 1 * 0.6], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + progress.value, + [0, 1], + [1, 0.8], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateY: parallaxTranslateY }, { scale }], + }; + }); + + return ( + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + headerWrapper: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamicContent: { + overflow: 'hidden', + }, + boxContainer: { + flexDirection: 'row', + gap: 6, + padding: 12, + alignItems: 'stretch', + overflow: 'hidden', + }, + fab: { + position: 'absolute', + bottom: 32, + right: 24, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#304077', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 8, + }, + fabText: { + color: '#fff', + fontWeight: '800', + fontSize: 13, + }, +}); + +const content = Array.from({ length: 500 }, (_, k) => ({ + index: k + 1, + label: 'FlashList Item', +})); diff --git a/example/src/app/scroll-to-button-flatlist.tsx b/example/src/app/scroll-to-button-flatlist.tsx new file mode 100644 index 0000000..7535ea2 --- /dev/null +++ b/example/src/app/scroll-to-button-flatlist.tsx @@ -0,0 +1,173 @@ +import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { + Extrapolation, + interpolate, + scrollTo, + useAnimatedRef, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { scheduleOnUI } from 'react-native-worklets'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function Screen() { + const scrollRef = useAnimatedRef(); + + const handleScrollToTop = () => { + scheduleOnUI(() => { + 'worklet'; + scrollTo(scrollRef, 0, 0, true); + }); + }; + + return ( + + + {(value) => ( + ( + + + + ), + }} + /> + )} + + `${item.index}`} + renderItem={({ item }) => ( + + )} + /> + + + TOP + + + ); +} + +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); + const insets = useSafeAreaInsets(); + + const containerStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.value, + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const titleStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.value, + [0, 1], + [0, threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const boxSectionStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const parallaxTranslateY = interpolate( + progress.value, + [0, 1], + [0, threshold * 0.5], + Extrapolation.CLAMP + ); + const opacity = interpolate( + progress.value, + [0, 1 * 0.6], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + progress.value, + [0, 1], + [1, 0.8], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateY: parallaxTranslateY }, { scale }], + }; + }); + + return ( + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + headerWrapper: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamicContent: { + overflow: 'hidden', + }, + boxContainer: { + flexDirection: 'row', + gap: 6, + padding: 12, + alignItems: 'stretch', + overflow: 'hidden', + }, + fab: { + position: 'absolute', + bottom: 32, + right: 24, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#304077', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 8, + }, + fabText: { + color: '#fff', + fontWeight: '800', + fontSize: 13, + }, +}); + +const content = Array.from({ length: 500 }, (_, k) => ({ + index: k + 1, + label: 'FlatList Item', +})); diff --git a/example/src/app/scroll-to-button-legendlist.tsx b/example/src/app/scroll-to-button-legendlist.tsx new file mode 100644 index 0000000..55459ef --- /dev/null +++ b/example/src/app/scroll-to-button-legendlist.tsx @@ -0,0 +1,183 @@ +import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; +import HeaderMotion, { + createHeaderMotionScrollable, + ScrollablePresets, + useMotionProgress, +} from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { + Extrapolation, + interpolate, + scrollTo, + useAnimatedRef, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { scheduleOnUI } from 'react-native-worklets'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { AnimatedLegendList } from '@legendapp/list/reanimated'; + +const HeaderMotionLegendList = createHeaderMotionScrollable( + AnimatedLegendList, + ScrollablePresets.AnimatedLegendList +); + +export default function Screen() { + const scrollRef = useAnimatedRef(); + + const handleScrollToTop = () => { + scheduleOnUI(() => { + 'worklet'; + scrollTo(scrollRef, 0, 0, true); + }); + }; + + return ( + + + {(value) => ( + ( + + + + ), + }} + /> + )} + + `${item.index}`} + renderItem={({ item }) => ( + + )} + /> + + + TOP + + + ); +} + +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); + const insets = useSafeAreaInsets(); + + const containerStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.value, + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const titleStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.value, + [0, 1], + [0, threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const boxSectionStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const parallaxTranslateY = interpolate( + progress.value, + [0, 1], + [0, threshold * 0.5], + Extrapolation.CLAMP + ); + const opacity = interpolate( + progress.value, + [0, 1 * 0.6], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + progress.value, + [0, 1], + [1, 0.8], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateY: parallaxTranslateY }, { scale }], + }; + }); + + return ( + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + headerWrapper: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamicContent: { + overflow: 'hidden', + }, + boxContainer: { + flexDirection: 'row', + gap: 6, + padding: 12, + alignItems: 'stretch', + overflow: 'hidden', + }, + fab: { + position: 'absolute', + bottom: 32, + right: 24, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#304077', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 8, + }, + fabText: { + color: '#fff', + fontWeight: '800', + fontSize: 13, + }, +}); + +const content = Array.from({ length: 500 }, (_, k) => ({ + index: k + 1, + label: 'LegendList Item', +})); diff --git a/src/components/__tests__/createHeaderMotionScrollable.test.tsx b/src/components/__tests__/createHeaderMotionScrollable.test.tsx index 3cbc28c..e2020f7 100644 --- a/src/components/__tests__/createHeaderMotionScrollable.test.tsx +++ b/src/components/__tests__/createHeaderMotionScrollable.test.tsx @@ -1,6 +1,5 @@ const mockUseScrollManager = jest.fn(); const mockCreateAnimatedComponent = jest.fn((Component) => Component); - jest.mock('react', () => { const ReactActual = jest.requireActual('react'); @@ -19,7 +18,10 @@ jest.mock('react', () => { }); import React from 'react'; -import { createHeaderMotionScrollable } from '../createHeaderMotionScrollable'; +import { + createHeaderMotionScrollable, + type CreateHeaderMotionScrollableOptions, +} from '../createHeaderMotionScrollable'; jest.mock('../../hooks', () => ({ __esModule: true, @@ -96,6 +98,21 @@ void typedProps; // eslint-disable-next-line no-void void invalidTypedProps; +const validChildrenOptions: CreateHeaderMotionScrollableOptions = { + contentContainerMode: 'children', +}; + +const invalidChildrenOptions: CreateHeaderMotionScrollableOptions = { + contentContainerMode: 'children', + // @ts-expect-error managedRefTarget is only valid for renderScrollComponent mode. + managedRefTarget: 'inner', +}; + +// eslint-disable-next-line no-void +void validChildrenOptions; +// eslint-disable-next-line no-void +void invalidChildrenOptions; + function createScrollManagerResult() { return { scrollableProps: { @@ -208,14 +225,23 @@ describe('createHeaderMotionScrollable', () => { React.createElement(React.Fragment, null, item.label), }) as React.ReactElement; + expect(mockUseScrollManager).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + animatedRef: undefined, + }) + ); expect(element.props.children).toBeUndefined(); + expect(element.props.ref).toBe(scrollManagerResult.scrollableProps.ref); expect(typeof element.props.renderScrollComponent).toBe('function'); const scrollComponentElement = element.props.renderScrollComponent({ children: React.createElement('Child'), + ref: { current: null }, }); - const renderedScrollComponent = scrollComponentElement.type( - scrollComponentElement.props + const renderedScrollComponent = scrollComponentElement.type.render( + scrollComponentElement.props, + null ) as React.ReactElement; expect(renderedScrollComponent.props.children.props.style).toEqual([ @@ -224,4 +250,32 @@ describe('createHeaderMotionScrollable', () => { contentContainerStyle, ]); }); + + it('can target the injected inner scroll component for imperative sync', () => { + const scrollManagerResult = createScrollManagerResult(); + mockUseScrollManager.mockReturnValue(scrollManagerResult); + + const InnerManagedList = createHeaderMotionScrollable(GenericList, { + displayName: 'HeaderMotion.InnerManagedList', + isComponentAnimated: true, + managedRefTarget: 'inner', + }); + + const animatedRef = { current: null } as any; + const element = InnerManagedList({ + data: [{ id: '1', label: 'Row' }], + animatedRef, + renderItem: ({ item }) => + React.createElement(React.Fragment, null, item.label), + }) as React.ReactElement; + + expect(mockUseScrollManager).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + animatedRef: undefined, + }) + ); + expect(element.props.ref).toBe(animatedRef); + expect(typeof element.props.renderScrollComponent).toBe('function'); + }); }); diff --git a/src/components/createHeaderMotionScrollable.tsx b/src/components/createHeaderMotionScrollable.tsx index 72e50ee..355ce5c 100644 --- a/src/components/createHeaderMotionScrollable.tsx +++ b/src/components/createHeaderMotionScrollable.tsx @@ -33,9 +33,9 @@ export type HeaderMotionScrollableOwnProps< animatedRef?: AnimatedRef | AnimatedRef; }; -export interface CreateHeaderMotionScrollableOptions< +type CreateHeaderMotionScrollableCommonOptions< TIsComponentAnimated extends boolean = boolean -> { +> = { displayName?: string; /** * If true, this function will NOT call Animated.createAnimatedComponent internally. @@ -58,7 +58,39 @@ export interface CreateHeaderMotionScrollableOptions< * @default 'renderScrollComponent' */ contentContainerMode?: ContentContainerMode; -} +}; + +type CreateHeaderMotionScrollableChildrenOptions< + TIsComponentAnimated extends boolean = boolean +> = CreateHeaderMotionScrollableCommonOptions & { + contentContainerMode: 'children'; + managedRefTarget?: never; +}; + +type CreateHeaderMotionScrollableRenderOptions< + TIsComponentAnimated extends boolean = boolean +> = CreateHeaderMotionScrollableCommonOptions & { + contentContainerMode?: 'renderScrollComponent'; + /** + * Controls which ref HeaderMotion uses for imperative scroll synchronization. + * + * - `outer`: attach the managed ref to the wrapped scrollable component itself + * - `inner`: attach the managed ref to the injected inner scroll component + * + * Use `inner` for list abstractions like FlashList or LegendList whose outer + * ref is not the actual native scroll view that Reanimated `scrollTo()` + * should target. + * + * @default 'outer' + */ + managedRefTarget?: ManagedRefTarget; +}; + +export type CreateHeaderMotionScrollableOptions< + TIsComponentAnimated extends boolean = boolean +> = + | CreateHeaderMotionScrollableChildrenOptions + | CreateHeaderMotionScrollableRenderOptions; export function createHeaderMotionScrollable< TScrollableComponent extends ScrollableComponent, @@ -70,6 +102,7 @@ export function createHeaderMotionScrollable< const { isComponentAnimated = false, contentContainerMode = 'renderScrollComponent', + managedRefTarget = 'outer', displayName = `HeaderMotion(${getDisplayName( ScrollableComponent as { displayName?: string; @@ -117,7 +150,7 @@ export function createHeaderMotionScrollable< onScrollEndDrag, onMomentumScrollBegin, onMomentumScrollEnd, - animatedRef, + animatedRef: managedRefTarget === 'inner' ? undefined : animatedRef, ensureScrollableContentMinHeight, } ); @@ -171,6 +204,7 @@ export function createHeaderMotionScrollable< children: rest.children, mode: contentContainerMode, style: managedContentContainerStyle, + scrollRef: managedRefTarget === 'inner' ? ref : undefined, }); return ( @@ -179,7 +213,7 @@ export function createHeaderMotionScrollable< {...rest} {...refreshControlProps} {...contentContainerProps} - ref={ref} + ref={managedRefTarget === 'inner' ? animatedRef : ref} onLayout={handleLayout} onScroll={managedOnScroll} /> @@ -200,16 +234,26 @@ function useContentContainerProps({ children: rawChildren, mode, style, + scrollRef, }: { children?: ReactNode; mode: ContentContainerMode; style?: any; + scrollRef?: Ref; }) { const renderScrollComponent = useCallback( - (props: ScrollViewProps) => ( - - ), - [style] + (props: ScrollComponentProps) => { + const { ref: outerScrollRef, ...scrollProps } = props; + + return ( + + ); + }, + [scrollRef, style] ); const children = {rawChildren}; @@ -244,6 +288,25 @@ function getDisplayName(ScrollableComponent: { } type UserOnLayout = ScrollViewProps['onLayout'] | undefined; +type ScrollComponentProps = ScrollViewProps & { ref?: Ref }; +type ManagedRefTarget = 'outer' | 'inner'; + +function mergeRefs(...refs: Array | undefined>) { + return (value: T | null) => { + refs.forEach((ref) => { + if (!ref) { + return; + } + + if (typeof ref === 'function') { + ref(value); + return; + } + + (ref as { current: T | null }).current = value; + }); + }; +} // TODO: From here below Codex did some absolute TypeScript magic but it seems to work // Having limited time, I can't spend more on adjusting this to make it less convoluted diff --git a/src/index.ts b/src/index.ts index 3e0abff..b0075fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -105,6 +105,7 @@ const HeaderMotion: HeaderMotionCompound = Object.assign( export default HeaderMotion; export * from './hooks'; +export { ScrollablePresets } from './utils/presets'; export type * from './types'; export { createHeaderMotionScrollable }; export { Bridge, Header, NavigationBridge }; diff --git a/src/utils/presets.ts b/src/utils/presets.ts new file mode 100644 index 0000000..684d3fe --- /dev/null +++ b/src/utils/presets.ts @@ -0,0 +1,16 @@ +import type { CreateHeaderMotionScrollableOptions } from '../components/createHeaderMotionScrollable'; + +export const ScrollablePresets = { + FlashList: { + displayName: 'HeaderMotionFlashList', + isComponentAnimated: false, + contentContainerMode: 'renderScrollComponent', + managedRefTarget: 'inner', + } as const satisfies CreateHeaderMotionScrollableOptions, + AnimatedLegendList: { + displayName: 'HeaderMotionLegendList', + isComponentAnimated: true, + contentContainerMode: 'renderScrollComponent', + managedRefTarget: 'inner', + } as const satisfies CreateHeaderMotionScrollableOptions, +};