react-native-header-motion 0.4.0 → 1.0.0-beta.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 (140) hide show
  1. package/README.md +400 -335
  2. package/lib/module/components/Bridge.js +16 -0
  3. package/lib/module/components/Bridge.js.map +1 -0
  4. package/lib/module/components/FlatList.js +5 -62
  5. package/lib/module/components/FlatList.js.map +1 -1
  6. package/lib/module/components/Header.js +71 -13
  7. package/lib/module/components/Header.js.map +1 -1
  8. package/lib/module/components/HeaderDynamic.js +34 -0
  9. package/lib/module/components/HeaderDynamic.js.map +1 -0
  10. package/lib/module/components/HeaderMotion.js +59 -23
  11. package/lib/module/components/HeaderMotion.js.map +1 -1
  12. package/lib/module/components/HeaderPanBoundary.js +54 -0
  13. package/lib/module/components/HeaderPanBoundary.js.map +1 -0
  14. package/lib/module/components/NavigationBridge.js +20 -0
  15. package/lib/module/components/NavigationBridge.js.map +1 -0
  16. package/lib/module/components/ScrollManager.js +7 -5
  17. package/lib/module/components/ScrollManager.js.map +1 -1
  18. package/lib/module/components/ScrollView.js +6 -47
  19. package/lib/module/components/ScrollView.js.map +1 -1
  20. package/lib/module/components/createHeaderMotionScrollable.js +136 -0
  21. package/lib/module/components/createHeaderMotionScrollable.js.map +1 -0
  22. package/lib/module/components/index.js +3 -1
  23. package/lib/module/components/index.js.map +1 -1
  24. package/lib/module/context.js +8 -1
  25. package/lib/module/context.js.map +1 -1
  26. package/lib/module/hooks/index.js +1 -0
  27. package/lib/module/hooks/index.js.map +1 -1
  28. package/lib/module/hooks/useActiveScrollId.js +7 -6
  29. package/lib/module/hooks/useActiveScrollId.js.map +1 -1
  30. package/lib/module/hooks/useHeaderMotionBridge.js +14 -0
  31. package/lib/module/hooks/useHeaderMotionBridge.js.map +1 -0
  32. package/lib/module/hooks/useMotionProgress.js +10 -36
  33. package/lib/module/hooks/useMotionProgress.js.map +1 -1
  34. package/lib/module/hooks/useMotionProgress.test.js +56 -0
  35. package/lib/module/hooks/useMotionProgress.test.js.map +1 -0
  36. package/lib/module/hooks/useScrollManager.js +219 -109
  37. package/lib/module/hooks/useScrollManager.js.map +1 -1
  38. package/lib/module/index.js +21 -18
  39. package/lib/module/index.js.map +1 -1
  40. package/lib/module/utils/defaults.js +2 -1
  41. package/lib/module/utils/defaults.js.map +1 -1
  42. package/lib/module/utils/header.js +24 -0
  43. package/lib/module/utils/header.js.map +1 -0
  44. package/lib/module/utils/headerOffsetStyle.js +31 -0
  45. package/lib/module/utils/headerOffsetStyle.js.map +1 -0
  46. package/lib/module/utils/index.js +3 -0
  47. package/lib/module/utils/index.js.map +1 -1
  48. package/lib/module/utils/refreshControl.js +93 -0
  49. package/lib/module/utils/refreshControl.js.map +1 -0
  50. package/lib/module/utils/values.js +36 -0
  51. package/lib/module/utils/values.js.map +1 -1
  52. package/lib/typescript/src/components/Bridge.d.ts +19 -0
  53. package/lib/typescript/src/components/Bridge.d.ts.map +1 -0
  54. package/lib/typescript/src/components/FlatList.d.ts +7 -15
  55. package/lib/typescript/src/components/FlatList.d.ts.map +1 -1
  56. package/lib/typescript/src/components/Header.d.ts +73 -12
  57. package/lib/typescript/src/components/Header.d.ts.map +1 -1
  58. package/lib/typescript/src/components/HeaderDynamic.d.ts +11 -0
  59. package/lib/typescript/src/components/HeaderDynamic.d.ts.map +1 -0
  60. package/lib/typescript/src/components/HeaderMotion.d.ts +37 -18
  61. package/lib/typescript/src/components/HeaderMotion.d.ts.map +1 -1
  62. package/lib/typescript/src/components/HeaderPanBoundary.d.ts +11 -0
  63. package/lib/typescript/src/components/HeaderPanBoundary.d.ts.map +1 -0
  64. package/lib/typescript/src/components/NavigationBridge.d.ts +19 -0
  65. package/lib/typescript/src/components/NavigationBridge.d.ts.map +1 -0
  66. package/lib/typescript/src/components/ScrollManager.d.ts +18 -25
  67. package/lib/typescript/src/components/ScrollManager.d.ts.map +1 -1
  68. package/lib/typescript/src/components/ScrollView.d.ts +7 -14
  69. package/lib/typescript/src/components/ScrollView.d.ts.map +1 -1
  70. package/lib/typescript/src/components/createHeaderMotionScrollable.d.ts +86 -0
  71. package/lib/typescript/src/components/createHeaderMotionScrollable.d.ts.map +1 -0
  72. package/lib/typescript/src/components/index.d.ts +3 -1
  73. package/lib/typescript/src/components/index.d.ts.map +1 -1
  74. package/lib/typescript/src/context.d.ts +3 -13
  75. package/lib/typescript/src/context.d.ts.map +1 -1
  76. package/lib/typescript/src/hooks/index.d.ts +1 -0
  77. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  78. package/lib/typescript/src/hooks/useActiveScrollId.d.ts +7 -6
  79. package/lib/typescript/src/hooks/useActiveScrollId.d.ts.map +1 -1
  80. package/lib/typescript/src/hooks/useHeaderMotionBridge.d.ts +10 -0
  81. package/lib/typescript/src/hooks/useHeaderMotionBridge.d.ts.map +1 -0
  82. package/lib/typescript/src/hooks/useMotionProgress.d.ts +8 -25
  83. package/lib/typescript/src/hooks/useMotionProgress.d.ts.map +1 -1
  84. package/lib/typescript/src/hooks/useMotionProgress.test.d.ts +2 -0
  85. package/lib/typescript/src/hooks/useMotionProgress.test.d.ts.map +1 -0
  86. package/lib/typescript/src/hooks/useScrollManager.d.ts +63 -31
  87. package/lib/typescript/src/hooks/useScrollManager.d.ts.map +1 -1
  88. package/lib/typescript/src/index.d.ts +56 -26
  89. package/lib/typescript/src/index.d.ts.map +1 -1
  90. package/lib/typescript/src/types.d.ts +63 -15
  91. package/lib/typescript/src/types.d.ts.map +1 -1
  92. package/lib/typescript/src/utils/defaults.d.ts +3 -2
  93. package/lib/typescript/src/utils/defaults.d.ts.map +1 -1
  94. package/lib/typescript/src/utils/header.d.ts +10 -0
  95. package/lib/typescript/src/utils/header.d.ts.map +1 -0
  96. package/lib/typescript/src/utils/headerOffsetStyle.d.ts +19 -0
  97. package/lib/typescript/src/utils/headerOffsetStyle.d.ts.map +1 -0
  98. package/lib/typescript/src/utils/index.d.ts +3 -0
  99. package/lib/typescript/src/utils/index.d.ts.map +1 -1
  100. package/lib/typescript/src/utils/refreshControl.d.ts +150 -0
  101. package/lib/typescript/src/utils/refreshControl.d.ts.map +1 -0
  102. package/lib/typescript/src/utils/values.d.ts +4 -1
  103. package/lib/typescript/src/utils/values.d.ts.map +1 -1
  104. package/package.json +13 -5
  105. package/src/components/Bridge.tsx +29 -0
  106. package/src/components/FlatList.tsx +18 -84
  107. package/src/components/Header.tsx +159 -23
  108. package/src/components/HeaderDynamic.tsx +45 -0
  109. package/src/components/HeaderMotion.tsx +114 -41
  110. package/src/components/HeaderPanBoundary.tsx +92 -0
  111. package/src/components/NavigationBridge.tsx +30 -0
  112. package/src/components/ScrollManager.tsx +38 -43
  113. package/src/components/ScrollView.tsx +16 -68
  114. package/src/components/createHeaderMotionScrollable.tsx +438 -0
  115. package/src/components/index.ts +3 -1
  116. package/src/context.ts +12 -18
  117. package/src/hooks/index.ts +1 -0
  118. package/src/hooks/useActiveScrollId.ts +7 -6
  119. package/src/hooks/useHeaderMotionBridge.ts +15 -0
  120. package/src/hooks/useMotionProgress.test.ts +67 -0
  121. package/src/hooks/useMotionProgress.ts +12 -37
  122. package/src/hooks/useScrollManager.ts +310 -129
  123. package/src/index.ts +82 -36
  124. package/src/types.ts +85 -25
  125. package/src/utils/defaults.ts +7 -1
  126. package/src/utils/header.tsx +52 -0
  127. package/src/utils/headerOffsetStyle.ts +40 -0
  128. package/src/utils/index.ts +3 -0
  129. package/src/utils/refreshControl.tsx +118 -0
  130. package/src/utils/values.ts +57 -1
  131. package/lib/module/components/HeaderBase.js +0 -59
  132. package/lib/module/components/HeaderBase.js.map +0 -1
  133. package/lib/module/hooks/refreshControl.js +0 -31
  134. package/lib/module/hooks/refreshControl.js.map +0 -1
  135. package/lib/typescript/src/components/HeaderBase.d.ts +0 -34
  136. package/lib/typescript/src/components/HeaderBase.d.ts.map +0 -1
  137. package/lib/typescript/src/hooks/refreshControl.d.ts +0 -13
  138. package/lib/typescript/src/hooks/refreshControl.d.ts.map +0 -1
  139. package/src/components/HeaderBase.tsx +0 -51
  140. package/src/hooks/refreshControl.ts +0 -55
