@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
  );
@@ -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 = (): StackNavigationOptions => {
61
- return {
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.164.1-beta.7",
3
+ "version": "0.164.1-beta.9",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},