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.
- package/README.md +75 -25
- package/lib/module/components/FlatList.js +2 -0
- package/lib/module/components/FlatList.js.map +1 -1
- package/lib/module/components/HeaderBase.js +53 -5
- package/lib/module/components/HeaderBase.js.map +1 -1
- package/lib/module/components/HeaderMotion.js +66 -24
- package/lib/module/components/HeaderMotion.js.map +1 -1
- package/lib/module/components/ScrollManager.js.map +1 -1
- package/lib/module/components/ScrollView.js +2 -0
- package/lib/module/components/ScrollView.js.map +1 -1
- package/lib/module/context.js.map +1 -1
- package/lib/module/hooks/useMotionProgress.js +6 -2
- package/lib/module/hooks/useMotionProgress.js.map +1 -1
- package/lib/module/hooks/useScrollManager.js +79 -29
- package/lib/module/hooks/useScrollManager.js.map +1 -1
- package/lib/module/utils/index.js +1 -0
- package/lib/module/utils/index.js.map +1 -1
- package/lib/module/utils/refreshControl.js +93 -0
- package/lib/module/utils/refreshControl.js.map +1 -0
- package/lib/module/utils/values.js +36 -0
- package/lib/module/utils/values.js.map +1 -1
- package/lib/typescript/src/components/FlatList.d.ts +1 -1
- package/lib/typescript/src/components/FlatList.d.ts.map +1 -1
- package/lib/typescript/src/components/HeaderBase.d.ts +9 -2
- package/lib/typescript/src/components/HeaderBase.d.ts.map +1 -1
- package/lib/typescript/src/components/HeaderMotion.d.ts +5 -1
- package/lib/typescript/src/components/HeaderMotion.d.ts.map +1 -1
- package/lib/typescript/src/components/ScrollManager.d.ts +6 -16
- package/lib/typescript/src/components/ScrollManager.d.ts.map +1 -1
- package/lib/typescript/src/components/ScrollView.d.ts +2 -2
- package/lib/typescript/src/components/ScrollView.d.ts.map +1 -1
- package/lib/typescript/src/context.d.ts +7 -3
- package/lib/typescript/src/context.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useMotionProgress.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useScrollManager.d.ts +5 -4
- package/lib/typescript/src/hooks/useScrollManager.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +20 -6
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/lib/typescript/src/utils/index.d.ts +1 -0
- package/lib/typescript/src/utils/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/refreshControl.d.ts +150 -0
- package/lib/typescript/src/utils/refreshControl.d.ts.map +1 -0
- package/lib/typescript/src/utils/values.d.ts +4 -1
- package/lib/typescript/src/utils/values.d.ts.map +1 -1
- package/package.json +7 -5
- package/src/components/FlatList.tsx +9 -3
- package/src/components/HeaderBase.tsx +93 -4
- package/src/components/HeaderMotion.tsx +102 -26
- package/src/components/ScrollManager.tsx +23 -28
- package/src/components/ScrollView.tsx +9 -3
- package/src/context.ts +9 -2
- package/src/hooks/useMotionProgress.ts +10 -2
- package/src/hooks/useScrollManager.ts +105 -36
- package/src/types.ts +22 -10
- package/src/utils/index.ts +1 -0
- package/src/utils/refreshControl.tsx +118 -0
- package/src/utils/values.ts +57 -1
- package/lib/module/hooks/refreshControl.js +0 -31
- package/lib/module/hooks/refreshControl.js.map +0 -1
- package/lib/typescript/src/hooks/refreshControl.d.ts +0 -13
- package/lib/typescript/src/hooks/refreshControl.d.ts.map +0 -1
- package/src/hooks/refreshControl.ts +0 -55
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback,
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
129
|
+
if (prevMeasurement === measured) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
94
132
|
|
|
95
|
-
|
|
96
|
-
|
|
133
|
+
dynamicMeasurement.set(measured);
|
|
134
|
+
progressThresholdValue.set(
|
|
135
|
+
typeof progressThreshold === 'number'
|
|
136
|
+
? progressThreshold
|
|
137
|
+
: progressThreshold(measured)
|
|
138
|
+
);
|
|
97
139
|
},
|
|
98
|
-
[
|
|
140
|
+
[
|
|
141
|
+
measureDynamicMode,
|
|
142
|
+
measureDynamic,
|
|
143
|
+
dynamicMeasurement,
|
|
144
|
+
progressThreshold,
|
|
145
|
+
progressThresholdValue,
|
|
146
|
+
]
|
|
99
147
|
);
|
|
100
148
|
|
|
101
|
-
|
|
149
|
+
useEffect(() => {
|
|
102
150
|
if (typeof progressThreshold === 'number') {
|
|
103
|
-
|
|
151
|
+
progressThresholdValue.set(progressThreshold);
|
|
152
|
+
return;
|
|
104
153
|
}
|
|
105
154
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}, [dynamicMeasurement,
|
|
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
|
-
|
|
164
|
+
if (originalHeaderHeight.get() === measuredValue) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
originalHeaderHeight.set(measuredValue);
|
|
116
169
|
},
|
|
117
|
-
[
|
|
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
|
|
140
|
-
const
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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<
|
|
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
|
-
{
|
|
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 {
|
|
48
|
-
|
|
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
|
|
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 '
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
const scrollValue = value[id];
|
|
137
163
|
if (!scrollValue) {
|
|
138
|
-
|
|
139
|
-
scrollValue = value[id]!;
|
|
164
|
+
return value;
|
|
140
165
|
}
|
|
141
166
|
|
|
142
167
|
const progressDiff = oldProgress - newProgress;
|
|
143
|
-
newCur = scrollValue.current - progressDiff *
|
|
144
|
-
const newMin = newCur - newProgress *
|
|
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
|
|
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 -
|
|
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
|
|
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 +
|
|
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,
|