Skip to content

Commit 85cc385

Browse files
authored
feat: createHeaderMotionScrollable (#14)
* feat: createHeaderMotionScrollable (WIP - codex's work for now) * chore: simplify createHeaderMotionScrollable * chore: codex-powered TS magic for createHeaderMotionScrollable * chore: reorganize types * chore: fix lint warnings and extend tests * chore: do not infer FlatList and ScrollView exported types
1 parent 7185484 commit 85cc385

23 files changed

Lines changed: 1262 additions & 344 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,6 @@ android/generated
8484

8585
# React Native Nitro Modules
8686
nitrogen/
87+
88+
example/ios
89+
example/android

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ You can use either style; pick based on your integration needs:
118118
- Prefer **components** when you want a “batteries included” wiring:
119119

120120
- `HeaderMotion.ScrollView` / `HeaderMotion.FlatList` for common scrollables
121-
- `HeaderMotion.ScrollManager` for custom scrollables via render-props
121+
- `createHeaderMotionScrollable()` for reusable wrappers around custom scrollables
122+
- `HeaderMotion.ScrollManager` for one-off custom scrollables via render-props
122123

123124
- Prefer **hooks** when you want to build your own wrappers:
124125
- `useScrollManager()` (same engine as `HeaderMotion.ScrollManager`, but hook-based)
@@ -393,6 +394,7 @@ The package exports a default compound component plus hooks, types, and a couple
393394
- `HeaderMotion.Header` (bridge for navigation headers)
394395
- `HeaderMotion.ScrollView` (pre-wired Animated.ScrollView)
395396
- `HeaderMotion.FlatList` (pre-wired Animated.FlatList)
397+
- `createHeaderMotionScrollable` (factory for reusable custom scrollables)
396398
- `HeaderMotion.ScrollManager` (render-prop API for custom scrollables)
397399

398400
#### Props
@@ -449,6 +451,41 @@ Supports:
449451
- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'`
450452
- `ensureScrollableContentMinHeight?: boolean`
451453

454+
#### `createHeaderMotionScrollable(Component, options?)`
455+
456+
Named export for building reusable scrollable wrappers on top of `useScrollManager()`.
457+
This is the same abstraction used internally by `HeaderMotion.ScrollView` and `HeaderMotion.FlatList`.
458+
459+
Returned components support:
460+
461+
- `scrollId?: string`
462+
- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'`
463+
- `ensureScrollableContentMinHeight?: boolean`
464+
465+
Use:
466+
467+
- `contentContainerMode: 'children'` for ScrollView-like components
468+
- `contentContainerMode: 'renderScrollComponent'` for FlatList-like components
469+
- `isComponentAnimated: true` when you pass an already animated component
470+
471+
The returned component keeps the wrapped component's prop shape, and list-like
472+
generic components preserve item inference at usage time. Users do not need to
473+
pass generics to `createHeaderMotionScrollable()` itself.
474+
475+
By default, the factory wraps the provided component with
476+
`Animated.createAnimatedComponent()`.
477+
478+
Example:
479+
480+
```tsx
481+
import { FlashList } from '@shopify/flash-list';
482+
import { createHeaderMotionScrollable } from 'react-native-header-motion';
483+
484+
const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, {
485+
displayName: 'HeaderMotionFlashList',
486+
});
487+
```
488+
452489
#### `HeaderMotion.ScrollManager`
453490

454491
Render-prop API for custom scrollables (pager pages, 3rd party lists, etc.).

example/app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@
2727
"favicon": "./assets/favicon.png",
2828
"bundler": "metro"
2929
},
30-
"plugins": ["expo-router"]
30+
"plugins": ["expo-dev-client", "expo-router"]
3131
}
3232
}

