react-native-header-motion 1.0.0-alpha.0 → 1.0.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 (137) hide show
  1. package/README.md +65 -528
  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 -54
  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 +14 -20
  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 +19 -7
  17. package/lib/module/components/ScrollManager.js.map +1 -1
  18. package/lib/module/components/ScrollView.js +6 -39
  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/useConsumerScrollHandlers.js +86 -0
  31. package/lib/module/hooks/useConsumerScrollHandlers.js.map +1 -0
  32. package/lib/module/hooks/useHeaderMotionBridge.js +14 -0
  33. package/lib/module/hooks/useHeaderMotionBridge.js.map +1 -0
  34. package/lib/module/hooks/useMotionProgress.js +12 -42
  35. package/lib/module/hooks/useMotionProgress.js.map +1 -1
  36. package/lib/module/hooks/useMotionProgress.test.js +56 -0
  37. package/lib/module/hooks/useMotionProgress.test.js.map +1 -0
  38. package/lib/module/hooks/useScrollManager.js +168 -87
  39. package/lib/module/hooks/useScrollManager.js.map +1 -1
  40. package/lib/module/index.js +21 -18
  41. package/lib/module/index.js.map +1 -1
  42. package/lib/module/utils/defaults.js +2 -1
  43. package/lib/module/utils/defaults.js.map +1 -1
  44. package/lib/module/utils/header.js +24 -0
  45. package/lib/module/utils/header.js.map +1 -0
  46. package/lib/module/utils/headerOffsetStyle.js +31 -0
  47. package/lib/module/utils/headerOffsetStyle.js.map +1 -0
  48. package/lib/module/utils/index.js +2 -0
  49. package/lib/module/utils/index.js.map +1 -1
  50. package/lib/typescript/docs/docusaurus.config.d.ts +4 -0
  51. package/lib/typescript/docs/docusaurus.config.d.ts.map +1 -0
  52. package/lib/typescript/docs/sidebars.d.ts +4 -0
  53. package/lib/typescript/docs/sidebars.d.ts.map +1 -0
  54. package/lib/typescript/docs/src/pages/index.d.ts +2 -0
  55. package/lib/typescript/docs/src/pages/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/Bridge.d.ts +19 -0
  57. package/lib/typescript/src/components/Bridge.d.ts.map +1 -0
  58. package/lib/typescript/src/components/FlatList.d.ts +7 -15
  59. package/lib/typescript/src/components/FlatList.d.ts.map +1 -1
  60. package/lib/typescript/src/components/Header.d.ts +73 -12
  61. package/lib/typescript/src/components/Header.d.ts.map +1 -1
  62. package/lib/typescript/src/components/HeaderDynamic.d.ts +11 -0
  63. package/lib/typescript/src/components/HeaderDynamic.d.ts.map +1 -0
  64. package/lib/typescript/src/components/HeaderMotion.d.ts +38 -23
  65. package/lib/typescript/src/components/HeaderMotion.d.ts.map +1 -1
  66. package/lib/typescript/src/components/HeaderPanBoundary.d.ts +11 -0
  67. package/lib/typescript/src/components/HeaderPanBoundary.d.ts.map +1 -0
  68. package/lib/typescript/src/components/NavigationBridge.d.ts +19 -0
  69. package/lib/typescript/src/components/NavigationBridge.d.ts.map +1 -0
  70. package/lib/typescript/src/components/ScrollManager.d.ts +13 -9
  71. package/lib/typescript/src/components/ScrollManager.d.ts.map +1 -1
  72. package/lib/typescript/src/components/ScrollView.d.ts +7 -14
  73. package/lib/typescript/src/components/ScrollView.d.ts.map +1 -1
  74. package/lib/typescript/src/components/createHeaderMotionScrollable.d.ts +86 -0
  75. package/lib/typescript/src/components/createHeaderMotionScrollable.d.ts.map +1 -0
  76. package/lib/typescript/src/components/index.d.ts +3 -1
  77. package/lib/typescript/src/components/index.d.ts.map +1 -1
  78. package/lib/typescript/src/context.d.ts +3 -17
  79. package/lib/typescript/src/context.d.ts.map +1 -1
  80. package/lib/typescript/src/hooks/index.d.ts +1 -0
  81. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  82. package/lib/typescript/src/hooks/useActiveScrollId.d.ts +7 -6
  83. package/lib/typescript/src/hooks/useActiveScrollId.d.ts.map +1 -1
  84. package/lib/typescript/src/hooks/useConsumerScrollHandlers.d.ts +64 -0
  85. package/lib/typescript/src/hooks/useConsumerScrollHandlers.d.ts.map +1 -0
  86. package/lib/typescript/src/hooks/useHeaderMotionBridge.d.ts +10 -0
  87. package/lib/typescript/src/hooks/useHeaderMotionBridge.d.ts.map +1 -0
  88. package/lib/typescript/src/hooks/useMotionProgress.d.ts +8 -25
  89. package/lib/typescript/src/hooks/useMotionProgress.d.ts.map +1 -1
  90. package/lib/typescript/src/hooks/useMotionProgress.test.d.ts +2 -0
  91. package/lib/typescript/src/hooks/useMotionProgress.test.d.ts.map +1 -0
  92. package/lib/typescript/src/hooks/useScrollManager.d.ts +61 -29
  93. package/lib/typescript/src/hooks/useScrollManager.d.ts.map +1 -1
  94. package/lib/typescript/src/index.d.ts +56 -26
  95. package/lib/typescript/src/index.d.ts.map +1 -1
  96. package/lib/typescript/src/types.d.ts +54 -17
  97. package/lib/typescript/src/types.d.ts.map +1 -1
  98. package/lib/typescript/src/utils/defaults.d.ts +3 -2
  99. package/lib/typescript/src/utils/defaults.d.ts.map +1 -1
  100. package/lib/typescript/src/utils/header.d.ts +10 -0
  101. package/lib/typescript/src/utils/header.d.ts.map +1 -0
  102. package/lib/typescript/src/utils/headerOffsetStyle.d.ts +19 -0
  103. package/lib/typescript/src/utils/headerOffsetStyle.d.ts.map +1 -0
  104. package/lib/typescript/src/utils/index.d.ts +2 -0
  105. package/lib/typescript/src/utils/index.d.ts.map +1 -1
  106. package/lib/typescript/src/utils/refreshControl.d.ts +12 -12
  107. package/package.json +12 -5
  108. package/src/components/Bridge.tsx +29 -0
  109. package/src/components/FlatList.tsx +18 -76
  110. package/src/components/Header.tsx +159 -23
  111. package/src/components/HeaderDynamic.tsx +45 -0
  112. package/src/components/HeaderMotion.tsx +47 -50
  113. package/src/components/HeaderPanBoundary.tsx +92 -0
  114. package/src/components/NavigationBridge.tsx +30 -0
  115. package/src/components/ScrollManager.tsx +23 -11
  116. package/src/components/ScrollView.tsx +16 -60
  117. package/src/components/createHeaderMotionScrollable.tsx +438 -0
  118. package/src/components/index.ts +3 -1
  119. package/src/context.ts +11 -24
  120. package/src/hooks/index.ts +1 -0
  121. package/src/hooks/useActiveScrollId.ts +7 -6
  122. package/src/hooks/useConsumerScrollHandlers.ts +148 -0
  123. package/src/hooks/useHeaderMotionBridge.ts +15 -0
  124. package/src/hooks/useMotionProgress.test.ts +67 -0
  125. package/src/hooks/useMotionProgress.ts +12 -45
  126. package/src/hooks/useScrollManager.ts +251 -114
  127. package/src/index.ts +82 -36
  128. package/src/types.ts +81 -29
  129. package/src/utils/defaults.ts +7 -1
  130. package/src/utils/header.tsx +52 -0
  131. package/src/utils/headerOffsetStyle.ts +40 -0
  132. package/src/utils/index.ts +2 -0
  133. package/lib/module/components/HeaderBase.js +0 -107
  134. package/lib/module/components/HeaderBase.js.map +0 -1
  135. package/lib/typescript/src/components/HeaderBase.d.ts +0 -41
  136. package/lib/typescript/src/components/HeaderBase.d.ts.map +0 -1
  137. package/src/components/HeaderBase.tsx +0 -140
