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.
- package/README.md +400 -335
- package/lib/module/components/Bridge.js +16 -0
- package/lib/module/components/Bridge.js.map +1 -0
- package/lib/module/components/FlatList.js +5 -62
- package/lib/module/components/FlatList.js.map +1 -1
- package/lib/module/components/Header.js +71 -13
- package/lib/module/components/Header.js.map +1 -1
- package/lib/module/components/HeaderDynamic.js +34 -0
- package/lib/module/components/HeaderDynamic.js.map +1 -0
- package/lib/module/components/HeaderMotion.js +59 -23
- package/lib/module/components/HeaderMotion.js.map +1 -1
- package/lib/module/components/HeaderPanBoundary.js +54 -0
- package/lib/module/components/HeaderPanBoundary.js.map +1 -0
- package/lib/module/components/NavigationBridge.js +20 -0
- package/lib/module/components/NavigationBridge.js.map +1 -0
- package/lib/module/components/ScrollManager.js +7 -5
- package/lib/module/components/ScrollManager.js.map +1 -1
- package/lib/module/components/ScrollView.js +6 -47
- package/lib/module/components/ScrollView.js.map +1 -1
- package/lib/module/components/createHeaderMotionScrollable.js +136 -0
- package/lib/module/components/createHeaderMotionScrollable.js.map +1 -0
- package/lib/module/components/index.js +3 -1
- package/lib/module/components/index.js.map +1 -1
- package/lib/module/context.js +8 -1
- package/lib/module/context.js.map +1 -1
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/useActiveScrollId.js +7 -6
- package/lib/module/hooks/useActiveScrollId.js.map +1 -1
- package/lib/module/hooks/useHeaderMotionBridge.js +14 -0
- package/lib/module/hooks/useHeaderMotionBridge.js.map +1 -0
- package/lib/module/hooks/useMotionProgress.js +10 -36
- package/lib/module/hooks/useMotionProgress.js.map +1 -1
- package/lib/module/hooks/useMotionProgress.test.js +56 -0
- package/lib/module/hooks/useMotionProgress.test.js.map +1 -0
- package/lib/module/hooks/useScrollManager.js +219 -109
- package/lib/module/hooks/useScrollManager.js.map +1 -1
- package/lib/module/index.js +21 -18
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/defaults.js +2 -1
- package/lib/module/utils/defaults.js.map +1 -1
- package/lib/module/utils/header.js +24 -0
- package/lib/module/utils/header.js.map +1 -0
- package/lib/module/utils/headerOffsetStyle.js +31 -0
- package/lib/module/utils/headerOffsetStyle.js.map +1 -0
- package/lib/module/utils/index.js +3 -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/Bridge.d.ts +19 -0
- package/lib/typescript/src/components/Bridge.d.ts.map +1 -0
- package/lib/typescript/src/components/FlatList.d.ts +7 -15
- package/lib/typescript/src/components/FlatList.d.ts.map +1 -1
- package/lib/typescript/src/components/Header.d.ts +73 -12
- package/lib/typescript/src/components/Header.d.ts.map +1 -1
- package/lib/typescript/src/components/HeaderDynamic.d.ts +11 -0
- package/lib/typescript/src/components/HeaderDynamic.d.ts.map +1 -0
- package/lib/typescript/src/components/HeaderMotion.d.ts +37 -18
- package/lib/typescript/src/components/HeaderMotion.d.ts.map +1 -1
- package/lib/typescript/src/components/HeaderPanBoundary.d.ts +11 -0
- package/lib/typescript/src/components/HeaderPanBoundary.d.ts.map +1 -0
- package/lib/typescript/src/components/NavigationBridge.d.ts +19 -0
- package/lib/typescript/src/components/NavigationBridge.d.ts.map +1 -0
- package/lib/typescript/src/components/ScrollManager.d.ts +18 -25
- package/lib/typescript/src/components/ScrollManager.d.ts.map +1 -1
- package/lib/typescript/src/components/ScrollView.d.ts +7 -14
- package/lib/typescript/src/components/ScrollView.d.ts.map +1 -1
- package/lib/typescript/src/components/createHeaderMotionScrollable.d.ts +86 -0
- package/lib/typescript/src/components/createHeaderMotionScrollable.d.ts.map +1 -0
- package/lib/typescript/src/components/index.d.ts +3 -1
- package/lib/typescript/src/components/index.d.ts.map +1 -1
- package/lib/typescript/src/context.d.ts +3 -13
- package/lib/typescript/src/context.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +1 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useActiveScrollId.d.ts +7 -6
- package/lib/typescript/src/hooks/useActiveScrollId.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useHeaderMotionBridge.d.ts +10 -0
- package/lib/typescript/src/hooks/useHeaderMotionBridge.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useMotionProgress.d.ts +8 -25
- package/lib/typescript/src/hooks/useMotionProgress.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useMotionProgress.test.d.ts +2 -0
- package/lib/typescript/src/hooks/useMotionProgress.test.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useScrollManager.d.ts +63 -31
- package/lib/typescript/src/hooks/useScrollManager.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +56 -26
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +63 -15
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/lib/typescript/src/utils/defaults.d.ts +3 -2
- package/lib/typescript/src/utils/defaults.d.ts.map +1 -1
- package/lib/typescript/src/utils/header.d.ts +10 -0
- package/lib/typescript/src/utils/header.d.ts.map +1 -0
- package/lib/typescript/src/utils/headerOffsetStyle.d.ts +19 -0
- package/lib/typescript/src/utils/headerOffsetStyle.d.ts.map +1 -0
- package/lib/typescript/src/utils/index.d.ts +3 -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 +13 -5
- package/src/components/Bridge.tsx +29 -0
- package/src/components/FlatList.tsx +18 -84
- package/src/components/Header.tsx +159 -23
- package/src/components/HeaderDynamic.tsx +45 -0
- package/src/components/HeaderMotion.tsx +114 -41
- package/src/components/HeaderPanBoundary.tsx +92 -0
- package/src/components/NavigationBridge.tsx +30 -0
- package/src/components/ScrollManager.tsx +38 -43
- package/src/components/ScrollView.tsx +16 -68
- package/src/components/createHeaderMotionScrollable.tsx +438 -0
- package/src/components/index.ts +3 -1
- package/src/context.ts +12 -18
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useActiveScrollId.ts +7 -6
- package/src/hooks/useHeaderMotionBridge.ts +15 -0
- package/src/hooks/useMotionProgress.test.ts +67 -0
- package/src/hooks/useMotionProgress.ts +12 -37
- package/src/hooks/useScrollManager.ts +310 -129
- package/src/index.ts +82 -36
- package/src/types.ts +85 -25
- package/src/utils/defaults.ts +7 -1
- package/src/utils/header.tsx +52 -0
- package/src/utils/headerOffsetStyle.ts +40 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/refreshControl.tsx +118 -0
- package/src/utils/values.ts +57 -1
- package/lib/module/components/HeaderBase.js +0 -59
- package/lib/module/components/HeaderBase.js.map +0 -1
- package/lib/module/hooks/refreshControl.js +0 -31
- package/lib/module/hooks/refreshControl.js.map +0 -1
- package/lib/typescript/src/components/HeaderBase.d.ts +0 -34
- package/lib/typescript/src/components/HeaderBase.d.ts.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/components/HeaderBase.tsx +0 -51
- package/src/hooks/refreshControl.ts +0 -55
|
@@ -1,30 +1,166 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
67
|
+
* Reads the value that should define the "collapsible" part of the header.
|
|
36
68
|
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
*
|
|
78
|
+
* Controls when `measureDynamic` is allowed to update.
|
|
79
|
+
*
|
|
46
80
|
* - 'mount': Only measure once on mount
|
|
47
|
-
* - 'update':
|
|
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
|
|
53
|
-
*
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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
|
-
/**
|
|
107
|
+
/** Descendants that should participate in the shared header-motion state. */
|
|
65
108
|
children: ReactNode;
|
|
66
109
|
}
|
|
67
110
|
|
|
68
111
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
143
|
+
if (prevMeasurement === measured) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
94
146
|
|
|
95
|
-
|
|
96
|
-
|
|
147
|
+
dynamicMeasurement.set(measured);
|
|
148
|
+
const nextThreshold =
|
|
149
|
+
typeof progressThreshold === 'number'
|
|
150
|
+
? progressThreshold
|
|
151
|
+
: progressThreshold(measured);
|
|
152
|
+
progressThresholdValue.set(nextThreshold);
|
|
97
153
|
},
|
|
98
|
-
[
|
|
154
|
+
[
|
|
155
|
+
measureDynamicMode,
|
|
156
|
+
measureDynamic,
|
|
157
|
+
dynamicMeasurement,
|
|
158
|
+
progressThreshold,
|
|
159
|
+
progressThresholdValue,
|
|
160
|
+
]
|
|
99
161
|
);
|
|
100
162
|
|
|
101
|
-
|
|
163
|
+
useEffect(() => {
|
|
102
164
|
if (typeof progressThreshold === 'number') {
|
|
103
|
-
|
|
165
|
+
progressThresholdValue.set(progressThreshold);
|
|
166
|
+
return;
|
|
104
167
|
}
|
|
105
168
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}, [dynamicMeasurement,
|
|
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
|
|
140
|
-
const
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|