react-native-header-motion 0.2.0 → 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +126 -18
  2. package/lib/module/components/FlatList.js +12 -2
  3. package/lib/module/components/FlatList.js.map +1 -1
  4. package/lib/module/components/HeaderBase.js +53 -5
  5. package/lib/module/components/HeaderBase.js.map +1 -1
  6. package/lib/module/components/HeaderMotion.js +66 -24
  7. package/lib/module/components/HeaderMotion.js.map +1 -1
  8. package/lib/module/components/ScrollManager.js +10 -2
  9. package/lib/module/components/ScrollManager.js.map +1 -1
  10. package/lib/module/components/ScrollView.js +8 -0
  11. package/lib/module/components/ScrollView.js.map +1 -1
  12. package/lib/module/context.js.map +1 -1
  13. package/lib/module/hooks/useMotionProgress.js +6 -2
  14. package/lib/module/hooks/useMotionProgress.js.map +1 -1
  15. package/lib/module/hooks/useScrollManager.js +91 -29
  16. package/lib/module/hooks/useScrollManager.js.map +1 -1
  17. package/lib/module/utils/index.js +1 -0
  18. package/lib/module/utils/index.js.map +1 -1
  19. package/lib/module/utils/refreshControl.js +93 -0
  20. package/lib/module/utils/refreshControl.js.map +1 -0
  21. package/lib/module/utils/values.js +36 -0
  22. package/lib/module/utils/values.js.map +1 -1
  23. package/lib/typescript/src/components/FlatList.d.ts +2 -4
  24. package/lib/typescript/src/components/FlatList.d.ts.map +1 -1
  25. package/lib/typescript/src/components/HeaderBase.d.ts +9 -2
  26. package/lib/typescript/src/components/HeaderBase.d.ts.map +1 -1
  27. package/lib/typescript/src/components/HeaderMotion.d.ts +5 -1
  28. package/lib/typescript/src/components/HeaderMotion.d.ts.map +1 -1
  29. package/lib/typescript/src/components/ScrollManager.d.ts +6 -10
  30. package/lib/typescript/src/components/ScrollManager.d.ts.map +1 -1
  31. package/lib/typescript/src/components/ScrollView.d.ts +3 -3
  32. package/lib/typescript/src/components/ScrollView.d.ts.map +1 -1
  33. package/lib/typescript/src/context.d.ts +7 -3
  34. package/lib/typescript/src/context.d.ts.map +1 -1
  35. package/lib/typescript/src/hooks/useMotionProgress.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/useScrollManager.d.ts +10 -3
  37. package/lib/typescript/src/hooks/useScrollManager.d.ts.map +1 -1
  38. package/lib/typescript/src/types.d.ts +20 -4
  39. package/lib/typescript/src/types.d.ts.map +1 -1
  40. package/lib/typescript/src/utils/index.d.ts +1 -0
  41. package/lib/typescript/src/utils/index.d.ts.map +1 -1
  42. package/lib/typescript/src/utils/refreshControl.d.ts +150 -0
  43. package/lib/typescript/src/utils/refreshControl.d.ts.map +1 -0
  44. package/lib/typescript/src/utils/values.d.ts +4 -1
  45. package/lib/typescript/src/utils/values.d.ts.map +1 -1
  46. package/package.json +7 -5
  47. package/src/components/FlatList.tsx +23 -9
  48. package/src/components/HeaderBase.tsx +93 -4
  49. package/src/components/HeaderMotion.tsx +102 -26
  50. package/src/components/ScrollManager.tsx +27 -17
  51. package/src/components/ScrollView.tsx +17 -3
  52. package/src/context.ts +9 -2
  53. package/src/hooks/useMotionProgress.ts +10 -2
  54. package/src/hooks/useScrollManager.ts +127 -35
  55. package/src/types.ts +22 -4
  56. package/src/utils/index.ts +1 -0
  57. package/src/utils/refreshControl.tsx +118 -0
  58. package/src/utils/values.ts +57 -1
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useState } from 'react';
1
+ import { useCallback, useRef, useEffect, useMemo } from 'react';
2
2
  import {
3
3
  Extrapolation,
4
4
  interpolate,
@@ -11,6 +11,7 @@ import {
11
11
  import { HeaderMotionContext } from '../context';
12
12
  import type { ReactNode } from 'react';
13
13
  import type {
14
+ ScrollTo,
14
15
  MeasureAnimatedHeader,
15
16
  MeasureAnimatedHeaderAndSet,
16
17
  ProgressThreshold,
@@ -23,6 +24,32 @@ import {
23
24
  getInitialScrollValue,
24
25
  } from '../utils';
25
26
 
27
+ const resolveScrollIdForProgress = (
28
+ scrollValues: ScrollValues,
29
+ activeScrollIdValue: string | undefined
30
+ ) => {
31
+ 'worklet';
32
+
33
+ if (activeScrollIdValue) {
34
+ return activeScrollIdValue;
35
+ }
36
+
37
+ let onlyNonDefaultId: string | null = null;
38
+ for (const key in scrollValues) {
39
+ if (key === DEFAULT_SCROLL_ID) {
40
+ continue;
41
+ }
42
+
43
+ if (onlyNonDefaultId !== null) {
44
+ return DEFAULT_SCROLL_ID;
45
+ }
46
+
47
+ onlyNonDefaultId = key;
48
+ }
49
+
50
+ return onlyNonDefaultId ?? DEFAULT_SCROLL_ID;
51
+ };
52
+
26
53
  export interface HeaderMotionProps<T extends string> {
27
54
  /**
28
55
  * The threshold at which the header animation completes (reaches progress = 1).
@@ -61,6 +88,10 @@ export interface HeaderMotionProps<T extends string> {
61
88
  * @default Extrapolation.CLAMP
62
89
  */
63
90
  progressExtrapolation?: ExtrapolationType;
91
+ /** Enables panning directly on the header surface.
92
+ * @default false
93
+ */
94
+ enableHeaderPan?: boolean;
64
95
  /** Child components that will have access to the header motion context */
65
96
  children: ReactNode;
66
97
  }
@@ -76,45 +107,67 @@ function HeaderMotionContextProvider<T extends string>({
76
107
  measureDynamicMode = 'mount',
77
108
  activeScrollId,
78
109
  progressExtrapolation = Extrapolation.CLAMP,
110
+ enableHeaderPan = false,
79
111
  children,
80
112
  }: HeaderMotionProps<T>) {
81
- const [dynamicMeasurement, setDynamicMeasurement] = useState<
82
- number | undefined
83
- >(undefined);
84
- const [originalHeaderHeight, setOriginalHeaderHeight] = useState(0);
113
+ const dynamicMeasurement = useSharedValue<number | undefined>(undefined);
114
+ const originalHeaderHeight = useSharedValue(0);
115
+ const progressThresholdValue = useSharedValue(
116
+ typeof progressThreshold === 'number' ? progressThreshold : Infinity
117
+ );
118
+ const headerPanMomentumOffset = useSharedValue<number | null>(null);
85
119
 
86
120
  const setOrUpdateDynamicMeasurement =
87
121
  useCallback<MeasureAnimatedHeaderAndSet>(
88
122
  (e) => {
123
+ const prevMeasurement = dynamicMeasurement.get();
124
+ if (prevMeasurement !== undefined && measureDynamicMode === 'mount') {
125
+ return;
126
+ }
127
+
89
128
  const measured = measureDynamic(e);
90
- setDynamicMeasurement((prevMeasurement) => {
91
- if (prevMeasurement !== undefined && measureDynamicMode === 'mount') {
92
- return prevMeasurement;
93
- }
129
+ if (prevMeasurement === measured) {
130
+ return;
131
+ }
94
132
 
95
- return measured;
96
- });
133
+ dynamicMeasurement.set(measured);
134
+ progressThresholdValue.set(
135
+ typeof progressThreshold === 'number'
136
+ ? progressThreshold
137
+ : progressThreshold(measured)
138
+ );
97
139
  },
98
- [measureDynamicMode, measureDynamic, setDynamicMeasurement]
140
+ [
141
+ measureDynamicMode,
142
+ measureDynamic,
143
+ dynamicMeasurement,
144
+ progressThreshold,
145
+ progressThresholdValue,
146
+ ]
99
147
  );
100
148
 
101
- const calculatedProgressThreshold = useMemo(() => {
149
+ useEffect(() => {
102
150
  if (typeof progressThreshold === 'number') {
103
- return progressThreshold;
151
+ progressThresholdValue.set(progressThreshold);
152
+ return;
104
153
  }
105
154
 
106
- if (dynamicMeasurement === undefined) {
107
- return Infinity;
108
- }
109
- return progressThreshold(dynamicMeasurement);
110
- }, [dynamicMeasurement, progressThreshold]);
155
+ const measured = dynamicMeasurement.get();
156
+ progressThresholdValue.set(
157
+ measured === undefined ? Infinity : progressThreshold(measured)
158
+ );
159
+ }, [progressThreshold, dynamicMeasurement, progressThresholdValue]);
111
160
 
112
161
  const measureTotalHeight = useCallback<MeasureAnimatedHeaderAndSet>(
113
162
  (e) => {
114
163
  const measuredValue = e.nativeEvent.layout.height;
115
- setOriginalHeaderHeight(measuredValue);
164
+ if (originalHeaderHeight.get() === measuredValue) {
165
+ return;
166
+ }
167
+
168
+ originalHeaderHeight.set(measuredValue);
116
169
  },
117
- [setOriginalHeaderHeight]
170
+ [originalHeaderHeight]
118
171
  );
119
172
 
120
173
  const scrollValues = useSharedValue<ScrollValues>({
@@ -136,8 +189,10 @@ function HeaderMotionContextProvider<T extends string>({
136
189
  );
137
190
 
138
191
  const progress = useDerivedValue(() => {
139
- const id = activeScrollId?.get() ?? DEFAULT_SCROLL_ID;
140
- const scrollValue = scrollValues.get()[id];
192
+ const values = scrollValues.get();
193
+ const id = resolveScrollIdForProgress(values, activeScrollId?.get());
194
+ const scrollValue = values[id];
195
+ const threshold = progressThresholdValue.get();
141
196
 
142
197
  if (!scrollValue) {
143
198
  return 0;
@@ -146,30 +201,51 @@ function HeaderMotionContextProvider<T extends string>({
146
201
  const { min, current } = scrollValue;
147
202
  return interpolate(
148
203
  current,
149
- [min, min + calculatedProgressThreshold],
204
+ [min, min + threshold],
150
205
  [0, 1],
151
206
  progressExtrapolation
152
207
  );
153
208
  });
154
209
 
210
+ const scrollToRef = useRef<ScrollTo>(null);
211
+ // FUTURE: SharedValue-based scrollTo was removed for now because function updates
212
+ // were not propagating reliably, while it works for refs. Revisit later.
213
+ // We need to be updating the scrollTo on active scroll ID changes and doing it via state would cause re-renders.
214
+ // It's a bit of an anti-pattern to use refs for this as well, but I am yet to figure out a better way to pass those if SV won't work.
215
+ const animatedHeaderBaseProps = useMemo(
216
+ () => ({
217
+ enableHeaderPan,
218
+ scrollToRef,
219
+ headerPanMomentumOffset,
220
+ }),
221
+ [enableHeaderPan, headerPanMomentumOffset]
222
+ );
223
+
155
224
  const ctxValue = useMemo(
156
225
  () => ({
157
226
  progress,
158
227
  originalHeaderHeight,
159
228
  measureDynamic: setOrUpdateDynamicMeasurement,
160
229
  measureTotalHeight,
161
- progressThreshold: calculatedProgressThreshold,
230
+ enableHeaderPan,
231
+ headerPanMomentumOffset,
232
+ animatedHeaderBaseProps,
233
+ progressThreshold: progressThresholdValue,
162
234
  scrollValues,
235
+ scrollToRef,
163
236
  activeScrollId: activeScrollId as SharedValue<string> | undefined,
164
237
  }),
165
238
  [
166
239
  originalHeaderHeight,
167
240
  progress,
168
241
  measureTotalHeight,
242
+ enableHeaderPan,
243
+ headerPanMomentumOffset,
244
+ animatedHeaderBaseProps,
169
245
  setOrUpdateDynamicMeasurement,
170
246
  scrollValues,
171
247
  activeScrollId,
172
- calculatedProgressThreshold,
248
+ progressThresholdValue,
173
249
  ]
174
250
  );
175
251
 
@@ -1,29 +1,26 @@
1
- import { useScrollManager } from '../hooks';
1
+ import { useScrollManager, type UseScrollManagerOptions } from '../hooks';
2
2
  import type { ScrollManagerConfig } from '../types';
3
3
  import type { ReactNode } from 'react';
4
- import type { AnimatedRef } from 'react-native-reanimated';
4
+ import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes';
5
5
 
6
- type ScrollManagerRenderChildren = (
7
- scrollableProps: ScrollManagerConfig['scrollableProps'],
8
- options: ScrollManagerConfig['headerMotionContext']
6
+ type ScrollManagerRenderChildren<TRef extends InstanceOrElement = any> = (
7
+ scrollableProps: ScrollManagerConfig<TRef>['scrollableProps'],
8
+ options: ScrollManagerConfig<TRef>['headerMotionContext']
9
9
  ) => ReactNode;
10
10
 
11
- export interface HeaderMotionScrollManagerProps {
11
+ export interface HeaderMotionScrollManagerProps<
12
+ TRef extends InstanceOrElement = any
13
+ > extends UseScrollManagerOptions<TRef> {
12
14
  /**
13
15
  * Optional unique identifier for this scroll view.
14
16
  * Use this when you have multiple scroll views (e.g., in tabs) to track them separately.
15
17
  */
16
18
  scrollId?: string;
17
- /**
18
- * Optional animated ref to use for the scroll view.
19
- * When provided, the scroll manager will use this ref instead of creating its own.
20
- */
21
- animatedRef?: AnimatedRef<any>;
22
19
  /**
23
20
  * Render function that receives scroll props and header context.
24
21
  * Use this to create custom scroll implementations that integrate with HeaderMotion.
25
22
  */
26
- children: ScrollManagerRenderChildren;
23
+ children: ScrollManagerRenderChildren<TRef>;
27
24
  }
28
25
 
29
26
  /**
@@ -48,20 +45,33 @@ export interface HeaderMotionScrollManagerProps {
48
45
  * </HeaderMotion>
49
46
  * ```
50
47
  */
51
- export function HeaderMotionScrollManager({
48
+ export function HeaderMotionScrollManager<
49
+ TRef extends InstanceOrElement = any
50
+ >({
52
51
  children,
53
52
  scrollId,
54
53
  animatedRef,
55
- }: HeaderMotionScrollManagerProps) {
54
+ refreshControl,
55
+ refreshing,
56
+ onRefresh,
57
+ progressViewOffset,
58
+ }: HeaderMotionScrollManagerProps<TRef>) {
56
59
  if (typeof children !== 'function') {
57
60
  throw new Error(
58
61
  'HeaderMotion.ScrollManager only accepts render function as the only child.'
59
62
  );
60
63
  }
61
64
 
62
- const { scrollableProps, headerMotionContext } = useScrollManager(scrollId, {
63
- animatedRef,
64
- });
65
+ const { scrollableProps, headerMotionContext } = useScrollManager<TRef>(
66
+ scrollId,
67
+ {
68
+ animatedRef,
69
+ refreshControl,
70
+ refreshing,
71
+ onRefresh,
72
+ progressViewOffset,
73
+ }
74
+ );
65
75
 
66
76
  return children(scrollableProps, headerMotionContext);
67
77
  }
@@ -14,7 +14,7 @@ export type HeaderMotionScrollViewProps = AnimatedScrollViewProps & {
14
14
  * Optional animated ref to use for the scroll view.
15
15
  * When provided, the scroll manager will use this ref instead of creating its own.
16
16
  */
17
- animatedRef?: AnimatedRef<any>;
17
+ animatedRef?: AnimatedRef<Animated.ScrollView> | AnimatedRef;
18
18
  };
19
19
 
20
20
  /**
@@ -36,18 +36,32 @@ export function HeaderMotionScrollView({
36
36
  animatedRef,
37
37
  children,
38
38
  contentContainerStyle,
39
+ refreshControl,
39
40
  ...props
40
41
  }: HeaderMotionScrollViewProps) {
41
42
  return (
42
- <HeaderMotionScrollManager scrollId={scrollId} animatedRef={animatedRef}>
43
+ <HeaderMotionScrollManager
44
+ scrollId={scrollId}
45
+ animatedRef={animatedRef as AnimatedRef<Animated.ScrollView>}
46
+ refreshControl={refreshControl}
47
+ >
43
48
  {(
44
- { onScroll, ...scrollViewProps },
49
+ {
50
+ onScroll,
51
+ ref,
52
+ refreshControl: managedRefreshControl,
53
+ ...scrollViewProps
54
+ },
45
55
  { originalHeaderHeight, minHeightContentContainerStyle }
46
56
  ) => (
47
57
  <Animated.ScrollView
48
58
  {...scrollViewProps}
49
59
  {...props}
60
+ ref={ref}
50
61
  onScroll={onScroll}
62
+ {...(managedRefreshControl && {
63
+ refreshControl: managedRefreshControl,
64
+ })}
51
65
  >
52
66
  <Animated.View
53
67
  style={[
package/src/context.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createContext } from 'react';
2
2
  import { type SharedValue } from 'react-native-reanimated';
3
3
  import type {
4
+ AnimatedHeaderBaseMotionProps,
4
5
  MeasureAnimatedHeaderAndSet,
5
6
  Progress,
7
+ ScrollTo,
6
8
  ScrollValues,
7
9
  } from './types';
8
10
 
@@ -10,10 +12,15 @@ interface HeaderMotionContextType {
10
12
  progress: Progress;
11
13
  measureTotalHeight: MeasureAnimatedHeaderAndSet;
12
14
  measureDynamic: MeasureAnimatedHeaderAndSet;
15
+ enableHeaderPan: boolean;
16
+ headerPanMomentumOffset: SharedValue<number | null>;
17
+ animatedHeaderBaseProps: AnimatedHeaderBaseMotionProps;
13
18
  scrollValues: SharedValue<ScrollValues>;
14
19
  activeScrollId: SharedValue<string> | undefined;
15
- progressThreshold: number;
16
- originalHeaderHeight: number;
20
+ progressThreshold: SharedValue<number>;
21
+ originalHeaderHeight: SharedValue<number>;
22
+
23
+ scrollToRef: React.RefObject<ScrollTo | null>;
17
24
  }
18
25
 
19
26
  export const HeaderMotionContext =
@@ -44,13 +44,21 @@ export function useMotionProgress(): MotionProgress {
44
44
  'useMotionProgress must be used within a <HeaderMotion /> component. If using inside a navigation header, consider using <HeaderMotion.Header /> instead to ensure context access.'
45
45
  );
46
46
  }
47
- const { progress, measureTotalHeight, measureDynamic, progressThreshold } =
48
- ctxValue;
47
+ const {
48
+ progress,
49
+ measureTotalHeight,
50
+ measureDynamic,
51
+ progressThreshold,
52
+ animatedHeaderBaseProps,
53
+ activeScrollId,
54
+ } = ctxValue;
49
55
 
50
56
  return {
51
57
  progress,
52
58
  measureTotalHeight,
53
59
  measureDynamic,
54
60
  progressThreshold,
61
+ animatedHeaderBaseProps,
62
+ activeScrollId,
55
63
  };
56
64
  }
@@ -1,5 +1,6 @@
1
1
  import { useContext, useCallback, useEffect } from 'react';
2
2
  import {
3
+ cancelAnimation,
3
4
  measure,
4
5
  scrollTo,
5
6
  useAnimatedReaction,
@@ -11,8 +12,21 @@ import {
11
12
  } from 'react-native-reanimated';
12
13
  import { RuntimeKind, scheduleOnUI } from 'react-native-worklets';
13
14
  import { HeaderMotionContext } from '../context';
14
- import type { ScrollManagerConfig, ScrollValues } from '../types';
15
- import { DEFAULT_SCROLL_ID, getInitialScrollValue } from '../utils';
15
+ import type { ScrollManagerConfig } from '../types';
16
+ import {
17
+ resolveRefreshControl,
18
+ DEFAULT_SCROLL_ID,
19
+ ensureScrollValueRegistered,
20
+ warnIfMissingActiveScrollId,
21
+ type ResolveRefreshControlOptions,
22
+ } from '../utils';
23
+ import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes';
24
+
25
+ type ScrollHandlerContext = {
26
+ lastOffset: number | undefined;
27
+ };
28
+
29
+ const SCROLL_TOLERANCE = 0.5;
16
30
 
17
31
  /**
18
32
  * Hook that manages scroll tracking and synchronization for header animations.
@@ -51,18 +65,24 @@ import { DEFAULT_SCROLL_ID, getInitialScrollValue } from '../utils';
51
65
  * }
52
66
  * ```
53
67
  */
54
- export interface UseScrollManagerOptions {
68
+ export interface UseScrollManagerOptions<TRef extends InstanceOrElement = any>
69
+ extends Omit<ResolveRefreshControlOptions, 'progressViewOffset'> {
55
70
  /**
56
71
  * Optional animated ref to use instead of creating one internally.
57
72
  * Useful when you need access to the scroll view ref from outside.
58
73
  */
59
- animatedRef?: AnimatedRef<any>;
74
+ animatedRef?: AnimatedRef<TRef>;
75
+ /**
76
+ * Optional refresh progress offset override.
77
+ * When provided, it takes precedence over the automatic offset based on header height.
78
+ */
79
+ progressViewOffset?: ResolveRefreshControlOptions['progressViewOffset'];
60
80
  }
61
81
 
62
- export function useScrollManager(
82
+ export function useScrollManager<TRef extends InstanceOrElement = any>(
63
83
  scrollId?: string,
64
- options?: UseScrollManagerOptions
65
- ): ScrollManagerConfig {
84
+ options?: UseScrollManagerOptions<TRef>
85
+ ): ScrollManagerConfig<TRef> {
66
86
  const ctxValue = useContext(HeaderMotionContext);
67
87
  if (!ctxValue) {
68
88
  throw new Error(
@@ -76,11 +96,36 @@ export function useScrollManager(
76
96
  activeScrollId,
77
97
  progressThreshold,
78
98
  originalHeaderHeight,
99
+ scrollToRef,
100
+ headerPanMomentumOffset,
79
101
  } = ctxValue;
80
102
  const id = scrollId ?? DEFAULT_SCROLL_ID;
81
103
 
82
- const localRef = useAnimatedRef<any>(); // TODO: better typing
104
+ const localRef = useAnimatedRef<TRef>();
83
105
  const animatedRef = options?.animatedRef ?? localRef;
106
+ const refreshControl = options?.refreshControl;
107
+ const refreshing = options?.refreshing;
108
+ const onRefresh = options?.onRefresh;
109
+ const progressViewOffset =
110
+ options?.progressViewOffset ?? originalHeaderHeight;
111
+
112
+ useAnimatedReaction(
113
+ () => activeScrollId?.get(),
114
+ (activeId) => {
115
+ const currentValues = ensureScrollValueRegistered(scrollValues, id);
116
+ warnIfMissingActiveScrollId(currentValues, id, activeId);
117
+
118
+ if (!activeId || activeId === id) {
119
+ // TODO: Could we just be passing current scrollRef instead of the entire function?
120
+ scrollToRef.current = (y, scrollOptions = {}) => {
121
+ 'worklet';
122
+ const { isValueDelta = true, animated = false } = scrollOptions;
123
+ const newY = isValueDelta ? scrollValues.get()[id]!.current - y : y;
124
+ scrollTo(animatedRef, 0, newY, animated);
125
+ };
126
+ }
127
+ }
128
+ );
84
129
 
85
130
  useEffect(() => {
86
131
  return () => {
@@ -108,25 +153,20 @@ export function useScrollManager(
108
153
  return;
109
154
  }
110
155
 
111
- if (!scrollValues.get()[id]) {
112
- scrollValues.modify((value) => {
113
- (value as ScrollValues)[id] = getInitialScrollValue();
114
- return value;
115
- });
116
- }
156
+ ensureScrollValueRegistered(scrollValues, id);
117
157
 
118
158
  let newCur = -1;
159
+ const threshold = progressThreshold.get();
119
160
 
120
161
  scrollValues.modify((value) => {
121
- let scrollValue = value[id];
162
+ const scrollValue = value[id];
122
163
  if (!scrollValue) {
123
- (value as ScrollValues)[id] = getInitialScrollValue();
124
- scrollValue = value[id]!;
164
+ return value;
125
165
  }
126
166
 
127
167
  const progressDiff = oldProgress - newProgress;
128
- newCur = scrollValue.current - progressDiff * progressThreshold;
129
- const newMin = newCur - newProgress * progressThreshold;
168
+ newCur = scrollValue.current - progressDiff * threshold;
169
+ const newMin = newCur - newProgress * threshold;
130
170
  scrollValue.current = newCur;
131
171
  scrollValue.min = newMin;
132
172
 
@@ -139,29 +179,58 @@ export function useScrollManager(
139
179
  }
140
180
  );
141
181
 
142
- const scrollHandler = useCallback<ScrollHandler>(
143
- (e) => {
182
+ const onScroll = useCallback<ScrollHandler<ScrollHandlerContext>>(
183
+ (e, ctx) => {
144
184
  'worklet';
185
+ const newCurrent = e.contentOffset.y;
186
+
187
+ if (
188
+ ctx.lastOffset !== undefined &&
189
+ Math.abs(ctx.lastOffset - newCurrent) < SCROLL_TOLERANCE
190
+ ) {
191
+ return;
192
+ }
193
+ ctx.lastOffset = newCurrent;
194
+
195
+ const threshold = progressThreshold.get();
196
+ const values = scrollValues.get();
197
+ const scrollValue = values[id];
198
+
199
+ if (!scrollValue) {
200
+ return;
201
+ }
202
+
203
+ const activeScrollIdValue = activeScrollId?.get();
204
+ if (activeScrollIdValue && activeScrollIdValue !== id) {
205
+ return;
206
+ }
207
+
208
+ const oldCurrent = scrollValue.current;
209
+ const oldMin = scrollValue.min;
210
+ const isCollapsed = oldCurrent >= oldMin + threshold - 0.001;
211
+
212
+ // When the header is fully collapsed and the user is scrolled past the
213
+ // threshold, progress is mathematically guaranteed to stay at 1:
214
+ // min = newCurrent - threshold → (newCurrent - min) / threshold = 1
215
+ // In this case we update the values directly via .get() instead of
216
+ // .modify(), which avoids triggering the reactive cascade (progress
217
+ // re-derivation, animated reactions, animated styles). The values are
218
+ // still updated in-place for tab synchronization correctness.
219
+ if (isCollapsed && newCurrent >= threshold) {
220
+ scrollValue.current = newCurrent;
221
+ scrollValue.min = newCurrent - threshold;
222
+ return;
223
+ }
145
224
 
146
225
  scrollValues.modify((value) => {
147
226
  if (!value[id]) {
148
227
  return value;
149
228
  }
150
229
 
151
- const activeScrollIdValue = activeScrollId?.get();
152
- if (activeScrollIdValue && activeScrollIdValue !== id) {
153
- return value;
154
- }
155
-
156
- const oldCurrent = value[id].current;
157
- const oldMin = value[id].min;
158
- const isCollapsed = oldCurrent >= oldMin + progressThreshold - 0.001;
159
-
160
- const newCurrent = e.contentOffset.y;
161
230
  value[id].current = newCurrent;
162
231
 
163
232
  if (isCollapsed) {
164
- value[id].min = Math.max(0, newCurrent - progressThreshold);
233
+ value[id].min = Math.max(0, newCurrent - threshold);
165
234
  }
166
235
 
167
236
  return value;
@@ -170,9 +239,24 @@ export function useScrollManager(
170
239
  [scrollValues, id, activeScrollId, progressThreshold]
171
240
  );
172
241
 
173
- const onScroll = useAnimatedScrollHandler(scrollHandler);
242
+ const onBeginDrag = useCallback<ScrollHandler<ScrollHandlerContext>>(() => {
243
+ 'worklet';
244
+ if (headerPanMomentumOffset.get() === null) {
245
+ return;
246
+ }
247
+
248
+ cancelAnimation(headerPanMomentumOffset);
249
+ headerPanMomentumOffset.set(null);
250
+ }, [headerPanMomentumOffset]);
251
+
252
+ const animatedOnScroll = useAnimatedScrollHandler({
253
+ onBeginDrag,
254
+ onScroll,
255
+ });
174
256
 
175
257
  const minHeightContentContainerStyle = useAnimatedStyle(() => {
258
+ const threshold = progressThreshold.get();
259
+
176
260
  if (globalThis.__RUNTIME_KIND === RuntimeKind.ReactNative) {
177
261
  return {};
178
262
  }
@@ -184,14 +268,22 @@ export function useScrollManager(
184
268
  }
185
269
 
186
270
  return {
187
- minHeight: measurement.height + progressThreshold,
271
+ minHeight: measurement.height + threshold,
188
272
  };
189
273
  });
190
274
 
275
+ const resolvedRefreshControl = resolveRefreshControl({
276
+ refreshControl,
277
+ refreshing,
278
+ onRefresh,
279
+ progressViewOffset,
280
+ });
281
+
191
282
  const scrollableProps = {
192
- onScroll,
283
+ onScroll: animatedOnScroll,
193
284
  scrollEventThrottle: 16,
194
285
  ref: animatedRef,
286
+ refreshControl: resolvedRefreshControl,
195
287
  };
196
288
  const headerMotionContext = {
197
289
  originalHeaderHeight,
package/src/types.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import type { ReactElement } from 'react';
1
2
  import type { LayoutChangeEvent, ScrollViewProps } from 'react-native';
2
3
  import type { AnimatedRef, SharedValue } from 'react-native-reanimated';
3
4
  import { DEFAULT_SCROLL_ID } from './utils/defaults';
5
+ import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes';
4
6
 
5
7
  export type Progress = SharedValue<number>;
6
8
 
@@ -38,13 +40,21 @@ export type WithCollapsiblePagedHeaderProps<
38
40
 
39
41
  export interface MotionProgress {
40
42
  progress: Progress;
41
- progressThreshold: number;
43
+ progressThreshold: SharedValue<number>;
42
44
  measureTotalHeight: MeasureAnimatedHeaderAndSet;
43
45
  measureDynamic: MeasureAnimatedHeaderAndSet;
46
+ animatedHeaderBaseProps: AnimatedHeaderBaseMotionProps;
47
+ activeScrollId: SharedValue<string> | undefined;
48
+ }
49
+
50
+ export interface AnimatedHeaderBaseMotionProps {
51
+ enableHeaderPan: boolean;
52
+ scrollToRef: React.RefObject<ScrollTo | null>;
53
+ headerPanMomentumOffset: SharedValue<number | null>;
44
54
  }
45
55
 
46
56
  export interface ScrollManagerHeaderMotionContext {
47
- originalHeaderHeight: number;
57
+ originalHeaderHeight: SharedValue<number>;
48
58
  minHeightContentContainerStyle:
49
59
  | {}
50
60
  | {
@@ -52,11 +62,19 @@ export interface ScrollManagerHeaderMotionContext {
52
62
  };
53
63
  }
54
64
 
55
- export interface ScrollManagerConfig {
65
+ export interface ScrollManagerConfig<TRef extends InstanceOrElement = any> {
56
66
  scrollableProps: Required<
57
67
  Pick<ScrollViewProps, 'onScroll' | 'scrollEventThrottle'>
58
68
  > & {
59
- ref: AnimatedRef<any>; // TODO: better typing
69
+ refreshControl?: ReactElement;
70
+ ref: AnimatedRef<TRef>;
60
71
  };
61
72
  headerMotionContext: ScrollManagerHeaderMotionContext;
62
73
  }
74
+
75
+ export type ScrollTo = (y: number, options?: ScrollToOptions) => void;
76
+
77
+ interface ScrollToOptions {
78
+ isValueDelta?: boolean;
79
+ animated?: boolean;
80
+ }