@@ -0,0 +1,67 @@
1
+ const mockUseHeaderMotionContextOrThrow = jest.fn();
2
+
3
+ jest.mock('../context', () => ({
4
+ __esModule: true,
5
+ useHeaderMotionContextOrThrow: (...args: any[]) =>
6
+ mockUseHeaderMotionContextOrThrow(...args),
7
+ }));
8
+
9
+ import { useHeaderMotionBridge } from './useHeaderMotionBridge';
10
+ import { useMotionProgress } from './useMotionProgress';
11
+
12
+ function createSharedValue<T>(value: T) {
13
+ return {
14
+ get: jest.fn(() => value),
15
+ set: jest.fn(),
16
+ value,
17
+ };
18
+ }
19
+
20
+ const bridgeValue = {
21
+ progress: createSharedValue(0),
22
+ progressThreshold: createSharedValue(120),
23
+ measureTotalHeight: jest.fn(),
24
+ measureDynamic: jest.fn(),
25
+ headerPanMomentumOffset: createSharedValue<number | null>(null),
26
+ scrollValues: createSharedValue({}),
27
+ activeScrollId: undefined,
28
+ scrollToRef: { current: null },
29
+ originalHeaderHeight: 0,
30
+ };
31
+
32
+ describe('motion hooks', () => {
33
+ beforeEach(() => {
34
+ mockUseHeaderMotionContextOrThrow.mockReset();
35
+ });
36
+
37
+ it('useMotionProgress returns only progress and progressThreshold', () => {
38
+ mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue);
39
+
40
+ expect(useMotionProgress()).toEqual({
41
+ progress: bridgeValue.progress,
42
+ progressThreshold: bridgeValue.progressThreshold,
43
+ });
44
+ expect(mockUseHeaderMotionContextOrThrow).toHaveBeenCalledWith(
45
+ 'useMotionProgress must be used within <HeaderMotion /> or <HeaderMotion.NavigationBridge />. If you are rendering inside a navigation header, bridge the context with <HeaderMotion.Bridge /> and <HeaderMotion.NavigationBridge />.'
46
+ );
47
+ });
48
+
49
+ it('useHeaderMotionBridge returns the full bridge value', () => {
50
+ mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue);
51
+
52
+ expect(useHeaderMotionBridge()).toBe(bridgeValue);
53
+ expect(mockUseHeaderMotionContextOrThrow).toHaveBeenCalledWith(
54
+ 'useHeaderMotionBridge must be used within <HeaderMotion />. Use it only when bridging context into a separate subtree with <HeaderMotion.Bridge /> and <HeaderMotion.NavigationBridge />.'
55
+ );
56
+ });
57
+
58
+ it('rethrows missing-context errors from the helper hook', () => {
59
+ const error = new Error('missing context');
60
+ mockUseHeaderMotionContextOrThrow.mockImplementation(() => {
61
+ throw error;
62
+ });
63
+
64
+ expect(() => useMotionProgress()).toThrow(error);
65
+ expect(() => useHeaderMotionBridge()).toThrow(error);
66
+ });
67
+ });
@@ -1,64 +1,31 @@
1
- import { useContext } from 'react';
2
- import { HeaderMotionContext } from '../context';
1
+ import { useHeaderMotionContextOrThrow } from '../context';
3
2
  import type { MotionProgress } from '../types';
