@momo-kits/foundation 0.164.1-beta.1 → 0.164.1-beta.11

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({ gestureEnabled: 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({ gestureEnabled: 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';
@@ -1,11 +1,25 @@
1
- import React, { useContext, useEffect, useMemo, useRef } from '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 = (): StackNavigationOptions => {
25
- return {
62
+ const getStackOptions = (
63
+ options: StackNavigationOptions,
64
+ ): StackNavigationOptions => {
65
+ const base: StackNavigationOptions = {
26
66
  headerTitleAlign: 'left',
27
67
  headerTitle: props => <HeaderTitle {...props} />,
28
68
  headerTitleContainerStyle: {
@@ -38,8 +78,14 @@ const getStackOptions = (): StackNavigationOptions => {
38
78
  headerLeft: (props: any) => <HeaderLeft {...props} />,
39
79
  headerRight: (props: any) => <HeaderRight {...props} />,
40
80
  headerTintColor: Colors.black_17,
41
- gestureEnabled: false,
81
+ };
82
+
83
+ return {
84
+ ...base,
85
+ ...options,
42
86
  ...TransitionPresets.SlideFromRightIOS,
87
+ cardStyleInterpolator: forStackTransitionWithOverlay,
88
+ cardOverlayEnabled: true,
43
89
  };
44
90
  };
45
91
 
@@ -54,9 +100,17 @@ const getModalOptions = (): StackNavigationOptions => {
54
100
  cardOverlayEnabled: true,
55
101
  animation: 'none',
56
102
  presentation: 'transparentModal',
103
+ gestureEnabled: false,
57
104
  };
58
105
  };
59
106
 
107
+ /**
108
+ * screen owns its back flow (guard popup, custom handler or no back button),
109
+ * so swipe back must not bypass it
110
+ */
111
+ const hasCustomBackHandling = (params: NavigationOptions) =>
112
+ params.preventBack !== undefined || params.hiddenBack === true;
113
+
60
114
  /**
61
115
  * build react-navigation options
62
116
  */
@@ -125,6 +179,13 @@ const getOptions = (
125
179
  };
126
180
  }
127
181
 
182
+ /**
183
+ * only ever turns swipe back off, screens opt in via gestureEnabled
184
+ */
185
+ if (params.gestureEnabled === undefined && hasCustomBackHandling(params)) {
186
+ options.gestureEnabled = false;
187
+ }
188
+
128
189
  return {
129
190
  ...params,
130
191
  ...options,
@@ -230,6 +291,55 @@ const useAppState = () => {
230
291
  };
231
292
  };
232
293
 
294
+ /**
295
+ * screens the focused navigation branch can still pop.
296
+ * only stack navigators count, switching bottom tab is not a back step
297
+ */
298
+ const getStackDepth = (
299
+ state?: NavigationState | PartialState<NavigationState>,
300
+ ): number => {
301
+ const routes = state?.routes;
302
+ if (!routes?.length) {
303
+ return 0;
304
+ }
305
+
306
+ const index = state?.index ?? routes.length - 1;
307
+ const depth = state?.type === 'stack' ? index : 0;
308
+
309
+ return depth + getStackDepth(routes[index]?.state);
310
+ };
311
+
312
+ /**
313
+ * report back ownership to the native container: `true` when the mini app sits
314
+ * on its root screen and native may dismiss the rootview, `false` while
315
+ * react-navigation still has screens (or a modal / bottom sheet) to pop.
316
+ */
317
+ const useNativeCanGoBack = () => {
318
+ const lastValue = useRef<boolean | undefined>(undefined);
319
+
320
+ return useCallback(
321
+ (state?: NavigationState | PartialState<NavigationState>) => {
322
+ const canDismiss = getStackDepth(state) === 0;
323
+ if (lastValue.current === canDismiss) {
324
+ return;
325
+ }
326
+
327
+ const setCanGoBack = NativeModules?.MiniAppModule?.setCanGoBack;
328
+ if (typeof setCanGoBack !== 'function') {
329
+ return;
330
+ }
331
+
332
+ lastValue.current = canDismiss;
333
+ try {
334
+ setCanGoBack(canDismiss);
335
+ } catch (error) {
336
+ console.warn('[NavigationContainer] setCanGoBack failed:', error);
337
+ }
338
+ },
339
+ [],
340
+ );
341
+ };
342
+
233
343
  /**
234
344
  * Union of the two animation runtimes an internal foundation component might
235
345
  * receive. Public Screen props still accept React Native Animated.Value only;
@@ -267,13 +377,120 @@ const useNormalizedSharedValue = (
267
377
  return fallback;
268
378
  };
269
379
 
380
+ /**
381
+ * Travel, in points, at which the swipe back gesture ticks. Flat and well short of
382
+ * react-navigation's own `distance / 2` commit rule: the tick marks "the gesture is
383
+ * recognised", not "you are past the point of no return".
384
+ */
385
+ const SWIPE_BACK_HAPTIC_THRESHOLD = 50;
386
+
387
+ /**
388
+ * `current.progress` is `gesture.interpolate(...)` built by CardStack
389
+ * (@react-navigation/stack 7.4.2, views/Stack/CardStack.tsx `getProgressFromGesture`).
390
+ * Only `Animated.Value.addListener` bridges native driver updates back to js, so the raw
391
+ * translation has to be read off the interpolation's parent.
392
+ *
393
+ * Reaches into a private field. Every hop is optional and duck typed, so a shape change in
394
+ * a future @react-navigation/stack degrades to "no haptic" rather than a crash.
395
+ */
396
+ const getSwipeGestureValue = (
397
+ progress: Animated.AnimatedInterpolation<number> | undefined,
398
+ ): Animated.Value | undefined => {
399
+ const parent = (progress as { _parent?: unknown } | undefined)?._parent as
400
+ | Animated.Value
401
+ | undefined;
402
+
403
+ return typeof parent?.addListener === 'function' &&
404
+ typeof parent?.removeListener === 'function'
405
+ ? parent
406
+ : undefined;
407
+ };
408
+
409
+ const readAnimatedValue = (node: unknown, fallback: number): number => {
410
+ const value = (node as { __getValue?: () => unknown } | undefined)?.__getValue?.();
411
+ return typeof value === 'number' ? value : fallback;
412
+ };
413
+
414
+ /**
415
+ * Haptic tick for the iOS swipe back gesture.
416
+ *
417
+ * Deliberately a leaf that renders nothing. Consuming `CardAnimationContext` re-renders on
418
+ * every `Card` render, and an earlier version put that on `StackScreen` itself — which then
419
+ * re-rendered the whole screen mid gesture and cost the pop: `Card` defers `onClose()` by
420
+ * 32ms and clears that timer from `animate()`, which `componentDidUpdate` re-enters on any
421
+ * render in the window. Keeping the subscription in a null leaf isolates it.
422
+ *
423
+ * Both listeners are installed on mount, never lazily inside the gesture: on the New
424
+ * Architecture `addListener` queues `startListeningToAnimatedNodeValue` via
425
+ * `queueOperationBlock`, and that queue is only drained by a React mount commit — which
426
+ * never happens during a native driven pan.
427
+ */
428
+ const SwipeBackHaptic = () => {
429
+ const { navigator } = useContext<any>(ApplicationContext);
430
+ const animation = useContext(CardAnimationContext);
431
+ const hasFired = useRef(false);
432
+
433
+ useEffect(() => {
434
+ if (Platform.OS !== 'ios' || !animation) {
435
+ return;
436
+ }
437
+
438
+ const { swiping, inverted, current } = animation;
439
+ const gesture = getSwipeGestureValue(current?.progress);
440
+ if (!gesture || typeof swiping?.addListener !== 'function') {
441
+ return;
442
+ }
443
+
444
+ let isSwiping = readAnimatedValue(swiping, 0) === 1;
445
+
446
+ /**
447
+ * sign, not magnitude: a leftward overdrag reports a negative translation and must
448
+ * not count as progress toward dismissal
449
+ */
450
+ const direction = readAnimatedValue(inverted, 1);
451
+
452
+ const swipingListener = swiping.addListener(({ value }) => {
453
+ isSwiping = value === 1;
454
+ if (!isSwiping) {
455
+ hasFired.current = false;
456
+ }
457
+ });
458
+
459
+ const gestureListener = gesture.addListener(({ value: translation }) => {
460
+ if (!isSwiping) {
461
+ return;
462
+ }
463
+
464
+ if (translation * direction < SWIPE_BACK_HAPTIC_THRESHOLD) {
465
+ hasFired.current = false;
466
+ return;
467
+ }
468
+
469
+ if (!hasFired.current) {
470
+ hasFired.current = true;
471
+ navigator?.maxApi?.triggerEventVibration?.('light');
472
+ }
473
+ });
474
+
475
+ return () => {
476
+ gesture.removeListener(gestureListener);
477
+ swiping.removeListener(swipingListener);
478
+ };
479
+ }, [animation, navigator]);
480
+
481
+ return null;
482
+ };
483
+
270
484
  export {
271
485
  getStackOptions,
272
486
  getModalOptions,
273
487
  getOptions,
488
+ getStackDepth,
274
489
  exportHeaderTitle,
275
490
  setAutomationID,
276
491
  useComponentId,
277
492
  useAppState,
493
+ useNativeCanGoBack,
278
494
  useNormalizedSharedValue,
495
+ SwipeBackHaptic,
279
496
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.164.1-beta.1",
3
+ "version": "0.164.1-beta.11",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},