@@ -1,84 +1,59 @@
1
- import { useContext, useCallback, useEffect } from 'react';
2
1
  import {
3
- measure,
2
+ useContext,
3
+ useCallback,
4
+ useEffect,
5
+ useState,
6
+ type ContextType,
7
+ } from 'react';
8
+ import {
9
+ cancelAnimation,
4
10
  scrollTo,
5
11
  useAnimatedReaction,
6
12
  useAnimatedRef,
7
13
  useAnimatedScrollHandler,
8
- useAnimatedStyle,
14
+ useSharedValue,
9
15
  type AnimatedRef,
10
16
  type ScrollHandler,
11
17
  } from 'react-native-reanimated';
12
- import { RuntimeKind, scheduleOnUI } from 'react-native-worklets';
18
+ import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
13
19
  import { HeaderMotionContext } from '../context';
14
- import type { ScrollManagerConfig, ScrollValues } from '../types';
15
- import { DEFAULT_SCROLL_ID, getInitialScrollValue } from '../utils';
20
+ import type { ScrollManagerConfig, ScrollHandlerContext } from '../types';
21
+ import type { LayoutChangeEvent } from 'react-native';
16
22
  import {
17
23
  resolveRefreshControl,
24
+ DEFAULT_SCROLL_ID,
25
+ ensureScrollValueRegistered,
26
+ warnIfMissingActiveScrollId,
18
27
  type ResolveRefreshControlOptions,
19
- } from './refreshControl';
28
+ } from '../utils';
29
+ import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes';
20
30
  import {
21
31
  useConsumerScrollHandlers,
22
32
  useScrollHandlerComposition,
23
33
  type ConsumerScrollEventHandlers,
24
34
  } from './useConsumerScrollHandlers';