4
3
 
5
4
  /**
6
- * Hook to access motion progress values and measuring functions for header animations.
7
- * Returns the progress value (0-1), threshold, and measurement functions.
5
+ * Returns the two shared values most header animations actually need:
6
+ * `progress` and `progressThreshold`.
8
7
  *
9
- * Must be used within a {@link HeaderMotion} component.
8
+ * Use this inside your animated header components to derive transforms,
9
+ * opacity, scale, parallax, or any other visual response to scroll.
10
10
  *
11
- * @returns Motion progress values and measuring functions:
12
- * - `progress`: Shared value from 0 to 1
13
- * - `progressThreshold`: The threshold at which animation completes
14
- * - `measureTotalHeight`: Function to measure total header height. Should be passed to the `onLayout` prop of the base of a header, to let scrollables account for the total header height
15
- * - `measureDynamic`: Function to measure a dimension of choice of the animated element of the header - should be passed to the `onLayout` prop of such. If used, can be used for dynamic calculation of the {@link progressThreshold}.
16
- *
17
- * @throws Error if used outside of a {@link HeaderMotion} component
11
+ * `progress` usually lives in the `0..1` range, where `0` is the expanded
12
+ * state and `1` is the fully collapsed state. `progressThreshold` is the pixel
13
+ * distance that corresponds to that transition.
18
14
  *
19
15
  * @example
20
16
  * ```tsx
21
17
  * function MyHeader() {
22
- * const { progress, progressThreshold, measureTotalHeight, measureDynamic } = useMotionProgress();
23
- * const dynamicStyle = useAnimatedStyle(() => {
24
- * const translateY = interpolate(
25
- * progress.value,
26
- * [0, 1],
27
- * [0, -progressThreshold],
28
- * Extrapolation.CLAMP,
29
- * )
30
- * return { transform: [{ translateY }] }
31
- * })
32
- * return (
33
- * <AnimatedHeaderBase onLayout={measureTotalHeight}>
34
- * <Animated.View onLayout={measureDynamic} style={dynamicStyle} />
35
- * </AnimatedHeaderBase>
36
- * )
18
+ * const { progress, progressThreshold } = useMotionProgress();
37
19
  * }
38
20
  * ```
39
21
  */
