react-native-header-motion 0.3.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 (62) hide show
  1. package/README.md +75 -25
  2. package/lib/module/components/FlatList.js +2 -0
  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.map +1 -1
  9. package/lib/module/components/ScrollView.js +2 -0
  10. package/lib/module/components/ScrollView.js.map +1 -1
  11. package/lib/module/context.js.map +1 -1
  12. package/lib/module/hooks/useMotionProgress.js +6 -2
  13. package/lib/module/hooks/useMotionProgress.js.map +1 -1
  14. package/lib/module/hooks/useScrollManager.js +79 -29
  15. package/lib/module/hooks/useScrollManager.js.map +1 -1
  16. package/lib/module/utils/index.js +1 -0
  17. package/lib/module/utils/index.js.map +1 -1
  18. package/lib/module/utils/refreshControl.js +93 -0
  19. package/lib/module/utils/refreshControl.js.map +1 -0
  20. package/lib/module/utils/values.js +36 -0
  21. package/lib/module/utils/values.js.map +1 -1
  22. package/lib/typescript/src/components/FlatList.d.ts +1 -1
  23. package/lib/typescript/src/components/FlatList.d.ts.map +1 -1
  24. package/lib/typescript/src/components/HeaderBase.d.ts +9 -2
  25. package/lib/typescript/src/components/HeaderBase.d.ts.map +1 -1
  26. package/lib/typescript/src/components/HeaderMotion.d.ts +5 -1
  27. package/lib/typescript/src/components/HeaderMotion.d.ts.map +1 -1
  28. package/lib/typescript/src/components/ScrollManager.d.ts +6 -16
  29. package/lib/typescript/src/components/ScrollManager.d.ts.map +1 -1
  30. package/lib/typescript/src/components/ScrollView.d.ts +2 -2
  31. package/lib/typescript/src/components/ScrollView.d.ts.map +1 -1
  32. package/lib/typescript/src/context.d.ts +7 -3
  33. package/lib/typescript/src/context.d.ts.map +1 -1
  34. package/lib/typescript/src/hooks/useMotionProgress.d.ts.map +1 -1
  35. package/lib/typescript/src/hooks/useScrollManager.d.ts +5 -4
  36. package/lib/typescript/src/hooks/useScrollManager.d.ts.map +1 -1
  37. package/lib/typescript/src/types.d.ts +20 -6
  38. package/lib/typescript/src/types.d.ts.map +1 -1
  39. package/lib/typescript/src/utils/index.d.ts +1 -0
  40. package/lib/typescript/src/utils/index.d.ts.map +1 -1
  41. package/lib/typescript/src/utils/refreshControl.d.ts +150 -0
  42. package/lib/typescript/src/utils/refreshControl.d.ts.map +1 -0
  43. package/lib/typescript/src/utils/values.d.ts +4 -1
  44. package/lib/typescript/src/utils/values.d.ts.map +1 -1
  45. package/package.json +7 -5
  46. package/src/components/FlatList.tsx +9 -3
  47. package/src/components/HeaderBase.tsx +93 -4
  48. package/src/components/HeaderMotion.tsx +102 -26
  49. package/src/components/ScrollManager.tsx +23 -28
  50. package/src/components/ScrollView.tsx +9 -3
  51. package/src/context.ts +9 -2
  52. package/src/hooks/useMotionProgress.ts +10 -2
  53. package/src/hooks/useScrollManager.ts +105 -36
  54. package/src/types.ts +22 -10
  55. package/src/utils/index.ts +1 -0
  56. package/src/utils/refreshControl.tsx +118 -0
  57. package/src/utils/values.ts +57 -1
  58. package/lib/module/hooks/refreshControl.js +0 -31
  59. package/lib/module/hooks/refreshControl.js.map +0 -1
  60. package/lib/typescript/src/hooks/refreshControl.d.ts +0 -13
  61. package/lib/typescript/src/hooks/refreshControl.d.ts.map +0 -1
  62. package/src/hooks/refreshControl.ts +0 -55
@@ -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,36 +1,26 @@
1
- import { useScrollManager } from '../hooks';
1
+ import { useScrollManager, type UseScrollManagerOptions } from '../hooks';
2
2
  import type { ScrollManagerConfig } from '../types';
3
- import type { ResolveRefreshControlOptions } from '../hooks/refreshControl';
4
3
  import type { ReactNode } from 'react';
5
- import type { AnimatedRef } from 'react-native-reanimated';
4
+ import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes';
6
5
 