25
35
 
26
- /**
27
- * Hook that manages scroll tracking and synchronization for header animations.
28
- * Returns props to apply to scrollable components and additional values that help with adjusting styling of the scrollables to header's dimensions.
29
- *
30
- * This hook handles:
31
- * - Scroll position tracking
32
- * - Synchronization between multiple scroll views (when using multiple scroll IDs)
33
- * - Content container minimum height calculations for cases where one of the tracked scrollables does not take enough space to reach the progress threshold/
34
- *
35
- * Must be used within a HeaderMotion component.
36
- *
37
- * @param scrollId - Optional unique identifier for the related scrollable.
38
- * Use when you have multiple scrollables (e.g., in tabs).
39
- * @param options - Optional configuration object.
40
- * @param options.animatedRef - Optional animated ref to use instead of creating one internally.
41
- * Useful when you need access to the scroll view ref from outside.
42
- * @returns Configuration object containing:
43
- * - `scrollableProps`: Props to apply to scrollable component (onScroll, scrollEventThrottle, ref)
44
- * - `headerMotionContext`: Header context values (originalHeaderHeight, minHeightContentContainerStyle)
45
- *
46
- * @throws Error if used outside of a HeaderMotion component
47
- *
48
- * @example
49
- * ```tsx
50
- * function CustomScrollComponent() {
51
- * const { scrollableProps, headerMotionContext } = useScrollManager('myScroll');
52
- *
53
- * return (
54
- * <CustomScrollView {...scrollableProps}>
55
- * <View style={{ paddingTop: headerMotionContext.originalHeaderHeight }}>
56
- * Content
57
- * </View>
58
- * </CustomScrollView>
59
- * );
60
- * }
61
- * ```
62
- */
63
- export interface UseScrollManagerOptions
64
- extends Omit<ResolveRefreshControlOptions, 'progressViewOffset'>,
65
- ConsumerScrollEventHandlers {
66
- /**
67
- * Optional animated ref to use instead of creating one internally.
68
- * Useful when you need access to the scroll view ref from outside.
69
- */
70
- animatedRef?: AnimatedRef<any>;
71
- /**
72
- * Optional refresh progress offset override.
73
- * When provided, it takes precedence over the automatic offset based on header height.
74
- */
75
- progressViewOffset?: ResolveRefreshControlOptions['progressViewOffset'];
36
+ const SCROLL_TOLERANCE = 0.5;
37
+
38
+ type ScrollManagerContextValue = NonNullable<
39
+ ContextType<typeof HeaderMotionContext>
40
+ >;
41
+
42
+ interface MinHeightOptions {
43
+ enabled: boolean;
76
44
  }
77
45
 
78
- export function useScrollManager(
79
- scrollId?: string,
80
- options?: UseScrollManagerOptions
81
- ): ScrollManagerConfig {
46
+ interface SynchronizationOptions<TRef extends InstanceOrElement> {
47
+ animatedRef: AnimatedRef<TRef>;
48
+ id: string;
49
+ }
50
+
51
+ interface ScrollHandlersOptions {
52
+ consumerHandlers: ConsumerScrollEventHandlers;
53
+ id: string;
54
+ }
55
+
56
+ function useScrollManagerContext(): ScrollManagerContextValue {
82
57
  const ctxValue = useContext(HeaderMotionContext);
83
58
  if (!ctxValue) {
84
59
  throw new Error(
@@ -86,31 +61,89 @@ export function useScrollManager(
86
61
  );
87
62
  }
88
63
 
64
+ return ctxValue;
65
+ }
66
+
67
+ function useScrollManagerContentMinHeight({ enabled }: MinHeightOptions) {
68
+ const { progressThreshold } = useScrollManagerContext();
69
+ const preservedScrollContainerHeight = useSharedValue(0);
70
+ const [contentContainerMinHeight, setContentContainerMinHeight] = useState<
71
+ number | undefined
72
+ >(undefined);
73
+
74
+ const handleLayout = useCallback(
75
+ (e: LayoutChangeEvent) => {
76
+ if (!enabled) {
77
+ return;
78
+ }
79
+
80
+ const nextHeight = e.nativeEvent.layout.height;
81
+ scheduleOnUI((height: number) => {
82
+ 'worklet';
83
+ preservedScrollContainerHeight.set(height);
84
+ const nextMinHeight = height + progressThreshold.get();
85
+ scheduleOnRN(setContentContainerMinHeight, nextMinHeight);
86
+ }, nextHeight);
87
+ },
88
+ [enabled, preservedScrollContainerHeight, progressThreshold]
89
+ );
90
+
91
+ useAnimatedReaction(
92
+ () => progressThreshold.get(),
93
+ (threshold, previousThreshold) => {
94
+ if (
95
+ !enabled ||
96
+ previousThreshold === null ||
97
+ previousThreshold === threshold
98
+ ) {
99
+ return;
100
+ }
101
+
102
+ const currentHeight = preservedScrollContainerHeight.get();
103
+ if (currentHeight <= 0) {
104
+ return;
105
+ }
106
+
107
+ const nextMinHeight = currentHeight + threshold;
108
+ scheduleOnRN(setContentContainerMinHeight, nextMinHeight);
109
+ }
110
+ );
111
+
112
+ return {
113
+ contentContainerMinHeight,
114
+ handleLayout: enabled ? handleLayout : undefined,
115
+ };
116
+ }
117
+
118
+ function useScrollManagerSynchronization<TRef extends InstanceOrElement>({
119
+ animatedRef,
120
+ id,
121
+ }: SynchronizationOptions<TRef>) {
89
122
  const {
90
- scrollValues,
91
- progress,
92
123
  activeScrollId,
124
+ progress,
93
125
  progressThreshold,
94
- originalHeaderHeight,
95
- } = ctxValue;
96
- const id = scrollId ?? DEFAULT_SCROLL_ID;
126
+ scrollToRef,
127
+ scrollValues,
128
+ } = useScrollManagerContext();
97
129
 
98
- const localRef = useAnimatedRef<any>(); // TODO: better typing
99
- const animatedRef = options?.animatedRef ?? localRef;
100
- const refreshControl = options?.refreshControl;
101
- const refreshing = options?.refreshing;
102
- const onRefresh = options?.onRefresh;
103
- const progressViewOffset =
104
- options?.progressViewOffset ?? originalHeaderHeight;
130
+ useAnimatedReaction(
131
+ () => activeScrollId?.get(),
132
+ (activeId) => {
133
+ const currentValues = ensureScrollValueRegistered(scrollValues, id);
134
+ warnIfMissingActiveScrollId(currentValues, id, activeId);
105
135
 
106
- const { onScroll, onBeginDrag, onEndDrag, onMomentumBegin, onMomentumEnd } =
107
- useConsumerScrollHandlers({
108
- onScroll: options?.onScroll,
109
- onScrollBeginDrag: options?.onScrollBeginDrag,
110
- onScrollEndDrag: options?.onScrollEndDrag,
111
- onMomentumScrollBegin: options?.onMomentumScrollBegin,
112
- onMomentumScrollEnd: options?.onMomentumScrollEnd,
113
- });
136
+ if (!activeId || activeId === id) {
137
+ // TODO: Could we just be passing current scrollRef instead of the entire function?
138
+ scrollToRef.current = (y, scrollOptions = {}) => {
139
+ 'worklet';
140
+ const { isValueDelta = true, animated = false } = scrollOptions;
141
+ const newY = isValueDelta ? scrollValues.get()[id]!.current - y : y;
142
+ scrollTo(animatedRef, 0, newY, animated);
143
+ };
144
+ }
145
+ }
146
+ );
114
147
 
115
148
  useEffect(() => {
116
149
  return () => {
@@ -122,13 +155,11 @@ export function useScrollManager(
122
155
  });
123
156
  }, id);
124
157
  };
125
- }, [scrollValues, id]);
158
+ }, [id, scrollValues]);
126
159
 