40
22
  export function useMotionProgress(): MotionProgress {
41
- const ctxValue = useContext(HeaderMotionContext);
42
- if (!ctxValue) {
43
- throw new Error(
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
- );
46
- }
47
- const {
48
- progress,
49
- measureTotalHeight,
50
- measureDynamic,
51
- progressThreshold,
52
- animatedHeaderBaseProps,
53
- activeScrollId,
54
- } = ctxValue;
23
+ const { progress, progressThreshold } = useHeaderMotionContextOrThrow(
24
+ 'useMotionProgress must be used within <HeaderMotion /> or <HeaderMotion.NavigationBridge />. If you are rendering inside a navigation header, bridge the context with <HeaderMotion.Bridge /> and <HeaderMotion.NavigationBridge />.'
25
+ );
55
26
 
56
27
  return {
57
28
  progress,
58
- measureTotalHeight,
59
- measureDynamic,
60
29
  progressThreshold,
61
- animatedHeaderBaseProps,
62
- activeScrollId,
63
30
  };
64
31
  }
@@ -1,18 +1,24 @@
1
- import { useContext, useCallback, useEffect } from 'react';
1
+ import {
2
+ useContext,
3
+ useCallback,
4
+ useEffect,
5
+ useState,
6
+ type ContextType,
7
+ } from 'react';
2
8
  import {
3
9
  cancelAnimation,
4
- measure,
5
10
  scrollTo,
6
11
  useAnimatedReaction,
7
12
  useAnimatedRef,
8
13
  useAnimatedScrollHandler,
9
- useAnimatedStyle,
14
+ useSharedValue,
10
15
  type AnimatedRef,
11
16
  type ScrollHandler,
12
17
  } from 'react-native-reanimated';
13
- import { RuntimeKind, scheduleOnUI } from 'react-native-worklets';
18
+ import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
14
19
  import { HeaderMotionContext } from '../context';
