@momo-kits/foundation 0.164.1-beta.7 → 0.164.1-beta.9
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>
|
|
@@ -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
|
);
|
package/Application/utils.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import React, {
|
|
|
6
6
|
useRef,
|
|
7
7
|
} from 'react';
|
|
8
8
|
import {
|
|
9
|
+
CardAnimationContext,
|
|
9
10
|
CardStyleInterpolators,
|
|
10
11
|
StackNavigationOptions,
|
|
11
12
|
TransitionPresets,
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
} from '@react-navigation/stack';
|
|
17
18
|
import type { NavigationState, PartialState } from '@react-navigation/native';
|
|
18
19
|
import type { HeaderTitleProps, NavigationOptions } from './types';
|
|
20
|
+
import { ApplicationContext } from '../Context';
|
|
19
21
|
import { Colors, Spacing } from '../Consts';
|
|
20
22
|
import { Animated, AppState, NativeModules, Platform } from 'react-native';
|
|
21
23
|
import type { SharedValue } from 'react-native-reanimated';
|
|
@@ -57,8 +59,10 @@ const forStackTransitionWithOverlay = (
|
|
|
57
59
|
/**
|
|
58
60
|
* default options for stack screen
|
|
59
61
|
*/
|
|
60
|
-
const getStackOptions = (
|
|
61
|
-
|
|
62
|
+
const getStackOptions = ({
|
|
63
|
+
swipeBack = true,
|
|
64
|
+
}: { swipeBack?: boolean } = {}): StackNavigationOptions => {
|
|
65
|
+
const base: StackNavigationOptions = {
|
|
62
66
|
headerTitleAlign: 'left',
|
|
63
67
|
headerTitle: props => <HeaderTitle {...props} />,
|
|
64
68
|
headerTitleContainerStyle: {
|
|
@@ -74,6 +78,25 @@ const getStackOptions = (): StackNavigationOptions => {
|
|
|
74
78
|
headerLeft: (props: any) => <HeaderLeft {...props} />,
|
|
75
79
|
headerRight: (props: any) => <HeaderRight {...props} />,
|
|
76
80
|
headerTintColor: Colors.black_17,
|
|
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,
|
|
77
100
|
gestureEnabled: Platform.OS === 'ios',
|
|
78
101
|
...TransitionPresets.SlideFromRightIOS,
|
|
79
102
|
cardStyleInterpolator: forStackTransitionWithOverlay,
|
|
@@ -369,6 +392,110 @@ const useNormalizedSharedValue = (
|
|
|
369
392
|
return fallback;
|
|
370
393
|
};
|
|
371
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Travel, in points, at which the swipe back gesture ticks. Flat and well short of
|
|
397
|
+
* react-navigation's own `distance / 2` commit rule: the tick marks "the gesture is
|
|
398
|
+
* recognised", not "you are past the point of no return".
|
|
399
|
+
*/
|
|
400
|
+
const SWIPE_BACK_HAPTIC_THRESHOLD = 50;
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* `current.progress` is `gesture.interpolate(...)` built by CardStack
|
|
404
|
+
* (@react-navigation/stack 7.4.2, views/Stack/CardStack.tsx `getProgressFromGesture`).
|
|
405
|
+
* Only `Animated.Value.addListener` bridges native driver updates back to js, so the raw
|
|
406
|
+
* translation has to be read off the interpolation's parent.
|
|
407
|
+
*
|
|
408
|
+
* Reaches into a private field. Every hop is optional and duck typed, so a shape change in
|
|
409
|
+
* a future @react-navigation/stack degrades to "no haptic" rather than a crash.
|
|
410
|
+
*/
|
|
411
|
+
const getSwipeGestureValue = (
|
|
412
|
+
progress: Animated.AnimatedInterpolation<number> | undefined,
|
|
413
|
+
): Animated.Value | undefined => {
|
|
414
|
+
const parent = (progress as { _parent?: unknown } | undefined)?._parent as
|
|
415
|
+
| Animated.Value
|
|
416
|
+
| undefined;
|
|
417
|
+
|
|
418
|
+
return typeof parent?.addListener === 'function' &&
|
|
419
|
+
typeof parent?.removeListener === 'function'
|
|
420
|
+
? parent
|
|
421
|
+
: undefined;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const readAnimatedValue = (node: unknown, fallback: number): number => {
|
|
425
|
+
const value = (node as { __getValue?: () => unknown } | undefined)?.__getValue?.();
|
|
426
|
+
return typeof value === 'number' ? value : fallback;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Haptic tick for the iOS swipe back gesture.
|
|
431
|
+
*
|
|
432
|
+
* Deliberately a leaf that renders nothing. Consuming `CardAnimationContext` re-renders on
|
|
433
|
+
* every `Card` render, and an earlier version put that on `StackScreen` itself — which then
|
|
434
|
+
* re-rendered the whole screen mid gesture and cost the pop: `Card` defers `onClose()` by
|
|
435
|
+
* 32ms and clears that timer from `animate()`, which `componentDidUpdate` re-enters on any
|
|
436
|
+
* render in the window. Keeping the subscription in a null leaf isolates it.
|
|
437
|
+
*
|
|
438
|
+
* Both listeners are installed on mount, never lazily inside the gesture: on the New
|
|
439
|
+
* Architecture `addListener` queues `startListeningToAnimatedNodeValue` via
|
|
440
|
+
* `queueOperationBlock`, and that queue is only drained by a React mount commit — which
|
|
441
|
+
* never happens during a native driven pan.
|
|
442
|
+
*/
|
|
443
|
+
const SwipeBackHaptic = () => {
|
|
444
|
+
const { navigator } = useContext<any>(ApplicationContext);
|
|
445
|
+
const animation = useContext(CardAnimationContext);
|
|
446
|
+
const hasFired = useRef(false);
|
|
447
|
+
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
if (Platform.OS !== 'ios' || !animation) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const { swiping, inverted, current } = animation;
|
|
454
|
+
const gesture = getSwipeGestureValue(current?.progress);
|
|
455
|
+
if (!gesture || typeof swiping?.addListener !== 'function') {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let isSwiping = readAnimatedValue(swiping, 0) === 1;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* sign, not magnitude: a leftward overdrag reports a negative translation and must
|
|
463
|
+
* not count as progress toward dismissal
|
|
464
|
+
*/
|
|
465
|
+
const direction = readAnimatedValue(inverted, 1);
|
|
466
|
+
|
|
467
|
+
const swipingListener = swiping.addListener(({ value }) => {
|
|
468
|
+
isSwiping = value === 1;
|
|
469
|
+
if (!isSwiping) {
|
|
470
|
+
hasFired.current = false;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const gestureListener = gesture.addListener(({ value: translation }) => {
|
|
475
|
+
if (!isSwiping) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (translation * direction < SWIPE_BACK_HAPTIC_THRESHOLD) {
|
|
480
|
+
hasFired.current = false;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!hasFired.current) {
|
|
485
|
+
hasFired.current = true;
|
|
486
|
+
navigator?.maxApi?.triggerEventVibration?.('light');
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
return () => {
|
|
491
|
+
gesture.removeListener(gestureListener);
|
|
492
|
+
swiping.removeListener(swipingListener);
|
|
493
|
+
};
|
|
494
|
+
}, [animation, navigator]);
|
|
495
|
+
|
|
496
|
+
return null;
|
|
497
|
+
};
|
|
498
|
+
|
|
372
499
|
export {
|
|
373
500
|
getStackOptions,
|
|
374
501
|
getModalOptions,
|
|
@@ -380,4 +507,5 @@ export {
|
|
|
380
507
|
useAppState,
|
|
381
508
|
useNativeCanGoBack,
|
|
382
509
|
useNormalizedSharedValue,
|
|
510
|
+
SwipeBackHaptic,
|
|
383
511
|
};
|