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,30 +1,166 @@
1
- import { useMotionProgress } from '../hooks/useMotionProgress';
2
- import type { MotionProgress } from '../types';
3
- import type { ReactNode } from 'react';
1
+ import { StyleSheet, type ViewProps } from 'react-native';
2
+ import Animated from 'react-native-reanimated';
3
+ import { useHeaderMotionContextOrThrow } from '../context';
4
+ import type {
5
+ HeaderAsChildProps,
6
+ HeaderDefaultProps,
7
+ HeaderPanDecayConfig,
8
+ } from '../types';
9
+ import {
10
+ cloneWithOnLayout,
11
+ composeOnLayoutHandlers,
12
+ resolveSlottableChild,
13
+ } from '../utils';
14
+ import { HeaderDynamic } from './HeaderDynamic';
15
+ import { HeaderPanBoundary } from './HeaderPanBoundary';
4
16
 
5
- type HeaderRenderChildren = (props: MotionProgress) => ReactNode;
17
+ type HeaderPanProps =
18
+ | {
19
+ /** Enables dragging the header itself to scroll the active scrollable.
20
+ *
21
+ * This is useful when the header covers a large portion of the screen
22
+ * and you want the gesture to feel continuous between header and content.
23
+ *
24
+ * @default false
25
+ */
26
+ pannable: true;
27
+ /**
28
+ * Customizes the momentum animation that runs after a header pan ends.
29
+ *
30
+ * Use an object for a fixed decay profile. Use a function when the decay
31
+ * should depend on the end event, for example to dampen or amplify
32
+ * certain velocities.
33
+ *
34
+ * If you provide a function, it runs inside the gesture end worklet and
35
+ * **must itself be marked with the 'worklet' directive.**
36
+ */
37
+ panDecayConfig?: HeaderPanDecayConfig;
38
+ }
39
+ | {
40
+ pannable?: false | undefined;
41
+ panDecayConfig?: never;
42
+ };
6
43
 
7
- export interface HeaderMotionHeaderProps {
8
- /**
9
- * Render function that receives motion progress props.
10
- * Use this to animate your header based on scroll progress and to provide measurement functions to the elements of the header.
11
- */
12
- children: HeaderRenderChildren;
13
- }
44
+ export type HeaderProps =
45
+ | (HeaderDefaultProps &
46
+ HeaderPanProps & {
47
+ /**
48
+ * Applies the default absolute-positioned header layout.
49
+ *
50
+ * Leave this enabled for navigation headers and any header that should
51
+ * visually float above the scrollable content. Disable it only when you
52
+ * intentionally want the header to participate in normal layout flow.
53
+ *
54
+ * @default true
55
+ */
56
+ overlay?: boolean;
57
+ /**
58
+ * Wraps the pan gesture in `GestureHandlerRootView`.
59
+ *
60
+ * Only use this when the rendered header subtree is not already under a
61
+ * gesture-handler root.
62
+ *
63
+ * @default false
64
+ */
65
+ withGestureHandlerRootView?: boolean;
66
+ })
67
+ | (HeaderAsChildProps &
68
+ HeaderPanProps & {
69
+ /**
70
+ * Wraps the pan gesture in `GestureHandlerRootView`.
71
+ *
72
+ * Only use this when the rendered header subtree is not already under a
73
+ * gesture-handler root.
74
+ *
75
+ * @default false
76
+ */
77
+ withGestureHandlerRootView?: boolean;
78
+ });
14
79
 