15
- import type { ScrollManagerConfig } from '../types';
20
+ import type { ScrollManagerConfig, ScrollHandlerContext } from '../types';
21
+ import type { LayoutChangeEvent } from 'react-native';
16
22
  import {
17
23
  resolveRefreshControl,
18
24
  DEFAULT_SCROLL_ID,
@@ -21,68 +27,33 @@ import {
21
27
  type ResolveRefreshControlOptions,
22
28
  } from '../utils';
23
29
  import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes';
24
-
25
- type ScrollHandlerContext = {
26
- lastOffset: number | undefined;
27
- };
30
+ import {
31
+ useConsumerScrollHandlers,
32
+ useScrollHandlerComposition,
33
+ type ConsumerScrollEventHandlers,
34
+ } from './useConsumerScrollHandlers';
28
35
 
29
36
  const SCROLL_TOLERANCE = 0.5;
30
37
 
31
- /**
32
- * Hook that manages scroll tracking and synchronization for header animations.
33
- * Returns props to apply to scrollable components and additional values that help with adjusting styling of the scrollables to header's dimensions.
34
- *
35
- * This hook handles:
36
- * - Scroll position tracking
37
- * - Synchronization between multiple scroll views (when using multiple scroll IDs)
38
- * - Content container minimum height calculations for cases where one of the tracked scrollables does not take enough space to reach the progress threshold/
39
- *
40
- * Must be used within a HeaderMotion component.
41
- *
42
- * @param scrollId - Optional unique identifier for the related scrollable.
43
- * Use when you have multiple scrollables (e.g., in tabs).
44
- * @param options - Optional configuration object.
45
- * @param options.animatedRef - Optional animated ref to use instead of creating one internally.
46
- * Useful when you need access to the scroll view ref from outside.
47
- * @returns Configuration object containing:
48
- * - `scrollableProps`: Props to apply to scrollable component (onScroll, scrollEventThrottle, ref)
49
- * - `headerMotionContext`: Header context values (originalHeaderHeight, minHeightContentContainerStyle)
50
- *
51
- * @throws Error if used outside of a HeaderMotion component
52
- *
53
- * @example
54
- * ```tsx
55
- * function CustomScrollComponent() {
56
- * const { scrollableProps, headerMotionContext } = useScrollManager('myScroll');
57
- *
58
- * return (
59
- * <CustomScrollView {...scrollableProps}>
60
- * <View style={{ paddingTop: headerMotionContext.originalHeaderHeight }}>
61
- * Content
62
- * </View>
63
- * </CustomScrollView>
64
- * );
65
- * }
66
- * ```
67
- */
68
- export interface UseScrollManagerOptions<TRef extends InstanceOrElement = any>
69
- extends Omit<ResolveRefreshControlOptions, 'progressViewOffset'> {
70
- /**
71
- * Optional animated ref to use instead of creating one internally.
72
- * Useful when you need access to the scroll view ref from outside.
73
- */
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'];
38
+ type ScrollManagerContextValue = NonNullable<
39
+ ContextType<typeof HeaderMotionContext>
40
+ >;
41
+
42
+ interface MinHeightOptions {
43
+ enabled: boolean;
80
44
  }
81
45
 
82
- export function useScrollManager<TRef extends InstanceOrElement = any>(
83
- scrollId?: string,
84
- options?: UseScrollManagerOptions<TRef>
85
- ): ScrollManagerConfig<TRef> {
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 {
86
57
  const ctxValue = useContext(HeaderMotionContext);
87
58
  if (!ctxValue) {
88
59
  throw new Error(
@@ -90,24 +61,71 @@ export function useScrollManager<TRef extends InstanceOrElement = any>(
90
61
  );
91
62
  }
92
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>) {
93
122
  const {
94
- scrollValues,
95
- progress,
96
123
  activeScrollId,
124
+ progress,
97
125
  progressThreshold,
98
- originalHeaderHeight,
99
126
  scrollToRef,
100
- headerPanMomentumOffset,
101
- } = ctxValue;
102
- const id = scrollId ?? DEFAULT_SCROLL_ID;
103
-
104
- const localRef = useAnimatedRef<TRef>();
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;
127
+ scrollValues,
128
+ } = useScrollManagerContext();
111
129
 
112
130
  useAnimatedReaction(
113
131
  () => activeScrollId?.get(),
@@ -137,13 +155,11 @@ export function useScrollManager<TRef extends InstanceOrElement = any>(
137
155
  });
138
156
  }, id);
139
157
  };
140
- }, [scrollValues, id]);
158
+ }, [id, scrollValues]);
141
159
 
142
160
  useAnimatedReaction(
143
161
  () => progress.value,
144
162
  (newProgress, oldProgress) => {
145
- // 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)
146
- // Only sync inactive scroll views when we have multiple tabs being tracked
147
163
  const currentActiveScrollId = activeScrollId?.get();
148
164
  if (
149
165
  !currentActiveScrollId ||
@@ -178,10 +194,26 @@ export function useScrollManager<TRef extends InstanceOrElement = any>(
178
194
  }
179
195
  }
180
196
  );
197
+ }
198
+
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);
181
211
 