example/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
"main": "expo-router/entry",
55
"scripts": {
66
"start": "expo start",
7-
"android": "expo start --android",
8-
"ios": "expo start --ios",
7+
"android": "expo run:android",
8+
"ios": "expo run:ios",
99
"web": "expo start --web"
1010
},
1111
"dependencies": {
1212
"@expo/metro-runtime": "~55.0.6",
13+
"@legendapp/list": "2.0.19",
14+
"@shopify/flash-list": "2.3.0",
1315
"expo": "~55.0.7",
1416
"expo-constants": "~55.0.8",
17+
"expo-dev-client": "~55.0.8",
1518
"expo-linking": "~55.0.7",
1619
"expo-router": "~55.0.6",
1720
"expo-status-bar": "~55.0.4",

example/src/app/flashlist.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ContentCard, ShowcaseCollapsibleHeader } from '@/components';
2+
import { FlashList } from '@shopify/flash-list';
3+
import HeaderMotion, {
4+
createHeaderMotionScrollable,
5+
} from 'react-native-header-motion';
6+
import { Stack } from 'expo-router';
7+
8+
type ListRow = {
9+
index: number;
10+
label: string;
11+
};
12+
13+
const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, {
14+
displayName: 'HeaderMotionFlashList',
15+
});
16+
17+
export default function Screen() {
18+
return (
19+
<HeaderMotion>
20+
<HeaderMotion.Header>
21+
{(headerProps) => (
22+
<Stack.Screen
23+
options={{
24+
header: () => (
25+
<ShowcaseCollapsibleHeader
26+
{...headerProps}
27+
title="FlashList"
28+
subtitle="Shared factory wrapper"
29+
backgroundColor="#14532d"
30+
/>
31+
),
32+
}}
33+
/>
34+
)}
35+
</HeaderMotion.Header>
36+
<HeaderMotionFlashList
37+
data={content}
38+
keyExtractor={(item) => `${item.index}`}
39+
renderItem={({ item }) => (
40+
<ContentCard
41+
index={item.index}
42+
label={item.label}
43+
backgroundColor="#dcfce7"
44+
textColor="#14532d"
45+
/>
46+
)}
47+
/>
48+
</HeaderMotion>
49+
);
50+
}
51+
52+
const content: ListRow[] = Array.from({ length: 500 }, (_, k) => ({
53+
index: k + 1,
54+
label: 'FlashList Item',
55+
}));

example/src/app/flatlist-handlers.tsx