7
- type ScrollManagerRenderChildren = (
8
- scrollableProps: ScrollManagerConfig['scrollableProps'],
9
- options: ScrollManagerConfig['headerMotionContext']
6
+ type ScrollManagerRenderChildren<TRef extends InstanceOrElement = any> = (
7
+ scrollableProps: ScrollManagerConfig<TRef>['scrollableProps'],
8
+ options: ScrollManagerConfig<TRef>['headerMotionContext']
10
9
  ) => ReactNode;
11
10
 
12
- export interface HeaderMotionScrollManagerProps
13
- extends Omit<ResolveRefreshControlOptions, 'progressViewOffset'> {
11
+ export interface HeaderMotionScrollManagerProps<
12
+ TRef extends InstanceOrElement = any
13
+ > extends UseScrollManagerOptions<TRef> {
14
14
  /**
15
15
  * Optional unique identifier for this scroll view.
16
16
  * Use this when you have multiple scroll views (e.g., in tabs) to track them separately.
17
17
  */
18
18
  scrollId?: string;
19
- /**
20
- * Optional animated ref to use for the scroll view.
21
- * When provided, the scroll manager will use this ref instead of creating its own.
22
- */
23
- animatedRef?: AnimatedRef<any>;
24
- /**
25
- * Optional refresh progress offset override.
26
- * When provided, it takes precedence over the automatic offset based on header height.
27
- */
28
- progressViewOffset?: ResolveRefreshControlOptions['progressViewOffset'];
29
19
  /**
30
20
  * Render function that receives scroll props and header context.
31
21
  * Use this to create custom scroll implementations that integrate with HeaderMotion.
32
22
  */
33
- children: ScrollManagerRenderChildren;
23
+ children: ScrollManagerRenderChildren<TRef>;
34
24
  }
35
25
 
36
26
  /**
@@ -55,7 +45,9 @@ export interface HeaderMotionScrollManagerProps
55
45
  * </HeaderMotion>
56
46
  * ```
57
47
  */
58
- export function HeaderMotionScrollManager({
48
+ export function HeaderMotionScrollManager<
49
+ TRef extends InstanceOrElement = any
50
+ >({
59
51
  children,
60
52
  scrollId,
61
53
  animatedRef,
@@ -63,20 +55,23 @@ export function HeaderMotionScrollManager({
63
55
  refreshing,
64
56
  onRefresh,
65
57
  progressViewOffset,
66
- }: HeaderMotionScrollManagerProps) {
58
+ }: HeaderMotionScrollManagerProps<TRef>) {
67
59
  if (typeof children !== 'function') {
68
60
  throw new Error(
69
61
  'HeaderMotion.ScrollManager only accepts render function as the only child.'
70
62
  );
71
63
  }
72
64
 
73
- const { scrollableProps, headerMotionContext } = useScrollManager(scrollId, {
74
- animatedRef,
75
- refreshControl,
76
- refreshing,
77
- onRefresh,
78
- progressViewOffset,
79
- });
65
+ const { scrollableProps, headerMotionContext } = useScrollManager<TRef>(
66
+ scrollId,
67
+ {
68
+ animatedRef,
69
+ refreshControl,
70
+ refreshing,
71
+ onRefresh,
72
+ progressViewOffset,
73
+ }
74
+ );
80
75
 
81
76
  return children(scrollableProps, headerMotionContext);
82
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
  /**
@@ -42,16 +42,22 @@ export function HeaderMotionScrollView({
42
42
  return (
43
43
  <HeaderMotionScrollManager
44
44
  scrollId={scrollId}
45
- animatedRef={animatedRef}
45
+ animatedRef={animatedRef as AnimatedRef<Animated.ScrollView>}
46
46
  refreshControl={refreshControl}
47
47
  >
48
48
  {(
49
- { onScroll, refreshControl: managedRefreshControl, ...scrollViewProps },
49
+ {
50
+ onScroll,
51
+ ref,
52
+ refreshControl: managedRefreshControl,
53
+ ...scrollViewProps
54
+ },
50
55
  { originalHeaderHeight, minHeightContentContainerStyle }
51
56
  ) => (
52
57
  <Animated.ScrollView
53
58
  {...scrollViewProps}
54
59
  {...props}
60
+ ref={ref}
55
61
  onScroll={onScroll}
56
62
  {...(managedRefreshControl && {
57
63
  refreshControl: managedRefreshControl,
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,12 +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
16
  import {
17
17
  resolveRefreshControl,
18
+ DEFAULT_SCROLL_ID,
19
+ ensureScrollValueRegistered,
20
+ warnIfMissingActiveScrollId,
18
21
  type ResolveRefreshControlOptions,
19
- } from './refreshControl';
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;
20
30
 
21
31
  /**
22
32
  * Hook that manages scroll tracking and synchronization for header animations.
@@ -55,13 +65,13 @@ import {
55
65
  * }
56
66
  * ```
57
67
  */
58
- export interface UseScrollManagerOptions
68
+ export interface UseScrollManagerOptions<TRef extends InstanceOrElement = any>
59
69
  extends Omit<ResolveRefreshControlOptions, 'progressViewOffset'> {
60
70
  /**
61
71
  * Optional animated ref to use instead of creating one internally.
62
72
  * Useful when you need access to the scroll view ref from outside.
63
73
  */
64
- animatedRef?: AnimatedRef<any>;
74
+ animatedRef?: AnimatedRef<TRef>;
65
75
  /**
66
76
  * Optional refresh progress offset override.
67
77
  * When provided, it takes precedence over the automatic offset based on header height.
@@ -69,10 +79,10 @@ export interface UseScrollManagerOptions
69
79
  progressViewOffset?: ResolveRefreshControlOptions['progressViewOffset'];
70
80
  }
71
81
 
72
- export function useScrollManager(
82
+ export function useScrollManager<TRef extends InstanceOrElement = any>(
73
83
  scrollId?: string,
74
- options?: UseScrollManagerOptions
75
- ): ScrollManagerConfig {
84
+ options?: UseScrollManagerOptions<TRef>
85
+ ): ScrollManagerConfig<TRef> {
76
86
  const ctxValue = useContext(HeaderMotionContext);
77
87
  if (!ctxValue) {
78
88
  throw new Error(
@@ -86,10 +96,12 @@ export function useScrollManager(
86
96
  activeScrollId,
87
97
  progressThreshold,
88
98
  originalHeaderHeight,
99
+ scrollToRef,
100
+ headerPanMomentumOffset,
89
101
  } = ctxValue;
90
102
  const id = scrollId ?? DEFAULT_SCROLL_ID;
91
103
 
92
- const localRef = useAnimatedRef<any>(); // TODO: better typing
104
+ const localRef = useAnimatedRef<TRef>();
93
105
  const animatedRef = options?.animatedRef ?? localRef;
94
106
  const refreshControl = options?.refreshControl;
95
107
  const refreshing = options?.refreshing;
@@ -97,6 +109,24 @@ export function useScrollManager(
97
109
  const progressViewOffset =
98
110
  options?.progressViewOffset ?? originalHeaderHeight;
99
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
+ );
129
+
100
130
  useEffect(() => {
101
131
  return () => {
102
132
  scheduleOnUI((scrollIdToDelete) => {
@@ -123,25 +153,20 @@ export function useScrollManager(
123
153
  return;
124
154
  }
125
155
 
126
- if (!scrollValues.get()[id]) {
127
- scrollValues.modify((value) => {
128
- (value as ScrollValues)[id] = getInitialScrollValue();
129
- return value;
130
- });
131
- }
156
+ ensureScrollValueRegistered(scrollValues, id);
132
157
 
133
158
  let newCur = -1;
159
+ const threshold = progressThreshold.get();
134
160
 
135
161
  scrollValues.modify((value) => {
136
- let scrollValue = value[id];
162
+ const scrollValue = value[id];
137
163
  if (!scrollValue) {
138
- (value as ScrollValues)[id] = getInitialScrollValue();
139
- scrollValue = value[id]!;
164
+ return value;
140
165
  }
141
166
 
142
167
  const progressDiff = oldProgress - newProgress;
143
- newCur = scrollValue.current - progressDiff * progressThreshold;
144
- const newMin = newCur - newProgress * progressThreshold;
168
+ newCur = scrollValue.current - progressDiff * threshold;
169
+ const newMin = newCur - newProgress * threshold;
145
170
  scrollValue.current = newCur;
146
171
  scrollValue.min = newMin;
147
172
 
@@ -154,29 +179,58 @@ export function useScrollManager(
154
179
  }
155
180
  );
156
181
 
157
- const scrollHandler = useCallback<ScrollHandler>(
158
- (e) => {
182
+ const onScroll = useCallback<ScrollHandler<ScrollHandlerContext>>(
183
+ (e, ctx) => {
159
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
+ }
160
224
 
161
225
  scrollValues.modify((value) => {
162
226
  if (!value[id]) {
163
227
  return value;
164
228
  }
165
229
 
166
- const activeScrollIdValue = activeScrollId?.get();
167
- if (activeScrollIdValue && activeScrollIdValue !== id) {
168
- return value;
169
- }
170
-
171
- const oldCurrent = value[id].current;
172
- const oldMin = value[id].min;
173
- const isCollapsed = oldCurrent >= oldMin + progressThreshold - 0.001;
174
-
175
- const newCurrent = e.contentOffset.y;
176
230
  value[id].current = newCurrent;
177
231
 
178
232
  if (isCollapsed) {
179
- value[id].min = Math.max(0, newCurrent - progressThreshold);
233
+ value[id].min = Math.max(0, newCurrent - threshold);
180
234
  }
181
235
 
182
236
  return value;
@@ -185,9 +239,24 @@ export function useScrollManager(
185
239
  [scrollValues, id, activeScrollId, progressThreshold]
186
240
  );
187
241
 
188
- 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
+ });
189
256
 
190
257
  const minHeightContentContainerStyle = useAnimatedStyle(() => {
258
+ const threshold = progressThreshold.get();
259
+
191
260
  if (globalThis.__RUNTIME_KIND === RuntimeKind.ReactNative) {
192
261
  return {};
193
262
  }
@@ -199,7 +268,7 @@ export function useScrollManager(
199
268
  }
200
269
 
201
270
  return {
202
- minHeight: measurement.height + progressThreshold,
271
+ minHeight: measurement.height + threshold,
203
272
  };
204
273
  });
205
274
 
@@ -211,7 +280,7 @@ export function useScrollManager(
211
280
  });
212
281
 
213
282
  const scrollableProps = {
214
- onScroll,
283
+ onScroll: animatedOnScroll,
215
284
  scrollEventThrottle: 16,
216
285
  ref: animatedRef,
217
286
  refreshControl: resolvedRefreshControl,