@react-navigation/lynx 0.3.0 → 0.4.0-canary-20260909-fa1c06ba
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/package.json +2 -2
- package/src/stack/types.ts +14 -0
- package/src/stack/views/LynxStackView.tsx +62 -17
- package/src/stack/views/SheetScreen.tsx +120 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-navigation/lynx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-canary-20260909-fa1c06ba",
|
|
4
4
|
"description": "Lynx integration for React Navigation",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"escape-string-regexp": "^5.0.0",
|
|
36
|
-
"@react-navigation/core": "^8.0.0-alpha.
|
|
36
|
+
"@react-navigation/core": "^8.0.0-alpha.34"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@lynx-js/react": "*",
|
package/src/stack/types.ts
CHANGED
|
@@ -16,11 +16,25 @@ export type LynxStackPresentation = 'card' | 'formSheet';
|
|
|
16
16
|
export type LynxStackNavigationOptions = {
|
|
17
17
|
presentation?: LynxStackPresentation | undefined;
|
|
18
18
|
contentStyle?: Lynx.CSSProperties | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Heights the sheet can rest at, as fractions of the screen, or
|
|
21
|
+
* `'fitToContents'` to measure the content. `formSheet` only, as is every
|
|
22
|
+
* option below. Names follow `@react-navigation/native-stack`.
|
|
23
|
+
*/
|
|
24
|
+
sheetAllowedDetents?: number[] | 'fitToContents' | undefined;
|
|
25
|
+
sheetInitialDetentIndex?: number | 'last' | undefined;
|
|
26
|
+
/** Detents up to this one leave the content behind the sheet undimmed. */
|
|
27
|
+
sheetLargestUndimmedDetentIndex?: number | 'none' | 'last' | undefined;
|
|
28
|
+
sheetGrabberVisible?: boolean | undefined;
|
|
29
|
+
sheetCornerRadius?: number | 'systemDefault' | undefined;
|
|
30
|
+
sheetExpandsWhenScrolledToEdge?: boolean | undefined;
|
|
19
31
|
};
|
|
20
32
|
|
|
21
33
|
export type LynxStackNavigationEventMap = {
|
|
22
34
|
transitionStart: { data: { closing: boolean } };
|
|
23
35
|
transitionEnd: { data: { closing: boolean } };
|
|
36
|
+
/** The detent a `formSheet` settled at, as an index into its detents. */
|
|
37
|
+
sheetDetentChange: { data: { index: number } };
|
|
24
38
|
};
|
|
25
39
|
|
|
26
40
|
export type LynxStackNavigationProp<
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
LynxStackNavigationHelpers,
|
|
12
12
|
} from '../types';
|
|
13
13
|
import { CardScreen } from './CardScreen';
|
|
14
|
+
import { SheetScreen } from './SheetScreen';
|
|
14
15
|
import {
|
|
15
16
|
type LynxStackViewState,
|
|
16
17
|
type LynxStackViewStateAction,
|
|
@@ -44,7 +45,13 @@ function LynxStackViewContent({
|
|
|
44
45
|
dispatch({ type: 'REMOVE_POPPED_ROUTE', key });
|
|
45
46
|
};
|
|
46
47
|
|
|
47
|
-
const onNativeDismiss = (
|
|
48
|
+
const onNativeDismiss = ({
|
|
49
|
+
key,
|
|
50
|
+
markNativelyDismissed,
|
|
51
|
+
}: {
|
|
52
|
+
key: string;
|
|
53
|
+
markNativelyDismissed: boolean;
|
|
54
|
+
}) => {
|
|
48
55
|
const currentState = navigation.getState();
|
|
49
56
|
const index = currentState.routes.findIndex((route) => route.key === key);
|
|
50
57
|
|
|
@@ -59,13 +66,16 @@ function LynxStackViewContent({
|
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
// The native side has already taken these screens off the stack, so the
|
|
62
|
-
// reducer must not keep them rendered waiting for a pop animation.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
// reducer must not keep them rendered waiting for a pop animation. A sheet
|
|
70
|
+
// whose dismissal was prevented is the exception: it is still there.
|
|
71
|
+
if (markNativelyDismissed) {
|
|
72
|
+
dispatch({
|
|
73
|
+
type: 'ADD_NATIVELY_DISMISSED_ROUTES',
|
|
74
|
+
keys: currentState.routes
|
|
75
|
+
.slice(index, currentState.index + 1)
|
|
76
|
+
.map((route) => route.key),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
69
79
|
|
|
70
80
|
navigation.dispatch({
|
|
71
81
|
...StackActions.pop(dismissCount),
|
|
@@ -86,7 +96,12 @@ function LynxStackViewContent({
|
|
|
86
96
|
});
|
|
87
97
|
};
|
|
88
98
|
|
|
89
|
-
const cards
|
|
99
|
+
const cards: ReactElement[] = [];
|
|
100
|
+
// The native sheet is its own host, so these render outside the stack host
|
|
101
|
+
// rather than as screens in it.
|
|
102
|
+
const sheets: ReactElement[] = [];
|
|
103
|
+
|
|
104
|
+
renderedRoutes.forEach((route) => {
|
|
90
105
|
const index = routeIndexByKey.get(route.key);
|
|
91
106
|
const popped = poppedByKey.get(route.key);
|
|
92
107
|
const descriptor = descriptors[route.key] ?? popped?.descriptor;
|
|
@@ -99,13 +114,38 @@ function LynxStackViewContent({
|
|
|
99
114
|
|
|
100
115
|
const presentation = descriptor.options.presentation ?? 'card';
|
|
101
116
|
|
|
102
|
-
if (presentation !== 'card') {
|
|
117
|
+
if (presentation !== 'card' && presentation !== 'formSheet') {
|
|
103
118
|
throw new Error(
|
|
104
|
-
`The route '${route.name}' uses the '${presentation}' presentation, which the Lynx stack does not support
|
|
119
|
+
`The route '${route.name}' uses the '${presentation}' presentation, which the Lynx stack does not support. Only 'card' and 'formSheet' are available.`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (presentation === 'formSheet') {
|
|
124
|
+
if (index === 0) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`The route '${route.name}' cannot use the 'formSheet' presentation because it is the first route in the stack. Add a screen with the 'card' presentation before it.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
sheets.push(
|
|
131
|
+
<SheetScreen
|
|
132
|
+
key={route.key}
|
|
133
|
+
descriptor={descriptor}
|
|
134
|
+
navigation={navigation}
|
|
135
|
+
isFocused={index === state.index}
|
|
136
|
+
isPopped={popped != null}
|
|
137
|
+
onRemovePoppedRoute={onRemovePoppedRoute}
|
|
138
|
+
onNativeDismiss={(markNativelyDismissed) =>
|
|
139
|
+
onNativeDismiss({ key: route.key, markNativelyDismissed })
|
|
140
|
+
}
|
|
141
|
+
onNativeDismissPrevented={() => onNativeDismissPrevented(route.key)}
|
|
142
|
+
/>
|
|
105
143
|
);
|
|
144
|
+
|
|
145
|
+
return;
|
|
106
146
|
}
|
|
107
147
|
|
|
108
|
-
|
|
148
|
+
cards.push(
|
|
109
149
|
<CardScreen
|
|
110
150
|
key={route.key}
|
|
111
151
|
descriptor={descriptor}
|
|
@@ -115,15 +155,20 @@ function LynxStackViewContent({
|
|
|
115
155
|
isPopped={popped != null}
|
|
116
156
|
isDetached={index != null && index > state.index}
|
|
117
157
|
onRemovePoppedRoute={onRemovePoppedRoute}
|
|
118
|
-
onNativeDismiss={() =>
|
|
158
|
+
onNativeDismiss={() =>
|
|
159
|
+
onNativeDismiss({ key: route.key, markNativelyDismissed: true })
|
|
160
|
+
}
|
|
119
161
|
onNativeDismissPrevented={() => onNativeDismissPrevented(route.key)}
|
|
120
162
|
/>
|
|
121
163
|
);
|
|
164
|
+
});
|
|
122
165
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
166
|
+
return (
|
|
167
|
+
<>
|
|
168
|
+
<StackHostNativeComponent>{cards}</StackHostNativeComponent>
|
|
169
|
+
{sheets}
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
127
172
|
}
|
|
128
173
|
|
|
129
174
|
export function LynxStackView({ state, navigation, descriptors }: Props) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NavigationProvider,
|
|
3
|
+
usePreventRemoveContext,
|
|
4
|
+
} from '@react-navigation/core';
|
|
5
|
+
import { FormSheetNativeComponent } from 'lynx-screens';
|
|
6
|
+
|
|
7
|
+
import type { LynxStackDescriptor, LynxStackNavigationHelpers } from '../types';
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
descriptor: LynxStackDescriptor;
|
|
11
|
+
navigation: LynxStackNavigationHelpers;
|
|
12
|
+
isFocused: boolean;
|
|
13
|
+
isPopped: boolean;
|
|
14
|
+
onRemovePoppedRoute: (key: string) => void;
|
|
15
|
+
onNativeDismiss: (markNativelyDismissed: boolean) => void;
|
|
16
|
+
onNativeDismissPrevented: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A `formSheet` route. The native sheet is its own host rather than a screen
|
|
21
|
+
* inside the stack, so this renders as a sibling of the stack host and opens
|
|
22
|
+
* and closes with focus instead of taking an activity mode.
|
|
23
|
+
*/
|
|
24
|
+
export function SheetScreen({
|
|
25
|
+
descriptor,
|
|
26
|
+
navigation,
|
|
27
|
+
isFocused,
|
|
28
|
+
isPopped,
|
|
29
|
+
onRemovePoppedRoute,
|
|
30
|
+
onNativeDismiss,
|
|
31
|
+
onNativeDismissPrevented,
|
|
32
|
+
}: Props) {
|
|
33
|
+
const { preventedRoutes } = usePreventRemoveContext();
|
|
34
|
+
|
|
35
|
+
const { route, options } = descriptor;
|
|
36
|
+
const { contentStyle } = options;
|
|
37
|
+
|
|
38
|
+
// Prevention comes from `usePreventRemove` and nothing else, for the reason
|
|
39
|
+
// `CardScreen` spells out: a static option would have the native side block
|
|
40
|
+
// the gesture and the `onNativeDismissPrevented` round-trip pop the route
|
|
41
|
+
// anyway, since only a `beforeRemove` listener can cancel that dispatch.
|
|
42
|
+
const isRemovePrevented = preventedRoutes[route.key]?.preventRemove === true;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<FormSheetNativeComponent
|
|
46
|
+
isOpen={isFocused}
|
|
47
|
+
detents={options.sheetAllowedDetents}
|
|
48
|
+
initialDetentIndex={options.sheetInitialDetentIndex}
|
|
49
|
+
largestUndimmedDetentIndex={options.sheetLargestUndimmedDetentIndex}
|
|
50
|
+
prefersGrabberVisible={options.sheetGrabberVisible}
|
|
51
|
+
preferredCornerRadius={options.sheetCornerRadius}
|
|
52
|
+
prefersScrollingExpandsWhenScrolledToEdge={
|
|
53
|
+
options.sheetExpandsWhenScrolledToEdge
|
|
54
|
+
}
|
|
55
|
+
preventNativeDismiss={isRemovePrevented}
|
|
56
|
+
nativeContainerStyle={{ backgroundColor: contentStyle?.backgroundColor }}
|
|
57
|
+
onWillAppear={() =>
|
|
58
|
+
navigation.emit({
|
|
59
|
+
type: 'transitionStart',
|
|
60
|
+
data: { closing: false },
|
|
61
|
+
target: route.key,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
onDidAppear={() =>
|
|
65
|
+
navigation.emit({
|
|
66
|
+
type: 'transitionEnd',
|
|
67
|
+
data: { closing: false },
|
|
68
|
+
target: route.key,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
onWillDisappear={() =>
|
|
72
|
+
navigation.emit({
|
|
73
|
+
type: 'transitionStart',
|
|
74
|
+
data: { closing: true },
|
|
75
|
+
target: route.key,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
onDidDisappear={() => {
|
|
79
|
+
navigation.emit({
|
|
80
|
+
type: 'transitionEnd',
|
|
81
|
+
data: { closing: true },
|
|
82
|
+
target: route.key,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The route stays rendered while the sheet animates out, which is what
|
|
86
|
+
// gives it something to animate. This is where it finally goes.
|
|
87
|
+
if (isPopped) {
|
|
88
|
+
onRemovePoppedRoute(route.key);
|
|
89
|
+
}
|
|
90
|
+
}}
|
|
91
|
+
onDetentChanged={(index) =>
|
|
92
|
+
navigation.emit({
|
|
93
|
+
type: 'sheetDetentChange',
|
|
94
|
+
data: { index },
|
|
95
|
+
target: route.key,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
onNativeDismiss={() => onNativeDismiss(!isRemovePrevented)}
|
|
99
|
+
onNativeDismissPrevented={onNativeDismissPrevented}
|
|
100
|
+
>
|
|
101
|
+
<NavigationProvider navigation={descriptor.navigation} route={route}>
|
|
102
|
+
<view
|
|
103
|
+
style={{
|
|
104
|
+
display: 'flex',
|
|
105
|
+
flexDirection: 'column',
|
|
106
|
+
width: '100%',
|
|
107
|
+
// `fitToContents` measures the content, so it must not be told to
|
|
108
|
+
// fill the sheet.
|
|
109
|
+
...(options.sheetAllowedDetents === 'fitToContents'
|
|
110
|
+
? null
|
|
111
|
+
: { height: '100%' }),
|
|
112
|
+
...contentStyle,
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
{descriptor.render()}
|
|
116
|
+
</view>
|
|
117
|
+
</NavigationProvider>
|
|
118
|
+
</FormSheetNativeComponent>
|
|
119
|
+
);
|
|
120
|
+
}
|