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,
+};
]