15
- /**
16
- * Header component for providing motion progress properties to animated headers.
17
- * Must be used within a HeaderMotion component.
18
- *
19
- * Use to pass props to the header components in React Navigation / Expo Router, which cannot access HeaderMotion's context and `useMotionProgress` otherwise.`
20
- */
21
- export function HeaderMotionHeader({ children }: HeaderMotionHeaderProps) {
22
- if (typeof children !== 'function') {
23
- throw new Error(
24
- 'HeaderMotion.Header only accepts render function as the only child.'
80
+ const headerOverlayStyle = StyleSheet.create({
81
+ overlay: {
82
+ position: 'absolute',
83
+ top: 0,
84
+ left: 0,
85
+ right: 0,
86
+ },
87
+ }).overlay;
88
+
89
+ function HeaderRoot(props: HeaderProps) {
90
+ const ctxValue = useHeaderMotionContextOrThrow(
91
+ 'HeaderMotion.Header 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 />.'
92
+ );
93
+
94
+ if (props.asChild) {
95
+ const child = resolveSlottableChild('HeaderMotion.Header', props.children);
96
+
97
+ return (
98
+ <HeaderPanBoundary
99
+ pannable={props.pannable}
100
+ panDecayConfig={props.panDecayConfig}
101
+ headerPanMomentumOffset={ctxValue.headerPanMomentumOffset}
102
+ scrollToRef={ctxValue.scrollToRef}
103
+ withGestureHandlerRootView={props.withGestureHandlerRootView}
104
+ >
105
+ {cloneWithOnLayout(
106
+ child,
107
+ ctxValue.measureTotalHeight,
108
+ 'HeaderMotion.Header'
109
+ )}
110
+ </HeaderPanBoundary>
25
111
  );
26
112
  }
27
113
 
28
- const motionProgressProps = useMotionProgress();
29
- return children(motionProgressProps);
114
+ const {
115
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
116
+ asChild: _asChild,
117
+ overlay = true,
118
+ pannable,
119
+ panDecayConfig,
120
+ onLayout,
121
+ style,
122
+ withGestureHandlerRootView,
123
+ ...rest
124
+ } = props;
125
+ const resolvedOnLayout = onLayout as ViewProps['onLayout'] | undefined;
126
+
127
+ return (
128
+ <HeaderPanBoundary
129
+ pannable={pannable}
130
+ panDecayConfig={panDecayConfig}
131
+ headerPanMomentumOffset={ctxValue.headerPanMomentumOffset}
132
+ scrollToRef={ctxValue.scrollToRef}
133
+ withGestureHandlerRootView={withGestureHandlerRootView}
134
+ >
135
+ <Animated.View
136
+ {...rest}
137
+ onLayout={composeOnLayoutHandlers(
138
+ resolvedOnLayout,
139
+ ctxValue.measureTotalHeight
140
+ )}
141
+ style={[overlay ? headerOverlayStyle : undefined, style]}
142
+ />
143
+ </HeaderPanBoundary>
144
+ );
30
145
  }
146
+
147
+ /**
148
+ * Header container that measures the total header height for scroll offsetting.
149
+ *
150
+ * It renders an `Animated.View` by default, wires the outer header measurement
151
+ * automatically, and can optionally make the header surface pannable.
152
+ *
153
+ * Pair it with `Header.Dynamic` to mark the part of the header that should
154
+ * drive the collapse threshold.
155
+ */
156
+ export const Header = Object.assign(HeaderRoot, {
157
+ /**
158
+ * Marks the part of the header whose measured layout should define the
159
+ * collapsible distance.
160
+ *
161
+ * In most designs, this is the section that visually disappears while the
162
+ * header collapses. Its measured value feeds `measureDynamic`, which can in
163
+ * turn drive `progressThreshold`.
164
+ */
165
+ Dynamic: HeaderDynamic,
166
+ });
@@ -0,0 +1,45 @@
1
+ import type { ViewProps } from 'react-native';
2
+ import Animated from 'react-native-reanimated';
3
+ import { useHeaderMotionContextOrThrow } from '../context';
4
+ import type { HeaderDynamicProps } from '../types';
5
+ import {
6
+ cloneWithOnLayout,
7
+ composeOnLayoutHandlers,
8
+ resolveSlottableChild,
9
+ } from '../utils';
10
+
11
+ /**
12
+ * Marks the part of the header whose layout should define the collapsible
13
+ * distance.
14
+ *
15
+ * In most designs, this is the section that visually disappears while the
16
+ * header collapses. Its measured value feeds `measureDynamic`, which in turn
17
+ * can drive `progressThreshold`.
18
+ */
19
+ export function HeaderDynamic(props: HeaderDynamicProps) {
20
+ const ctxValue = useHeaderMotionContextOrThrow(
21
+ 'HeaderMotion.Header.Dynamic 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 />.'
22
+ );
23
+
24
+ if (props.asChild) {
25
+ return cloneWithOnLayout(
26
+ resolveSlottableChild('HeaderMotion.Header.Dynamic', props.children),
27
+ ctxValue.measureDynamic,
28
+ 'HeaderMotion.Header.Dynamic'
29
+ );
30
+ }
31
+
32
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
33
+ const { asChild: _asChild, onLayout, ...rest } = props;
34
+ const resolvedOnLayout = onLayout as ViewProps['onLayout'] | undefined;
35
+
36
+ return (
37
+ <Animated.View
38
+ {...rest}
39
+ onLayout={composeOnLayoutHandlers(
40
+ resolvedOnLayout,
41
+ ctxValue.measureDynamic
42
+ )}
43
+ />
44
+ );
45
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useState } from 'react';
1
+ import { useCallback, useRef, useEffect, useMemo, useState } 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,51 +24,96 @@ 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
- * The threshold at which the header animation completes (reaches progress = 1).
29
- * Can be a fixed number or a function that calculates based on the result of {@link measureDynamic}.
55
+ * Distance that maps the active scrollable from `progress = 0`
56
+ * to `progress = 1`.
57
+ *
58
+ * Use a number when the collapse distance is fixed. Use a function when the
59
+ * distance should depend on what `measureDynamic` reads from
60
+ * `HeaderMotion.Header.Dynamic`.
30
61
  *
31
- * Defaults to a function that returns the return value of `measureDynamic` unchanged.
62
+ * A common pattern is to measure the height of the part of the header that
63
+ * should disappear and use that as the threshold.
32
64
  */
33
65
  progressThreshold?: ProgressThreshold;
34
66
  /**
35
- * Function to measure a dimension of choice of the animated element of the header.
67
+ * Reads the value that should define the "collapsible" part of the header.
36
68
  *
37
- * Receives the layout change event from React Native.
69
+ * This is called from `HeaderMotion.Header.Dynamic` on layout. The returned
70
+ * number feeds `progressThreshold` when you provide that prop as a function.
38
71
  *
39
- * This function can be further accessed when rendering headers from `HeaderMotion.Header` or `useMotionProgress` - should be passed to the `onLayout` prop of such. If used, can be used for dynamic calculation of the {@link progressThreshold}.
40
- *
41
- * Defaults to measuring the height from the event.
72
+ * By default, the library measures the dynamic section's height. Override
73
+ * this when the collapse distance should be based on something else, for
74
+ * example width or a derived value from the layout event.
42
75
  */
43
76
  measureDynamic?: MeasureAnimatedHeader;
44
77
  /**
45
- * Mode for measuring dynamic header height.
78
+ * Controls when `measureDynamic` is allowed to update.
79
+ *
46
80
  * - 'mount': Only measure once on mount
47
- * - 'update': Update measurement on every layout recalculation of the component that {@link measureDynamic} was provided to as the `onLayout` property
81
+ * - 'update': Re-measure whenever `HeaderMotion.Header.Dynamic` lays out again
82
+ *
83
+ * Use `'mount'` for stable headers. Use `'update'` when the dynamic section
84
+ * can change size after mount, for example after async data loads or content
85
+ * expansion.
86
+ *
48
87
  * @default 'mount'
49
88
  */
50
89
  measureDynamicMode?: 'update' | 'mount';
51
90
  /**
52
- * Shared value for tracking the active scroll ID in multi-scroll scenarios (e.g. tabs).
53
- * When provided, the header animation will sync across multiple scroll views.
91
+ * Shared value that tells HeaderMotion which scrollable currently owns the
92
+ * header progress in multi-scroll setups.
93
+ *
94
+ * Pass this when one header is shared across multiple scrollables, such as
95
+ * tabs or pager pages. Each scrollable should also get its own `scrollId`.
54
96
  */
55
97
  activeScrollId?: SharedValue<T>;
56
98
  /**
57
- * Extrapolation type for the progress animation.
58
- * Controls how the progress value behaves outside the threshold range.
99
+ * Controls how `progress` behaves outside the `[0, threshold]` range.
100
+ *
101
+ * The default clamps the value between `0` and `1`. Relax this if you want
102
+ * to animate overscroll or other out-of-range states.
59
103
  *
60
- * You may want to modify it to achieve some animations for the overscroll scenarios.
61
104
  * @default Extrapolation.CLAMP
62
105
  */
63
106
  progressExtrapolation?: ExtrapolationType;
64
- /** Child components that will have access to the header motion context */
107
+ /** Descendants that should participate in the shared header-motion state. */
65
108
  children: ReactNode;
66
109
  }
67
110
 
68
111
  /**
69
- * Context provider component for HeaderMotion.
70
- * Manages header animation state and provides it to child components via context.
112
+ * Root provider for a header-motion setup.
113
+ *
114
+ * It tracks the measured header layout, the active scroll position, and the
115
+ * derived `progress` shared value consumed by your animated header UI.
116
+ *
71
117
  * @template T - The type of scroll ID string
72
118
  */
73
119
  function HeaderMotionContextProvider<T extends string>({
@@ -78,36 +124,53 @@ function HeaderMotionContextProvider<T extends string>({
78
124
  progressExtrapolation = Extrapolation.CLAMP,
79
125
  children,
80
126
  }: HeaderMotionProps<T>) {
81
- const [dynamicMeasurement, setDynamicMeasurement] = useState<
82
- number | undefined
83
- >(undefined);
127
+ const dynamicMeasurement = useSharedValue<number | undefined>(undefined);
84
128
  const [originalHeaderHeight, setOriginalHeaderHeight] = useState(0);
129
+ const progressThresholdValue = useSharedValue(
130
+ typeof progressThreshold === 'number' ? progressThreshold : Infinity
131
+ );
132
+ const headerPanMomentumOffset = useSharedValue<number | null>(null);
85
133
 
86
134
  const setOrUpdateDynamicMeasurement =
87
135
  useCallback<MeasureAnimatedHeaderAndSet>(
88
136
  (e) => {
137
+ const prevMeasurement = dynamicMeasurement.get();
138
+ if (prevMeasurement !== undefined && measureDynamicMode === 'mount') {
139
+ return;
140
+ }
141
+
89
142
  const measured = measureDynamic(e);
90
- setDynamicMeasurement((prevMeasurement) => {
91
- if (prevMeasurement !== undefined && measureDynamicMode === 'mount') {
92
- return prevMeasurement;
93
- }
143
+ if (prevMeasurement === measured) {
144
+ return;
145
+ }
94
146
 
95
- return measured;
96
- });
147
+ dynamicMeasurement.set(measured);
148
+ const nextThreshold =
149
+ typeof progressThreshold === 'number'
150
+ ? progressThreshold
151
+ : progressThreshold(measured);
152
+ progressThresholdValue.set(nextThreshold);
97
153
  },
98
- [measureDynamicMode, measureDynamic, setDynamicMeasurement]
154
+ [
155
+ measureDynamicMode,
156
+ measureDynamic,
157
+ dynamicMeasurement,
158
+ progressThreshold,
159
+ progressThresholdValue,
160
+ ]
99
161
  );
100
162
 
101
- const calculatedProgressThreshold = useMemo(() => {
163
+ useEffect(() => {
102
164
  if (typeof progressThreshold === 'number') {
103
- return progressThreshold;
165
+ progressThresholdValue.set(progressThreshold);
166
+ return;
104
167
  }
105
168
 
106
- if (dynamicMeasurement === undefined) {
107
- return Infinity;
108
- }
109
- return progressThreshold(dynamicMeasurement);
110
- }, [dynamicMeasurement, progressThreshold]);
169
+ const measured = dynamicMeasurement.get();
170
+ const nextThreshold =
171
+ measured === undefined ? Infinity : progressThreshold(measured);
172
+ progressThresholdValue.set(nextThreshold);
173
+ }, [progressThreshold, dynamicMeasurement, progressThresholdValue]);
111
174
 
112
175
  const measureTotalHeight = useCallback<MeasureAnimatedHeaderAndSet>(
113
176
  (e) => {
@@ -136,8 +199,10 @@ function HeaderMotionContextProvider<T extends string>({
136
199
  );
137
200
 
138
201
  const progress = useDerivedValue(() => {
139
- const id = activeScrollId?.get() ?? DEFAULT_SCROLL_ID;
140
- const scrollValue = scrollValues.get()[id];
202
+ const values = scrollValues.get();
203
+ const id = resolveScrollIdForProgress(values, activeScrollId?.get());
204
+ const scrollValue = values[id];
205
+ const threshold = progressThresholdValue.get();
141
206
 
142
207
  if (!scrollValue) {
143
208
  return 0;
@@ -146,30 +211,38 @@ function HeaderMotionContextProvider<T extends string>({
146
211
  const { min, current } = scrollValue;
147
212
  return interpolate(
148
213
  current,
149
- [min, min + calculatedProgressThreshold],
214
+ [min, min + threshold],
150
215
  [0, 1],
151
216
  progressExtrapolation
152
217
  );
153
218
  });
154
219
 
220
+ const scrollToRef = useRef<ScrollTo>(null);
221
+ // FUTURE: SharedValue-based scrollTo was removed for now because function updates
222
+ // were not propagating reliably, while it works for refs. Revisit later.
223
+ // We need to be updating the scrollTo on active scroll ID changes and doing it via state would cause re-renders.
224
+ // 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.
155
225
  const ctxValue = useMemo(
156
226
  () => ({
157
227
  progress,
158
228
  originalHeaderHeight,
159
229
  measureDynamic: setOrUpdateDynamicMeasurement,
160
230
  measureTotalHeight,
161
- progressThreshold: calculatedProgressThreshold,
231
+ headerPanMomentumOffset,
232
+ progressThreshold: progressThresholdValue,
162
233
  scrollValues,
234
+ scrollToRef,
163
235
  activeScrollId: activeScrollId as SharedValue<string> | undefined,
164
236
  }),
165
237
  [
166
238
  originalHeaderHeight,
167
239
  progress,
168
240
  measureTotalHeight,
241
+ headerPanMomentumOffset,
169
242
  setOrUpdateDynamicMeasurement,
170
243
  scrollValues,
171
244
  activeScrollId,
172
- calculatedProgressThreshold,
245
+ progressThresholdValue,
173
246
  ]
174
247
  );
175
248
 
@@ -0,0 +1,92 @@
1
+ import { useMemo, type ReactElement } from 'react';
2
+ import { Platform } from 'react-native';
3
+ import {
4
+ Gesture,
5
+ GestureDetector,
6
+ GestureHandlerRootView,
7
+ } from 'react-native-gesture-handler';
8
+ import { useAnimatedReaction, withDecay } from 'react-native-reanimated';
9
+ import type {
10
+ HeaderPanDecayConfig,
11
+ HeaderPanDecayEvent,
12
+ HeaderMotionBridgeValue,
13
+ } from '../types';
14
+
15
+ const PLATFORM_PANNING_ENABLED = Platform.select({
16
+ default: true,
17
+ android: false,
18
+ });
19
+
20
+ type HeaderPanBoundaryProps = Pick<
21
+ HeaderMotionBridgeValue,
22
+ 'scrollToRef' | 'headerPanMomentumOffset'
23
+ > & {
24
+ children: ReactElement;
25
+ pannable?: boolean;
26
+ panDecayConfig?: HeaderPanDecayConfig;
27
+ withGestureHandlerRootView?: boolean;
28
+ };
29
+
30
+ export function HeaderPanBoundary({
31
+ children,
32
+ pannable = false,
33
+ panDecayConfig,
34
+ scrollToRef,
35
+ headerPanMomentumOffset,
36
+ withGestureHandlerRootView = false,
37
+ }: HeaderPanBoundaryProps) {
38
+ useAnimatedReaction(
39
+ () => headerPanMomentumOffset.get(),
40
+ (offset, prevOffset) => {
41
+ if (offset !== null) {
42
+ const dy = offset - (prevOffset ?? 0);
43
+ scrollToRef.current?.(dy);
44
+ }
45
+ }
46
+ );
47
+
48
+ const isPanEnabled = PLATFORM_PANNING_ENABLED && pannable;
49
+
50
+ const pan = useMemo(
51
+ () =>
52
+ Gesture.Pan()
53
+ .enabled(isPanEnabled)
54
+ .onChange((e) => {
55
+ const dy = e.changeY;
56
+ scrollToRef.current?.(dy);
57
+ })
58
+ .onEnd((e) => {
59
+ const resolvedConfig = resolvePanDecayConfig(panDecayConfig, e);
60
+ headerPanMomentumOffset.set(
61
+ withDecay(resolvedConfig, () => headerPanMomentumOffset.set(null))
62
+ );
63
+ })
64
+ .shouldCancelWhenOutside(false),
65
+ [headerPanMomentumOffset, isPanEnabled, panDecayConfig, scrollToRef]
66
+ );
67
+
68
+ const content = <GestureDetector gesture={pan}>{children}</GestureDetector>;
69
+
70
+ if (!withGestureHandlerRootView) {
71
+ return content;
72
+ }
73
+
74
+ return <GestureHandlerRootView>{content}</GestureHandlerRootView>;
75
+ }
76
+
77
+ function resolvePanDecayConfig(
78
+ panDecayConfig: HeaderPanDecayConfig | undefined,
79
+ event: HeaderPanDecayEvent
80
+ ) {
81
+ 'worklet';
82
+
83
+ const resolvedConfig =
84
+ typeof panDecayConfig === 'function'
85
+ ? panDecayConfig(event)
86
+ : panDecayConfig;
87
+
88
+ return {
89
+ ...resolvedConfig,
90
+ velocity: resolvedConfig?.velocity ?? event.velocityY,
91
+ };
92
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react';
2
+ import { HeaderMotionContext } from '../context';
3
+ import type { HeaderMotionBridgeValue } from '../types';
4
+
5
+ export interface HeaderMotionNavigationBridgeProps {
6
+ /**
7
+ * Previously captured HeaderMotion context value to re-provide in another
8
+ * subtree.
9
+ */
10
+ value: HeaderMotionBridgeValue;
11
+ /** Subtree that should regain access to HeaderMotion context. */
12
+ children: ReactNode;
13
+ }
14
+
15
+ /**
16
+ * Re-provides HeaderMotion context in a different part of the React tree.
17
+ *
18
+ * This is primarily useful for navigation libraries that render headers outside
19
+ * the screen subtree where `HeaderMotion` itself lives.
20
+ */
21
+ export function NavigationBridge({
22
+ value,
23
+ children,
24
+ }: HeaderMotionNavigationBridgeProps) {
25
+ return (
26
+ <HeaderMotionContext.Provider value={value}>
27
+ {children}
28
+ </HeaderMotionContext.Provider>
29
+ );
30
+ }