127
160
  useAnimatedReaction(
128
161
  () => progress.value,
129
162
  (newProgress, oldProgress) => {
130
- // FUTURE: If really needed for, can use other scroll handlers to only do this either on scroll end or between scroll end and momentum end in onScroll (keep context in shared value)
131
- // Only sync inactive scroll views when we have multiple tabs being tracked
132
163
  const currentActiveScrollId = activeScrollId?.get();
133
164
  if (
134
165
  !currentActiveScrollId ||
@@ -138,25 +169,20 @@ export function useScrollManager(
138
169
  return;
139
170
  }
140
171
 
141
- if (!scrollValues.get()[id]) {
142
- scrollValues.modify((value) => {
143
- (value as ScrollValues)[id] = getInitialScrollValue();
144
- return value;
145
- });
146
- }
172
+ ensureScrollValueRegistered(scrollValues, id);
147
173
 
148
174
  let newCur = -1;
175
+ const threshold = progressThreshold.get();
149
176
 
150
177
  scrollValues.modify((value) => {
151
- let scrollValue = value[id];
178
+ const scrollValue = value[id];
152
179
  if (!scrollValue) {
153
- (value as ScrollValues)[id] = getInitialScrollValue();
154
- scrollValue = value[id]!;
180
+ return value;
155
181
  }
156
182
 
157
183
  const progressDiff = oldProgress - newProgress;
158
- newCur = scrollValue.current - progressDiff * progressThreshold;
159
- const newMin = newCur - newProgress * progressThreshold;
184
+ newCur = scrollValue.current - progressDiff * threshold;
185
+ const newMin = newCur - newProgress * threshold;
160
186
  scrollValue.current = newCur;
161
187
  scrollValue.min = newMin;
162
188
 
@@ -168,61 +194,219 @@ export function useScrollManager(
168
194
  }
169
195
  }
170
196
  );
197
+ }
171
198
 
172
- const scrollHandler = useCallback<ScrollHandler>(
173
- (e) => {
199
+ function useScrollManagerHandlers({
200
+ consumerHandlers,
201
+ id,
202
+ }: ScrollHandlersOptions) {
203
+ const {
204
+ activeScrollId,
205
+ headerPanMomentumOffset,
206
+ progressThreshold,
207
+ scrollValues,
208
+ } = useScrollManagerContext();
209
+ const { onScroll, onBeginDrag, onEndDrag, onMomentumBegin, onMomentumEnd } =
210
+ useConsumerScrollHandlers(consumerHandlers);
211
+
212
+ const handleScroll = useCallback<ScrollHandler<ScrollHandlerContext>>(
213
+ (e, ctx) => {
174
214
  'worklet';
175
215
  onScroll?.(e);
176
216
 
217
+ const newCurrent = e.contentOffset.y;
218
+
219
+ if (
220
+ ctx.lastOffset !== undefined &&
221
+ Math.abs(ctx.lastOffset - newCurrent) < SCROLL_TOLERANCE
222
+ ) {
223
+ return;
224
+ }
225
+ ctx.lastOffset = newCurrent;
226
+
227
+ const threshold = progressThreshold.get();
228
+ const values = scrollValues.get();
229
+ const scrollValue = values[id];
230
+
231
+ if (!scrollValue) {
232
+ return;
233
+ }
234
+
235
+ const activeScrollIdValue = activeScrollId?.get();
236
+ if (activeScrollIdValue && activeScrollIdValue !== id) {
237
+ return;
238
+ }
239
+
240
+ const oldCurrent = scrollValue.current;
241
+ const oldMin = scrollValue.min;
242
+ const isCollapsed = oldCurrent >= oldMin + threshold - 0.001;
243
+
244
+ if (isCollapsed && newCurrent >= threshold) {
245
+ scrollValue.current = newCurrent;
246
+ scrollValue.min = newCurrent - threshold;
247
+ return;
248
+ }
249
+
177
250
  scrollValues.modify((value) => {
178
251
  if (!value[id]) {
179
252
  return value;
180
253
  }
181
254
 
182
- const activeScrollIdValue = activeScrollId?.get();
183
- if (activeScrollIdValue && activeScrollIdValue !== id) {
184
- return value;
185
- }
186
-
187
- const oldCurrent = value[id].current;
188
- const oldMin = value[id].min;
189
- const isCollapsed = oldCurrent >= oldMin + progressThreshold - 0.001;
190
-
191
- const newCurrent = e.contentOffset.y;
192
255
  value[id].current = newCurrent;
193
256
 
194
257
  if (isCollapsed) {
195
- value[id].min = Math.max(0, newCurrent - progressThreshold);
258
+ value[id].min = Math.max(0, newCurrent - threshold);
196
259
  }
197
260
 
198
261
  return value;
199
262
  });
200
263
  },
201
- [scrollValues, id, activeScrollId, progressThreshold, onScroll]
264
+ [activeScrollId, id, onScroll, progressThreshold, scrollValues]
265
+ );
266
+
267
+ const handleBeginDrag = useCallback<ScrollHandler<ScrollHandlerContext>>(
268
+ (e) => {
269
+ 'worklet';
270
+ onBeginDrag?.(e);
271
+
272
+ if (headerPanMomentumOffset.get() === null) {
273
+ return;
274
+ }
275
+
276
+ cancelAnimation(headerPanMomentumOffset);
277
+ headerPanMomentumOffset.set(null);
278
+ },
279
+ [headerPanMomentumOffset, onBeginDrag]
202
280
  );
203
281
 
204
- const animatedScrollHandler = useAnimatedScrollHandler({
205
- onScroll: scrollHandler,
206
- onBeginDrag,
282
+ return useAnimatedScrollHandler({
283
+ onBeginDrag: handleBeginDrag,
284
+ onScroll: handleScroll,
207
285
  onEndDrag,
208
286
  onMomentumBegin,
209
287
  onMomentumEnd,
210
288
  });
289
+ }
290
+ export interface UseScrollManagerOptions<TRef extends InstanceOrElement = any>
291
+ extends Omit<ResolveRefreshControlOptions, 'progressViewOffset'>,
292
+ ConsumerScrollEventHandlers {
293
+ /**
294
+ * Animated ref for the managed scrollable.
295
+ *
296
+ * Provide this when the caller also needs imperative access to the same
297
+ * scrollable instance. Otherwise the hook creates one internally.
298
+ */
299
+ animatedRef?: AnimatedRef<TRef>;
300
+ /**
301
+ * Overrides the refresh indicator offset.
302
+ *
303
+ * By default, HeaderMotion derives this from the measured header height so
304
+ * pull-to-refresh starts below the header. Override it only when you need a
305
+ * custom refresh placement.
306
+ */
307
+ progressViewOffset?: ResolveRefreshControlOptions['progressViewOffset'];
308
+ /**
309
+ * Ensures short content can still scroll far enough to fully collapse the
310
+ * header.
311
+ *
312
+ * **Experimental: this relies on extra layout measurement and may still be
313
+ * refined.**
314
+ *
315
+ * Enable this when your content is sometimes shorter than the viewport and
316
+ * you still want the header to reach the collapsed state.
317
+ */
318
+ ensureScrollableContentMinHeight?: boolean;
319
+ }
211
320
 
212
- const minHeightContentContainerStyle = useAnimatedStyle(() => {
213
- if (globalThis.__RUNTIME_KIND === RuntimeKind.ReactNative) {
214
- return {};
215
- }
321
+ /**
322
+ * Wires a custom scrollable into HeaderMotion.
323
+ *
324
+ * Most code should not use this hook directly.
325
+ *
326
+ * **Prefer `createHeaderMotionScrollable()` whenever possible.** It gives
327
+ * you the same integration in a reusable component wrapper with less manual
328
+ * wiring. Reach for `useScrollManager()` only in more complex cases where the
329
+ * factory API is not enough, for example when a third-party scrollable needs
330
+ * highly custom composition.
331
+ *
332
+ * It returns two things:
333
+ * - `scrollableProps`: the event handlers / ref / refresh-control props that
334
+ * should go on the scrollable itself
335
+ * - `headerMotionContext`: layout values you can use to offset the content
336
+ * below the measured header
337
+ *
338
+ * In multi-scroll setups, pass a unique `scrollId` for each scrollable.
339
+ * In single-scroll setups, you usually do not need one.
340
+ *
341
+ * If you need the same fallback behavior but prefer render-prop composition
342
+ * over a hook, use `HeaderMotion.ScrollManager`.
343
+ *
344
+ * @param scrollId Optional unique identifier for the managed scrollable.
345
+ * @param options Optional configuration for refs, refresh handling, user
346
+ * scroll callbacks, and short-content fallback behavior.
347
+ * @returns Object containing:
348
+ * - `scrollableProps`: props to spread onto the scrollable (`ref`, managed
349
+ * `onScroll`, optional `onLayout`, and resolved `refreshControl`)
350
+ * - `headerMotionContext`: layout values for offsetting the content container
351
+ * (`originalHeaderHeight` and optional `contentContainerMinHeight`)
352
+ *
353
+ * @example
354
+ * ```tsx
355
+ * function CustomScrollComponent() {
356
+ * const { scrollableProps, headerMotionContext } = useScrollManager('myScroll');
357
+ *
358
+ * return (
359
+ * <CustomScrollView {...scrollableProps}>
360
+ * <View
361
+ * style={{
362
+ * paddingTop: headerMotionContext.originalHeaderHeight,
363
+ * minHeight: headerMotionContext.contentContainerMinHeight,
364
+ * }}
365
+ * >
366
+ * Content
367
+ * </View>
368
+ * </CustomScrollView>
369
+ * );
370
+ * }
371
+ * ```
372
+ */
373
+ export function useScrollManager<TRef extends InstanceOrElement = any>(
374
+ scrollId?: string,
375
+ options?: UseScrollManagerOptions<TRef>
376
+ ): ScrollManagerConfig<TRef> {
377
+ const { originalHeaderHeight } = useScrollManagerContext();
378
+ const id = scrollId ?? DEFAULT_SCROLL_ID;
216
379
 
217
- const measurement = measure(animatedRef);
380
+ const ensureScrollableContentMinHeight =
381
+ options?.ensureScrollableContentMinHeight ?? false;
382
+ const refreshControl = options?.refreshControl;
383
+ const refreshing = options?.refreshing;
384
+ const onRefresh = options?.onRefresh;
385
+ const progressViewOffset =
386
+ options?.progressViewOffset ?? originalHeaderHeight;
218
387
 
219
- if (!measurement) {
220
- return {};
221
- }
388
+ const localRef = useAnimatedRef<TRef>();
389
+ const animatedRef = options?.animatedRef ?? localRef;
222
390
 
223
- return {
224
- minHeight: measurement.height + progressThreshold,
225
- };
391
+ const { contentContainerMinHeight, handleLayout } =
392
+ useScrollManagerContentMinHeight({
393
+ enabled: ensureScrollableContentMinHeight,
394
+ });
395
+
396
+ useScrollManagerSynchronization({
397
+ id,
398
+ animatedRef,
399
+ });
400
+
401
+ const animatedOnScroll = useScrollManagerHandlers({
402
+ id,
403
+ consumerHandlers: {
404
+ onScroll: options?.onScroll,
405
+ onScrollBeginDrag: options?.onScrollBeginDrag,
406
+ onScrollEndDrag: options?.onScrollEndDrag,
407
+ onMomentumScrollBegin: options?.onMomentumScrollBegin,
408
+ onMomentumScrollEnd: options?.onMomentumScrollEnd,
409
+ },
226
410
  });
227
411
 
228
412
  const resolvedRefreshControl = resolveRefreshControl({
@@ -233,17 +417,14 @@ export function useScrollManager(
233
417
  });
234
418
 
235
419
  const scrollableProps = {
236
- onScroll: useScrollHandlerComposition(
237
- animatedScrollHandler,
238
- options?.onScroll
239
- ),
240
- scrollEventThrottle: 16,
420
+ onScroll: useScrollHandlerComposition(animatedOnScroll, options?.onScroll),
421
+ onLayout: handleLayout,
241
422
  ref: animatedRef,
242
423
  refreshControl: resolvedRefreshControl,
243
424
  };
244
425
  const headerMotionContext = {
245
426
  originalHeaderHeight,
246
- minHeightContentContainerStyle,
427
+ contentContainerMinHeight,
247
428
  };
248
429
 
249
430
  return { scrollableProps, headerMotionContext };