react-native-header-motion 0.2.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 +126 -18
- package/lib/module/components/FlatList.js +12 -2
- 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 +10 -2
- package/lib/module/components/ScrollManager.js.map +1 -1
- package/lib/module/components/ScrollView.js +8 -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 +91 -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 +2 -4
- 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 -10
- package/lib/typescript/src/components/ScrollManager.d.ts.map +1 -1
- package/lib/typescript/src/components/ScrollView.d.ts +3 -3
- 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 +10 -3
- package/lib/typescript/src/hooks/useScrollManager.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +20 -4
- 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 +23 -9
- package/src/components/HeaderBase.tsx +93 -4
- package/src/components/HeaderMotion.tsx +102 -26
- package/src/components/ScrollManager.tsx +27 -17
- package/src/components/ScrollView.tsx +17 -3
- package/src/context.ts +9 -2
- package/src/hooks/useMotionProgress.ts +10 -2
- package/src/hooks/useScrollManager.ts +127 -35
- package/src/types.ts +22 -4
- package/src/utils/index.ts +1 -0
- package/src/utils/refreshControl.tsx +118 -0
- package/src/utils/values.ts +57 -1
|
@@ -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,29 +1,26 @@
|
|
|
1
|
-
import { useScrollManager } from '../hooks';
|
|
1
|
+
import { useScrollManager, type UseScrollManagerOptions } from '../hooks';
|
|
2
2
|
import type { ScrollManagerConfig } from '../types';
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes';
|
|
5
5
|
|
|
6
|
-
type ScrollManagerRenderChildren = (
|
|
7
|
-
scrollableProps: ScrollManagerConfig['scrollableProps'],
|
|
8
|
-
options: ScrollManagerConfig['headerMotionContext']
|
|
6
|
+
type ScrollManagerRenderChildren<TRef extends InstanceOrElement = any> = (
|
|
7
|
+
scrollableProps: ScrollManagerConfig<TRef>['scrollableProps'],
|
|
8
|
+
options: ScrollManagerConfig<TRef>['headerMotionContext']
|
|
9
9
|
) => ReactNode;
|
|
10
10
|
|
|
11
|
-
export interface HeaderMotionScrollManagerProps
|
|
11
|
+
export interface HeaderMotionScrollManagerProps<
|
|
12
|
+
TRef extends InstanceOrElement = any
|
|
13
|
+
> extends UseScrollManagerOptions<TRef> {
|
|
12
14
|
/**
|
|
13
15
|
* Optional unique identifier for this scroll view.
|
|
14
16
|
* Use this when you have multiple scroll views (e.g., in tabs) to track them separately.
|
|
15
17
|
*/
|
|
16
18
|
scrollId?: string;
|
|
17
|
-
/**
|
|
18
|
-
* Optional animated ref to use for the scroll view.
|
|
19
|
-
* When provided, the scroll manager will use this ref instead of creating its own.
|
|
20
|
-
*/
|
|
21
|
-
animatedRef?: AnimatedRef<any>;
|
|
22
19
|
/**
|
|
23
20
|
* Render function that receives scroll props and header context.
|
|
24
21
|
* Use this to create custom scroll implementations that integrate with HeaderMotion.
|
|
25
22
|
*/
|
|
26
|
-
children: ScrollManagerRenderChildren
|
|
23
|
+
children: ScrollManagerRenderChildren<TRef>;
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
/**
|
|
@@ -48,20 +45,33 @@ export interface HeaderMotionScrollManagerProps {
|
|
|
48
45
|
* </HeaderMotion>
|
|
49
46
|
* ```
|
|
50
47
|
*/
|
|
51
|
-
export function HeaderMotionScrollManager
|
|
48
|
+
export function HeaderMotionScrollManager<
|
|
49
|
+
TRef extends InstanceOrElement = any
|
|
50
|
+
>({
|
|
52
51
|
children,
|
|
53
52
|
scrollId,
|
|
54
53
|
animatedRef,
|
|
55
|
-
|
|
54
|
+
refreshControl,
|
|
55
|
+
refreshing,
|
|
56
|
+
onRefresh,
|
|
57
|
+
progressViewOffset,
|
|
58
|
+
}: HeaderMotionScrollManagerProps<TRef>) {
|
|
56
59
|
if (typeof children !== 'function') {
|
|
57
60
|
throw new Error(
|
|
58
61
|
'HeaderMotion.ScrollManager only accepts render function as the only child.'
|
|
59
62
|
);
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
const { scrollableProps, headerMotionContext } = useScrollManager(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
const { scrollableProps, headerMotionContext } = useScrollManager<TRef>(
|
|
66
|
+
scrollId,
|
|
67
|
+
{
|
|
68
|
+
animatedRef,
|
|
69
|
+
refreshControl,
|
|
70
|
+
refreshing,
|
|
71
|
+
onRefresh,
|
|
72
|
+
progressViewOffset,
|
|
73
|
+
}
|
|
74
|
+
);
|
|
65
75
|
|
|
66
76
|
return children(scrollableProps, headerMotionContext);
|
|
67
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
|
/**
|
|
@@ -36,18 +36,32 @@ export function HeaderMotionScrollView({
|
|
|
36
36
|
animatedRef,
|
|
37
37
|
children,
|
|
38
38
|
contentContainerStyle,
|
|
39
|
+
refreshControl,
|
|
39
40
|
...props
|
|
40
41
|
}: HeaderMotionScrollViewProps) {
|
|
41
42
|
return (
|
|
42
|
-
<HeaderMotionScrollManager
|
|
43
|
+
<HeaderMotionScrollManager
|
|
44
|
+
scrollId={scrollId}
|
|
45
|
+
animatedRef={animatedRef as AnimatedRef<Animated.ScrollView>}
|
|
46
|
+
refreshControl={refreshControl}
|
|
47
|
+
>
|
|
43
48
|
{(
|
|
44
|
-
{
|
|
49
|
+
{
|
|
50
|
+
onScroll,
|
|
51
|
+
ref,
|
|
52
|
+
refreshControl: managedRefreshControl,
|
|
53
|
+
...scrollViewProps
|
|
54
|
+
},
|
|
45
55
|
{ originalHeaderHeight, minHeightContentContainerStyle }
|
|
46
56
|
) => (
|
|
47
57
|
<Animated.ScrollView
|
|
48
58
|
{...scrollViewProps}
|
|
49
59
|
{...props}
|
|
60
|
+
ref={ref}
|
|
50
61
|
onScroll={onScroll}
|
|
62
|
+
{...(managedRefreshControl && {
|
|
63
|
+
refreshControl: managedRefreshControl,
|
|
64
|
+
})}
|
|
51
65
|
>
|
|
52
66
|
<Animated.View
|
|
53
67
|
style={[
|
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,8 +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 {
|
|
15
|
+
import type { ScrollManagerConfig } from '../types';
|
|
16
|
+
import {
|
|
17
|
+
resolveRefreshControl,
|
|
18
|
+
DEFAULT_SCROLL_ID,
|
|
19
|
+
ensureScrollValueRegistered,
|
|
20
|
+
warnIfMissingActiveScrollId,
|
|
21
|
+
type ResolveRefreshControlOptions,
|
|
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;
|
|
16
30
|
|
|
17
31
|
/**
|
|
18
32
|
* Hook that manages scroll tracking and synchronization for header animations.
|
|
@@ -51,18 +65,24 @@ import { DEFAULT_SCROLL_ID, getInitialScrollValue } from '../utils';
|
|
|
51
65
|
* }
|
|
52
66
|
* ```
|
|
53
67
|
*/
|
|
54
|
-
export interface UseScrollManagerOptions
|
|
68
|
+
export interface UseScrollManagerOptions<TRef extends InstanceOrElement = any>
|
|
69
|
+
extends Omit<ResolveRefreshControlOptions, 'progressViewOffset'> {
|
|
55
70
|
/**
|
|
56
71
|
* Optional animated ref to use instead of creating one internally.
|
|
57
72
|
* Useful when you need access to the scroll view ref from outside.
|
|
58
73
|
*/
|
|
59
|
-
animatedRef?: AnimatedRef<
|
|
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'];
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
export function useScrollManager(
|
|
82
|
+
export function useScrollManager<TRef extends InstanceOrElement = any>(
|
|
63
83
|
scrollId?: string,
|
|
64
|
-
options?: UseScrollManagerOptions
|
|
65
|
-
): ScrollManagerConfig {
|
|
84
|
+
options?: UseScrollManagerOptions<TRef>
|
|
85
|
+
): ScrollManagerConfig<TRef> {
|
|
66
86
|
const ctxValue = useContext(HeaderMotionContext);
|
|
67
87
|
if (!ctxValue) {
|
|
68
88
|
throw new Error(
|
|
@@ -76,11 +96,36 @@ export function useScrollManager(
|
|
|
76
96
|
activeScrollId,
|
|
77
97
|
progressThreshold,
|
|
78
98
|
originalHeaderHeight,
|
|
99
|
+
scrollToRef,
|
|
100
|
+
headerPanMomentumOffset,
|
|
79
101
|
} = ctxValue;
|
|
80
102
|
const id = scrollId ?? DEFAULT_SCROLL_ID;
|
|
81
103
|
|
|
82
|
-
const localRef = useAnimatedRef<
|
|
104
|
+
const localRef = useAnimatedRef<TRef>();
|
|
83
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;
|
|
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
|
+
);
|
|
84
129
|
|
|
85
130
|
useEffect(() => {
|
|
86
131
|
return () => {
|
|
@@ -108,25 +153,20 @@ export function useScrollManager(
|
|
|
108
153
|
return;
|
|
109
154
|
}
|
|
110
155
|
|
|
111
|
-
|
|
112
|
-
scrollValues.modify((value) => {
|
|
113
|
-
(value as ScrollValues)[id] = getInitialScrollValue();
|
|
114
|
-
return value;
|
|
115
|
-
});
|
|
116
|
-
}
|
|
156
|
+
ensureScrollValueRegistered(scrollValues, id);
|
|
117
157
|
|
|
118
158
|
let newCur = -1;
|
|
159
|
+
const threshold = progressThreshold.get();
|
|
119
160
|
|
|
120
161
|
scrollValues.modify((value) => {
|
|
121
|
-
|
|
162
|
+
const scrollValue = value[id];
|
|
122
163
|
if (!scrollValue) {
|
|
123
|
-
|
|
124
|
-
scrollValue = value[id]!;
|
|
164
|
+
return value;
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
const progressDiff = oldProgress - newProgress;
|
|
128
|
-
newCur = scrollValue.current - progressDiff *
|
|
129
|
-
const newMin = newCur - newProgress *
|
|
168
|
+
newCur = scrollValue.current - progressDiff * threshold;
|
|
169
|
+
const newMin = newCur - newProgress * threshold;
|
|
130
170
|
scrollValue.current = newCur;
|
|
131
171
|
scrollValue.min = newMin;
|
|
132
172
|
|
|
@@ -139,29 +179,58 @@ export function useScrollManager(
|
|
|
139
179
|
}
|
|
140
180
|
);
|
|
141
181
|
|
|
142
|
-
const
|
|
143
|
-
(e) => {
|
|
182
|
+
const onScroll = useCallback<ScrollHandler<ScrollHandlerContext>>(
|
|
183
|
+
(e, ctx) => {
|
|
144
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
|
+
}
|
|
145
224
|
|
|
146
225
|
scrollValues.modify((value) => {
|
|
147
226
|
if (!value[id]) {
|
|
148
227
|
return value;
|
|
149
228
|
}
|
|
150
229
|
|
|
151
|
-
const activeScrollIdValue = activeScrollId?.get();
|
|
152
|
-
if (activeScrollIdValue && activeScrollIdValue !== id) {
|
|
153
|
-
return value;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const oldCurrent = value[id].current;
|
|
157
|
-
const oldMin = value[id].min;
|
|
158
|
-
const isCollapsed = oldCurrent >= oldMin + progressThreshold - 0.001;
|
|
159
|
-
|
|
160
|
-
const newCurrent = e.contentOffset.y;
|
|
161
230
|
value[id].current = newCurrent;
|
|
162
231
|
|
|
163
232
|
if (isCollapsed) {
|
|
164
|
-
value[id].min = Math.max(0, newCurrent -
|
|
233
|
+
value[id].min = Math.max(0, newCurrent - threshold);
|
|
165
234
|
}
|
|
166
235
|
|
|
167
236
|
return value;
|
|
@@ -170,9 +239,24 @@ export function useScrollManager(
|
|
|
170
239
|
[scrollValues, id, activeScrollId, progressThreshold]
|
|
171
240
|
);
|
|
172
241
|
|
|
173
|
-
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
|
+
});
|
|
174
256
|
|
|
175
257
|
const minHeightContentContainerStyle = useAnimatedStyle(() => {
|
|
258
|
+
const threshold = progressThreshold.get();
|
|
259
|
+
|
|
176
260
|
if (globalThis.__RUNTIME_KIND === RuntimeKind.ReactNative) {
|
|
177
261
|
return {};
|
|
178
262
|
}
|
|
@@ -184,14 +268,22 @@ export function useScrollManager(
|
|
|
184
268
|
}
|
|
185
269
|
|
|
186
270
|
return {
|
|
187
|
-
minHeight: measurement.height +
|
|
271
|
+
minHeight: measurement.height + threshold,
|
|
188
272
|
};
|
|
189
273
|
});
|
|
190
274
|
|
|
275
|
+
const resolvedRefreshControl = resolveRefreshControl({
|
|
276
|
+
refreshControl,
|
|
277
|
+
refreshing,
|
|
278
|
+
onRefresh,
|
|
279
|
+
progressViewOffset,
|
|
280
|
+
});
|
|
281
|
+
|
|
191
282
|
const scrollableProps = {
|
|
192
|
-
onScroll,
|
|
283
|
+
onScroll: animatedOnScroll,
|
|
193
284
|
scrollEventThrottle: 16,
|
|
194
285
|
ref: animatedRef,
|
|
286
|
+
refreshControl: resolvedRefreshControl,
|
|
195
287
|
};
|
|
196
288
|
const headerMotionContext = {
|
|
197
289
|
originalHeaderHeight,
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
1
2
|
import type { LayoutChangeEvent, ScrollViewProps } from 'react-native';
|
|
2
3
|
import type { AnimatedRef, SharedValue } from 'react-native-reanimated';
|
|
3
4
|
import { DEFAULT_SCROLL_ID } from './utils/defaults';
|
|
5
|
+
import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes';
|
|
4
6
|
|
|
5
7
|
export type Progress = SharedValue<number>;
|
|
6
8
|
|
|
@@ -38,13 +40,21 @@ export type WithCollapsiblePagedHeaderProps<
|
|
|
38
40
|
|
|
39
41
|
export interface MotionProgress {
|
|
40
42
|
progress: Progress;
|
|
41
|
-
progressThreshold: number
|
|
43
|
+
progressThreshold: SharedValue<number>;
|
|
42
44
|
measureTotalHeight: MeasureAnimatedHeaderAndSet;
|
|
43
45
|
measureDynamic: MeasureAnimatedHeaderAndSet;
|
|
46
|
+
animatedHeaderBaseProps: AnimatedHeaderBaseMotionProps;
|
|
47
|
+
activeScrollId: SharedValue<string> | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AnimatedHeaderBaseMotionProps {
|
|
51
|
+
enableHeaderPan: boolean;
|
|
52
|
+
scrollToRef: React.RefObject<ScrollTo | null>;
|
|
53
|
+
headerPanMomentumOffset: SharedValue<number | null>;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export interface ScrollManagerHeaderMotionContext {
|
|
47
|
-
originalHeaderHeight: number
|
|
57
|
+
originalHeaderHeight: SharedValue<number>;
|
|
48
58
|
minHeightContentContainerStyle:
|
|
49
59
|
| {}
|
|
50
60
|
| {
|
|
@@ -52,11 +62,19 @@ export interface ScrollManagerHeaderMotionContext {
|
|
|
52
62
|
};
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
export interface ScrollManagerConfig {
|
|
65
|
+
export interface ScrollManagerConfig<TRef extends InstanceOrElement = any> {
|
|
56
66
|
scrollableProps: Required<
|
|
57
67
|
Pick<ScrollViewProps, 'onScroll' | 'scrollEventThrottle'>
|
|
58
68
|
> & {
|
|
59
|
-
|
|
69
|
+
refreshControl?: ReactElement;
|
|
70
|
+
ref: AnimatedRef<TRef>;
|
|
60
71
|
};
|
|
61
72
|
headerMotionContext: ScrollManagerHeaderMotionContext;
|
|
62
73
|
}
|
|
74
|
+
|
|
75
|
+
export type ScrollTo = (y: number, options?: ScrollToOptions) => void;
|
|
76
|
+
|
|
77
|
+
interface ScrollToOptions {
|
|
78
|
+
isValueDelta?: boolean;
|
|
79
|
+
animated?: boolean;
|
|
80
|
+
}
|