@momo-kits/foundation 0.164.1-beta.1 → 0.164.1-beta.10
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.
|
@@ -153,7 +153,7 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
153
153
|
initialParams,
|
|
154
154
|
bottomTab: 'nested',
|
|
155
155
|
}}
|
|
156
|
-
options={{ ...getStackOptions() }}
|
|
156
|
+
options={{ ...getStackOptions({ swipeBack: false }) }}
|
|
157
157
|
/>
|
|
158
158
|
</Stack.Navigator>
|
|
159
159
|
</Animated.View>
|
|
@@ -188,7 +188,7 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
188
188
|
initialParams,
|
|
189
189
|
bottomTab: 'default',
|
|
190
190
|
}}
|
|
191
|
-
options={{ ...getStackOptions(), ...options }}
|
|
191
|
+
options={{ ...getStackOptions({ swipeBack: false }), ...options }}
|
|
192
192
|
/>
|
|
193
193
|
</Stack.Navigator>
|
|
194
194
|
</NavigationContainer>
|
|
@@ -16,7 +16,7 @@ import { DeviceEventEmitter } from 'react-native';
|
|
|
16
16
|
import StackScreen from './StackScreen';
|
|
17
17
|
import ModalScreen from './ModalScreen';
|
|
18
18
|
import Navigator from './Navigator';
|
|
19
|
-
import { getModalOptions, getStackOptions } from './utils';
|
|
19
|
+
import { getModalOptions, getStackOptions, useNativeCanGoBack } from './utils';
|
|
20
20
|
import { NavigationContainerProps } from './types';
|
|
21
21
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
22
22
|
import Localize from './Localize';
|
|
@@ -93,6 +93,7 @@ const Navigation: React.FC<any> = ({
|
|
|
93
93
|
const navigator = useRef(new Navigator(navigationRef, isReady));
|
|
94
94
|
const [showGrid, setShowGrid] = useState(false);
|
|
95
95
|
const insets = useSafeAreaInsets();
|
|
96
|
+
const syncCanGoBack = useNativeCanGoBack();
|
|
96
97
|
|
|
97
98
|
let headerBackground = context?.designSystemConfig?.config?.headerBar;
|
|
98
99
|
let headerGradient = theme.colors?.gradient;
|
|
@@ -192,7 +193,9 @@ const Navigation: React.FC<any> = ({
|
|
|
192
193
|
ref={navigationRef}
|
|
193
194
|
onReady={() => {
|
|
194
195
|
isReady.current = true;
|
|
196
|
+
syncCanGoBack(navigationRef.current?.getRootState?.());
|
|
195
197
|
}}
|
|
198
|
+
onStateChange={syncCanGoBack}
|
|
196
199
|
>
|
|
197
200
|
<Stack.Navigator
|
|
198
201
|
initialRouteName="Stack"
|
|
@@ -6,7 +6,7 @@ import Navigation from './Navigation';
|
|
|
6
6
|
import { ApplicationContext, MiniAppContext, ScreenContext } from '../Context';
|
|
7
7
|
import { GridSystem } from '../Layout';
|
|
8
8
|
import { version } from '../package.json';
|
|
9
|
-
import { useAppState } from './utils';
|
|
9
|
+
import { SwipeBackHaptic, useAppState } from './utils';
|
|
10
10
|
import { TooltipPortalHost, TooltipPortalProvider } from './TooltipPortal';
|
|
11
11
|
|
|
12
12
|
const runAfterInteractions = InteractionManager.runAfterInteractions;
|
|
@@ -413,6 +413,9 @@ const StackScreen: React.FC<any> = props => {
|
|
|
413
413
|
<Component heightHeader={heightHeader} {...data} />
|
|
414
414
|
{showGrid && <GridSystem />}
|
|
415
415
|
<TooltipPortalHost />
|
|
416
|
+
{/* a leaf on purpose: it subscribes to CardAnimationContext, which changes on every
|
|
417
|
+
Card render, and must not pull this screen into those renders */}
|
|
418
|
+
<SwipeBackHaptic />
|
|
416
419
|
</TooltipPortalProvider>
|
|
417
420
|
</ScreenContext.Provider>
|
|
418
421
|
);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
3
3
|
import Navigator from './Navigator';
|
|
4
|
-
import ScaleSizeProvider from './ScaleSizeProvider';
|
|
5
4
|
import { WidgetContainerProps } from './types';
|
|
6
5
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
7
6
|
import Localize from './Localize';
|
package/Application/utils.tsx
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
} from 'react';
|
|
2
8
|
import {
|
|
9
|
+
CardAnimationContext,
|
|
10
|
+
CardStyleInterpolators,
|
|
3
11
|
StackNavigationOptions,
|
|
4
12
|
TransitionPresets,
|
|
5
13
|
} from '@react-navigation/stack';
|
|
14
|
+
import type {
|
|
15
|
+
StackCardInterpolatedStyle,
|
|
16
|
+
StackCardInterpolationProps,
|
|
17
|
+
} from '@react-navigation/stack';
|
|
18
|
+
import type { NavigationState, PartialState } from '@react-navigation/native';
|
|
6
19
|
import type { HeaderTitleProps, NavigationOptions } from './types';
|
|
20
|
+
import { ApplicationContext } from '../Context';
|
|
7
21
|
import { Colors, Spacing } from '../Consts';
|
|
8
|
-
import { Animated, AppState, Platform } from 'react-native';
|
|
22
|
+
import { Animated, AppState, NativeModules, Platform } from 'react-native';
|
|
9
23
|
import type { SharedValue } from 'react-native-reanimated';
|
|
10
24
|
import { useSharedValue } from 'react-native-reanimated';
|
|
11
25
|
import {
|
|
@@ -18,11 +32,37 @@ import { HeaderBackground } from './Components/HeaderBackground';
|
|
|
18
32
|
import { HeaderLeft } from './Components/HeaderLeft';
|
|
19
33
|
import { HeaderRight } from './Components/HeaderRight';
|
|
20
34
|
|
|
35
|
+
const forStackTransitionWithOverlay = (
|
|
36
|
+
props: StackCardInterpolationProps,
|
|
37
|
+
): StackCardInterpolatedStyle => {
|
|
38
|
+
const { cardStyle } = CardStyleInterpolators.forHorizontalIOS(props);
|
|
39
|
+
return {
|
|
40
|
+
cardStyle,
|
|
41
|
+
shadowStyle: {
|
|
42
|
+
shadowOpacity: props.current.progress.interpolate({
|
|
43
|
+
inputRange: [0, 1],
|
|
44
|
+
outputRange: [0, 0.3],
|
|
45
|
+
extrapolate: 'clamp',
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
overlayStyle: {
|
|
49
|
+
backgroundColor: '#FDEAF4',
|
|
50
|
+
opacity: props.current.progress.interpolate({
|
|
51
|
+
inputRange: [0, 1],
|
|
52
|
+
outputRange: [0, 0.6],
|
|
53
|
+
extrapolate: 'clamp',
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
21
59
|
/**
|
|
22
60
|
* default options for stack screen
|
|
23
61
|
*/
|
|
24
|
-
const getStackOptions = (
|
|
25
|
-
|
|
62
|
+
const getStackOptions = ({
|
|
63
|
+
swipeBack = true,
|
|
64
|
+
}: { swipeBack?: boolean } = {}): StackNavigationOptions => {
|
|
65
|
+
const base: StackNavigationOptions = {
|
|
26
66
|
headerTitleAlign: 'left',
|
|
27
67
|
headerTitle: props => <HeaderTitle {...props} />,
|
|
28
68
|
headerTitleContainerStyle: {
|
|
@@ -38,8 +78,29 @@ const getStackOptions = (): StackNavigationOptions => {
|
|
|
38
78
|
headerLeft: (props: any) => <HeaderLeft {...props} />,
|
|
39
79
|
headerRight: (props: any) => <HeaderRight {...props} />,
|
|
40
80
|
headerTintColor: Colors.black_17,
|
|
41
|
-
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A bottom tab stack holds a single route, so the card transition never runs and
|
|
85
|
+
* `CardContainer` pins `gestureEnabled` to false at index 0 regardless. What does bite is
|
|
86
|
+
* `cardOverlayEnabled`: `current.progress` rests at 1, so the overlay would sit at its full
|
|
87
|
+
* 0.6 forever instead of only during a push, tinting any tab screen that paints no
|
|
88
|
+
* background of its own. This branch is the option set from before swipe back, unchanged.
|
|
89
|
+
*/
|
|
90
|
+
if (!swipeBack) {
|
|
91
|
+
return {
|
|
92
|
+
...base,
|
|
93
|
+
gestureEnabled: false,
|
|
94
|
+
...TransitionPresets.SlideFromRightIOS,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...base,
|
|
100
|
+
gestureEnabled: Platform.OS === 'ios',
|
|
42
101
|
...TransitionPresets.SlideFromRightIOS,
|
|
102
|
+
cardStyleInterpolator: forStackTransitionWithOverlay,
|
|
103
|
+
cardOverlayEnabled: true,
|
|
43
104
|
};
|
|
44
105
|
};
|
|
45
106
|
|
|
@@ -54,9 +115,17 @@ const getModalOptions = (): StackNavigationOptions => {
|
|
|
54
115
|
cardOverlayEnabled: true,
|
|
55
116
|
animation: 'none',
|
|
56
117
|
presentation: 'transparentModal',
|
|
118
|
+
gestureEnabled: false,
|
|
57
119
|
};
|
|
58
120
|
};
|
|
59
121
|
|
|
122
|
+
/**
|
|
123
|
+
* screen owns its back flow (guard popup, custom handler or no back button),
|
|
124
|
+
* so swipe back must not bypass it
|
|
125
|
+
*/
|
|
126
|
+
const hasCustomBackHandling = (params: NavigationOptions) =>
|
|
127
|
+
params.preventBack !== undefined || params.hiddenBack === true;
|
|
128
|
+
|
|
60
129
|
/**
|
|
61
130
|
* build react-navigation options
|
|
62
131
|
*/
|
|
@@ -125,6 +194,13 @@ const getOptions = (
|
|
|
125
194
|
};
|
|
126
195
|
}
|
|
127
196
|
|
|
197
|
+
/**
|
|
198
|
+
* only ever turns swipe back off, screens opt in via gestureEnabled
|
|
199
|
+
*/
|
|
200
|
+
if (params.gestureEnabled === undefined && hasCustomBackHandling(params)) {
|
|
201
|
+
options.gestureEnabled = false;
|
|
202
|
+
}
|
|
203
|
+
|
|
128
204
|
return {
|
|
129
205
|
...params,
|
|
130
206
|
...options,
|
|
@@ -230,6 +306,65 @@ const useAppState = () => {
|
|
|
230
306
|
};
|
|
231
307
|
};
|
|
232
308
|
|
|
309
|
+
/**
|
|
310
|
+
* screens the focused navigation branch can still pop.
|
|
311
|
+
* only stack navigators count, switching bottom tab is not a back step
|
|
312
|
+
*/
|
|
313
|
+
const getStackDepth = (
|
|
314
|
+
state?: NavigationState | PartialState<NavigationState>,
|
|
315
|
+
): number => {
|
|
316
|
+
const routes = state?.routes;
|
|
317
|
+
if (!routes?.length) {
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const index = state?.index ?? routes.length - 1;
|
|
322
|
+
const depth = state?.type === 'stack' ? index : 0;
|
|
323
|
+
|
|
324
|
+
return depth + getStackDepth(routes[index]?.state);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* report back ownership to the native container: `true` when the mini app sits
|
|
329
|
+
* on its root screen and native may dismiss the rootview, `false` while
|
|
330
|
+
* react-navigation still has screens (or a modal / bottom sheet) to pop.
|
|
331
|
+
*/
|
|
332
|
+
const useNativeCanGoBack = () => {
|
|
333
|
+
const lastValue = useRef<boolean | undefined>(undefined);
|
|
334
|
+
|
|
335
|
+
const report = useCallback((canDismiss: boolean) => {
|
|
336
|
+
if (lastValue.current === canDismiss) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const setCanGoBack = NativeModules?.MiniAppModule?.setCanGoBack;
|
|
341
|
+
if (typeof setCanGoBack !== 'function') {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
lastValue.current = canDismiss;
|
|
346
|
+
try {
|
|
347
|
+
setCanGoBack(canDismiss);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.warn('[NavigationContainer] setCanGoBack failed:', error);
|
|
350
|
+
}
|
|
351
|
+
}, []);
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The flag lives on the host, not in this tree, so leaving it at `false` on unmount
|
|
355
|
+
* strands every mini app opened afterwards: the host keeps believing react-navigation
|
|
356
|
+
* still has screens to pop, and goes on routing back into a container that is gone.
|
|
357
|
+
* Hand ownership back on the way out.
|
|
358
|
+
*/
|
|
359
|
+
useEffect(() => () => report(true), [report]);
|
|
360
|
+
|
|
361
|
+
return useCallback(
|
|
362
|
+
(state?: NavigationState | PartialState<NavigationState>) =>
|
|
363
|
+
report(getStackDepth(state) === 0),
|
|
364
|
+
[report],
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
|
|
233
368
|
/**
|
|
234
369
|
* Union of the two animation runtimes an internal foundation component might
|
|
235
370
|
* receive. Public Screen props still accept React Native Animated.Value only;
|
|
@@ -267,13 +402,120 @@ const useNormalizedSharedValue = (
|
|
|
267
402
|
return fallback;
|
|
268
403
|
};
|
|
269
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Travel, in points, at which the swipe back gesture ticks. Flat and well short of
|
|
407
|
+
* react-navigation's own `distance / 2` commit rule: the tick marks "the gesture is
|
|
408
|
+
* recognised", not "you are past the point of no return".
|
|
409
|
+
*/
|
|
410
|
+
const SWIPE_BACK_HAPTIC_THRESHOLD = 50;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* `current.progress` is `gesture.interpolate(...)` built by CardStack
|
|
414
|
+
* (@react-navigation/stack 7.4.2, views/Stack/CardStack.tsx `getProgressFromGesture`).
|
|
415
|
+
* Only `Animated.Value.addListener` bridges native driver updates back to js, so the raw
|
|
416
|
+
* translation has to be read off the interpolation's parent.
|
|
417
|
+
*
|
|
418
|
+
* Reaches into a private field. Every hop is optional and duck typed, so a shape change in
|
|
419
|
+
* a future @react-navigation/stack degrades to "no haptic" rather than a crash.
|
|
420
|
+
*/
|
|
421
|
+
const getSwipeGestureValue = (
|
|
422
|
+
progress: Animated.AnimatedInterpolation<number> | undefined,
|
|
423
|
+
): Animated.Value | undefined => {
|
|
424
|
+
const parent = (progress as { _parent?: unknown } | undefined)?._parent as
|
|
425
|
+
| Animated.Value
|
|
426
|
+
| undefined;
|
|
427
|
+
|
|
428
|
+
return typeof parent?.addListener === 'function' &&
|
|
429
|
+
typeof parent?.removeListener === 'function'
|
|
430
|
+
? parent
|
|
431
|
+
: undefined;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const readAnimatedValue = (node: unknown, fallback: number): number => {
|
|
435
|
+
const value = (node as { __getValue?: () => unknown } | undefined)?.__getValue?.();
|
|
436
|
+
return typeof value === 'number' ? value : fallback;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Haptic tick for the iOS swipe back gesture.
|
|
441
|
+
*
|
|
442
|
+
* Deliberately a leaf that renders nothing. Consuming `CardAnimationContext` re-renders on
|
|
443
|
+
* every `Card` render, and an earlier version put that on `StackScreen` itself — which then
|
|
444
|
+
* re-rendered the whole screen mid gesture and cost the pop: `Card` defers `onClose()` by
|
|
445
|
+
* 32ms and clears that timer from `animate()`, which `componentDidUpdate` re-enters on any
|
|
446
|
+
* render in the window. Keeping the subscription in a null leaf isolates it.
|
|
447
|
+
*
|
|
448
|
+
* Both listeners are installed on mount, never lazily inside the gesture: on the New
|
|
449
|
+
* Architecture `addListener` queues `startListeningToAnimatedNodeValue` via
|
|
450
|
+
* `queueOperationBlock`, and that queue is only drained by a React mount commit — which
|
|
451
|
+
* never happens during a native driven pan.
|
|
452
|
+
*/
|
|
453
|
+
const SwipeBackHaptic = () => {
|
|
454
|
+
const { navigator } = useContext<any>(ApplicationContext);
|
|
455
|
+
const animation = useContext(CardAnimationContext);
|
|
456
|
+
const hasFired = useRef(false);
|
|
457
|
+
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (Platform.OS !== 'ios' || !animation) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const { swiping, inverted, current } = animation;
|
|
464
|
+
const gesture = getSwipeGestureValue(current?.progress);
|
|
465
|
+
if (!gesture || typeof swiping?.addListener !== 'function') {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let isSwiping = readAnimatedValue(swiping, 0) === 1;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* sign, not magnitude: a leftward overdrag reports a negative translation and must
|
|
473
|
+
* not count as progress toward dismissal
|
|
474
|
+
*/
|
|
475
|
+
const direction = readAnimatedValue(inverted, 1);
|
|
476
|
+
|
|
477
|
+
const swipingListener = swiping.addListener(({ value }) => {
|
|
478
|
+
isSwiping = value === 1;
|
|
479
|
+
if (!isSwiping) {
|
|
480
|
+
hasFired.current = false;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const gestureListener = gesture.addListener(({ value: translation }) => {
|
|
485
|
+
if (!isSwiping) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (translation * direction < SWIPE_BACK_HAPTIC_THRESHOLD) {
|
|
490
|
+
hasFired.current = false;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!hasFired.current) {
|
|
495
|
+
hasFired.current = true;
|
|
496
|
+
navigator?.maxApi?.triggerEventVibration?.('light');
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
return () => {
|
|
501
|
+
gesture.removeListener(gestureListener);
|
|
502
|
+
swiping.removeListener(swipingListener);
|
|
503
|
+
};
|
|
504
|
+
}, [animation, navigator]);
|
|
505
|
+
|
|
506
|
+
return null;
|
|
507
|
+
};
|
|
508
|
+
|
|
270
509
|
export {
|
|
271
510
|
getStackOptions,
|
|
272
511
|
getModalOptions,
|
|
273
512
|
getOptions,
|
|
513
|
+
getStackDepth,
|
|
274
514
|
exportHeaderTitle,
|
|
275
515
|
setAutomationID,
|
|
276
516
|
useComponentId,
|
|
277
517
|
useAppState,
|
|
518
|
+
useNativeCanGoBack,
|
|
278
519
|
useNormalizedSharedValue,
|
|
520
|
+
SwipeBackHaptic,
|
|
279
521
|
};
|