Lines changed: 41 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import HeaderMotion, {
66
import { Stack } from 'expo-router';
77
import type { ComponentProps } from 'react';
88
import { useCallback, useRef } from 'react';
9-
import { FlatList, StyleSheet, View } from 'react-native';
9+
import { StyleSheet, View } from 'react-native';
1010
import Animated, {
1111
Extrapolation,
1212
interpolate,
@@ -16,7 +16,11 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
1616

1717
type ListRow = { key: string; index: number };
1818

19-
type FL = ComponentProps<typeof FlatList<ListRow>>;
19+
type FL = ComponentProps<typeof HeaderMotion.FlatList<ListRow>>;
20+
type FLFn<TKey extends keyof FL> = Extract<
21+
Exclude<FL[TKey], undefined>,
22+
(...args: any[]) => any
23+
>;
2024

2125
const LIST_DATA: ListRow[] = Array.from({ length: 120 }, (_, k) => ({
2226
key: `row-${k}`,
@@ -37,7 +41,7 @@ export default function Screen() {
3741
/>
3842
)}
3943
</HeaderMotion.Header>
40-
<HeaderMotion.FlatList<ListRow>
44+
<HeaderMotion.FlatList
4145
data={LIST_DATA}
4246
keyExtractor={(item) => item.key}
4347
renderItem={({ item }) => (
@@ -61,71 +65,62 @@ function useFlatListHandlerLoggers() {
6165
itemVisiblePercentThreshold: 35,
6266
}).current;
6367

64-
const onViewableItemsChanged = useCallback<
65-
NonNullable<FL['onViewableItemsChanged']>
66-
>((info) => {
67-
console.log(prefix, 'onViewableItemsChanged', {
68-
viewableCount: info.viewableItems.length,
69-
changedCount: info.changed.length,
70-
firstVisibleIndex: info.viewableItems[0]?.index,
71-
});
72-
}, []);
68+
const onViewableItemsChanged = useCallback<FLFn<'onViewableItemsChanged'>>(
69+
(info) => {
70+
console.log(prefix, 'onViewableItemsChanged', {
71+
viewableCount: info.viewableItems.length,
72+
changedCount: info.changed.length,
73+
firstVisibleIndex: info.viewableItems[0]?.index,
74+
});
75+
},
76+
[]
77+
);
7378

7479
return {
75-
onScrollBeginDrag: useCallback<NonNullable<FL['onScrollBeginDrag']>>(
76-
(e) => {
77-
console.log(prefix, 'onScrollBeginDrag', {
78-
y: e.nativeEvent.contentOffset.y,
79-
});
80-
},
81-
[]
82-
),
83-
onScrollEndDrag: useCallback<NonNullable<FL['onScrollEndDrag']>>((e) => {
80+
onScrollBeginDrag: useCallback<FLFn<'onScrollBeginDrag'>>((e) => {
81+
console.log(prefix, 'onScrollBeginDrag', {
82+
y: e.nativeEvent.contentOffset.y,
83+
});
84+
}, []),
85+
onScrollEndDrag: useCallback<FLFn<'onScrollEndDrag'>>((e) => {
8486
console.log(prefix, 'onScrollEndDrag', {
8587
y: e.nativeEvent.contentOffset.y,
8688
});
8789
}, []),
88-
onMomentumScrollBegin: useCallback<
89-
NonNullable<FL['onMomentumScrollBegin']>
90-
>((e) => {
90+
onMomentumScrollBegin: useCallback<FLFn<'onMomentumScrollBegin'>>((e) => {
9191
console.log(prefix, 'onMomentumScrollBegin', {
9292
y: e.nativeEvent.contentOffset.y,
9393
});
9494
}, []),
95-
onMomentumScrollEnd: useCallback<NonNullable<FL['onMomentumScrollEnd']>>(
96-
(e) => {
97-
console.log(prefix, 'onMomentumScrollEnd', {
98-
y: e.nativeEvent.contentOffset.y,
99-
});
100-
},
101-
[]
102-
),
103-
onContentSizeChange: useCallback<NonNullable<FL['onContentSizeChange']>>(
104-
(w, h) => {
105-
console.log(prefix, 'onContentSizeChange', { width: w, height: h });
106-
},
107-
[]
108-
),
109-
onScroll: useCallback<NonNullable<FL['onScroll']>>((e) => {
95+
onMomentumScrollEnd: useCallback<FLFn<'onMomentumScrollEnd'>>((e) => {
96+
console.log(prefix, 'onMomentumScrollEnd', {
97+
y: e.nativeEvent.contentOffset.y,
98+
});
99+
}, []),
100+
onContentSizeChange: useCallback<FLFn<'onContentSizeChange'>>((w, h) => {
101+
console.log(prefix, 'onContentSizeChange', { width: w, height: h });
102+
}, []),
103+
onScroll: useCallback<FLFn<'onScroll'>>((e) => {
110104
console.log(prefix, 'onScroll', {
111105
y: e.nativeEvent.contentOffset.y,
112106
});
113107
}, []),
114108
viewabilityConfig,
115109
onViewableItemsChanged,
116-
onEndReached: useCallback<NonNullable<FL['onEndReached']>>((info) => {
110+
onEndReached: useCallback<FLFn<'onEndReached'>>((info) => {
117111
console.log(prefix, 'onEndReached', info);
118112
}, []),
119113
onEndReachedThreshold: 0.25,
120-
onStartReached: useCallback<NonNullable<FL['onStartReached']>>((info) => {
114+
onStartReached: useCallback<FLFn<'onStartReached'>>((info) => {
121115
console.log(prefix, 'onStartReached', info);
122116
}, []),
123117
onStartReachedThreshold: 0.25,
124-
onScrollToIndexFailed: useCallback<
125-
NonNullable<FL['onScrollToIndexFailed']>
126-
>((info) => {
127-
console.log(prefix, 'onScrollToIndexFailed', info);
128-
}, []),
118+
onScrollToIndexFailed: useCallback<FLFn<'onScrollToIndexFailed'>>(
119+
(info) => {
120+
console.log(prefix, 'onScrollToIndexFailed', info);
121+
},
122+
[]
123+
),
129124
};
130125
}
131126

example/src/app/flatlist.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import Animated, {
1212
} from 'react-native-reanimated';
1313
import { useSafeAreaInsets } from 'react-native-safe-area-context';
1414

15+
type ListRow = {
16+
index: number;
17+
label: string;
18+
};
19+
1520
export default function Screen() {
1621
return (
1722
<HeaderMotion>
@@ -136,7 +141,7 @@ const styles = StyleSheet.create({
136141
},
137142
});
138143

139-
const content = Array.from({ length: 500 }, (_, k) => ({
144+
const content: ListRow[] = Array.from({ length: 500 }, (_, k) => ({
140145
index: k + 1,
141146
label: 'FlatList Item',
142147
}));

example/src/app/index.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const SECTIONS: ShowcaseSection[] = [
2525
{ title: 'Color Interpolation', href: '/colors', icon: '🎨' },
2626
{ title: 'Overscroll', href: '/overscroll', icon: '🔄' },
2727
{ title: 'FlatList', href: '/flatlist', icon: '📋' },
28+
{ title: 'FlashList', href: '/flashlist', icon: '⚡️' },
29+
{ title: 'LegendList', href: '/legendlist', icon: '🧾' },
2830
],
2931
},
3032
{
@@ -136,12 +138,7 @@ export default function Screen() {
136138
<Link href={item.href} asChild>
137139
<Pressable>
138140
{({ pressed }) => (
139-
<View
140-
style={[
141-
styles.itemRow,
142-
pressed && { backgroundColor: '#E5E7EB' },
143-
]}
144-
>
141+
<View style={[styles.itemRow, pressed && styles.itemRowPressed]}>
145142
<Text style={styles.itemIcon}>{item.icon}</Text>
146143
<Text style={styles.itemTitle}>{item.title}</Text>
147144
<Text style={styles.chevron}></Text>
@@ -187,6 +184,9 @@ const styles = StyleSheet.create({
187184
paddingHorizontal: 20,
188185
backgroundColor: '#FFF',
189186
},
187+
itemRowPressed: {
188+
backgroundColor: '#E5E7EB',
189+
},
190190
itemIcon: {
191191
fontSize: 20,
192192
marginRight: 14,

0 commit comments

Comments
 (0)