182
- const onScroll = useCallback<ScrollHandler<ScrollHandlerContext>>(
212
+ const handleScroll = useCallback<ScrollHandler<ScrollHandlerContext>>(
183
213
  (e, ctx) => {
184
214
  'worklet';
215
+ onScroll?.(e);
216
+
185
217
  const newCurrent = e.contentOffset.y;
186
218
 
187
219
  if (
@@ -209,13 +241,6 @@ export function useScrollManager<TRef extends InstanceOrElement = any>(
209
241
  const oldMin = scrollValue.min;
210
242
  const isCollapsed = oldCurrent >= oldMin + threshold - 0.001;
211
243
 
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
244
  if (isCollapsed && newCurrent >= threshold) {
220
245
  scrollValue.current = newCurrent;
221
246
  scrollValue.min = newCurrent - threshold;
@@ -236,40 +261,152 @@ export function useScrollManager<TRef extends InstanceOrElement = any>(
236
261
  return value;
237
262
  });
238
263
  },
239
- [scrollValues, id, activeScrollId, progressThreshold]
264
+ [activeScrollId, id, onScroll, progressThreshold, scrollValues]
240
265
  );
241
266
 
242
- const onBeginDrag = useCallback<ScrollHandler<ScrollHandlerContext>>(() => {
243
- 'worklet';
244
- if (headerPanMomentumOffset.get() === null) {
245
- return;
246
- }
267
+ const handleBeginDrag = useCallback<ScrollHandler<ScrollHandlerContext>>(
268
+ (e) => {
269
+ 'worklet';
270
+ onBeginDrag?.(e);
271
+
272
+ if (headerPanMomentumOffset.get() === null) {
273
+ return;
274
+ }
247
275
 
248
- cancelAnimation(headerPanMomentumOffset);
249
- headerPanMomentumOffset.set(null);
250
- }, [headerPanMomentumOffset]);
276
+ cancelAnimation(headerPanMomentumOffset);
277
+ headerPanMomentumOffset.set(null);
278
+ },
279
+ [headerPanMomentumOffset, onBeginDrag]
280
+ );
251
281
 
252
- const animatedOnScroll = useAnimatedScrollHandler({
253
- onBeginDrag,
254
- onScroll,
282
+ return useAnimatedScrollHandler({
283
+ onBeginDrag: handleBeginDrag,
284
+ onScroll: handleScroll,
285
+ onEndDrag,
286
+ onMomentumBegin,
287
+ onMomentumEnd,
255
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
+ }
320
+
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;
256
379
 
257
- const minHeightContentContainerStyle = useAnimatedStyle(() => {
258
- const threshold = progressThreshold.get();
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;
259
387
 
260
- if (globalThis.__RUNTIME_KIND === RuntimeKind.ReactNative) {
261
- return {};
262
- }
388
+ const localRef = useAnimatedRef<TRef>();
389
+ const animatedRef = options?.animatedRef ?? localRef;
263
390
 
264
- const measurement = measure(animatedRef);
391
+ const { contentContainerMinHeight, handleLayout } =
392
+ useScrollManagerContentMinHeight({
393
+ enabled: ensureScrollableContentMinHeight,
394
+ });
265
395
 
266
- if (!measurement) {
267
- return {};
268
- }
396
+ useScrollManagerSynchronization({
397
+ id,
398
+ animatedRef,
399
+ });
269
400
 
270
- return {
271
- minHeight: measurement.height + threshold,
272
- };
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
+ },
273
410
  });
274
411
 
275
412
  const resolvedRefreshControl = resolveRefreshControl({
@@ -280,14 +417,14 @@ export function useScrollManager<TRef extends InstanceOrElement = any>(
280
417
  });
281
418
 
282
419
  const scrollableProps = {
283
- onScroll: animatedOnScroll,
284
- scrollEventThrottle: 16,
420
+ onScroll: useScrollHandlerComposition(animatedOnScroll, options?.onScroll),
421
+ onLayout: handleLayout,
285
422
  ref: animatedRef,
286
423
  refreshControl: resolvedRefreshControl,
287
424
  };
288
425
  const headerMotionContext = {
289
426
  originalHeaderHeight,
290
- minHeightContentContainerStyle,
427
+ contentContainerMinHeight,
291
428
  };
292
429
 
293
430
  return { scrollableProps, headerMotionContext };