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

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.
@@ -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 { useAppState, useSwipeBackHaptic } from './utils';
10
10
  import { TooltipPortalHost, TooltipPortalProvider } from './TooltipPortal';
11
11
 
12
12
  const runAfterInteractions = InteractionManager.runAfterInteractions;
@@ -51,6 +51,7 @@ const StackScreen: React.FC<any> = props => {
51
51
  const navigation = useRef(new Navigation(props.navigation, context)).current;
52
52
  const heightHeader = useHeaderHeight();
53
53
  const { isBackgroundToForeground } = useAppState();
54
+ useSwipeBackHaptic();
54
55
 
55
56
  const data = {
56
57
  ...initialParams,
@@ -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,14 +1,28 @@
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';
7
20
  import { Colors, Spacing } from '../Consts';
8
- import { Animated, AppState, Platform } from 'react-native';
21
+ import { Animated, AppState, NativeModules, Platform } from 'react-native';
9
22
  import type { SharedValue } from 'react-native-reanimated';
10
23
  import { useSharedValue } from 'react-native-reanimated';
11
24
  import {
25
+ ApplicationContext,
12
26
  MiniAppContext,
13
27
  ScreenContext,
14
28
  TrackingScopeContext,
@@ -18,6 +32,30 @@ 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
  */
@@ -38,8 +76,10 @@ const getStackOptions = (): StackNavigationOptions => {
38
76
  headerLeft: (props: any) => <HeaderLeft {...props} />,
39
77
  headerRight: (props: any) => <HeaderRight {...props} />,
40
78
  headerTintColor: Colors.black_17,
41
- gestureEnabled: false,
79
+ gestureEnabled: Platform.OS === 'ios',
42
80
  ...TransitionPresets.SlideFromRightIOS,
81
+ cardStyleInterpolator: forStackTransitionWithOverlay,
82
+ cardOverlayEnabled: true,
43
83
  };
44
84
  };
45
85
 
@@ -54,9 +94,17 @@ const getModalOptions = (): StackNavigationOptions => {
54
94
  cardOverlayEnabled: true,
55
95
  animation: 'none',
56
96
  presentation: 'transparentModal',
97
+ gestureEnabled: false,
57
98
  };
58
99
  };
59
100
 
101
+ /**
102
+ * screen owns its back flow (guard popup, custom handler or no back button),
103
+ * so swipe back must not bypass it
104
+ */
105
+ const hasCustomBackHandling = (params: NavigationOptions) =>
106
+ params.preventBack !== undefined || params.hiddenBack === true;
107
+
60
108
  /**
61
109
  * build react-navigation options
62
110
  */
@@ -125,6 +173,13 @@ const getOptions = (
125
173
  };
126
174
  }
127
175
 
176
+ /**
177
+ * only ever turns swipe back off, screens opt in via gestureEnabled
178
+ */
179
+ if (params.gestureEnabled === undefined && hasCustomBackHandling(params)) {
180
+ options.gestureEnabled = false;
181
+ }
182
+
128
183
  return {
129
184
  ...params,
130
185
  ...options,
@@ -230,6 +285,160 @@ const useAppState = () => {
230
285
  };
231
286
  };
232
287
 
288
+ /**
289
+ * screens the focused navigation branch can still pop.
290
+ * only stack navigators count, switching bottom tab is not a back step
291
+ */
292
+ const getStackDepth = (
293
+ state?: NavigationState | PartialState<NavigationState>,
294
+ ): number => {
295
+ const routes = state?.routes;
296
+ if (!routes?.length) {
297
+ return 0;
298
+ }
299
+
300
+ const index = state?.index ?? routes.length - 1;
301
+ const depth = state?.type === 'stack' ? index : 0;
302
+
303
+ return depth + getStackDepth(routes[index]?.state);
304
+ };
305
+
306
+ /**
307
+ * report back ownership to the native container: `true` when the mini app sits
308
+ * on its root screen and native may dismiss the rootview, `false` while
309
+ * react-navigation still has screens (or a modal / bottom sheet) to pop.
310
+ */
311
+ const useNativeCanGoBack = () => {
312
+ const lastValue = useRef<boolean | undefined>(undefined);
313
+
314
+ return useCallback(
315
+ (state?: NavigationState | PartialState<NavigationState>) => {
316
+ const canDismiss = getStackDepth(state) === 0;
317
+ if (lastValue.current === canDismiss) {
318
+ return;
319
+ }
320
+
321
+ const setCanGoBack = NativeModules?.MiniAppModule?.setCanGoBack;
322
+ if (typeof setCanGoBack !== 'function') {
323
+ return;
324
+ }
325
+
326
+ lastValue.current = canDismiss;
327
+ try {
328
+ setCanGoBack(canDismiss);
329
+ } catch (error) {
330
+ console.warn('[NavigationContainer] setCanGoBack failed:', error);
331
+ }
332
+ },
333
+ [],
334
+ );
335
+ };
336
+
337
+ /**
338
+ * finger travel that reads as "swipe back recognised". flat px on purpose:
339
+ * scaling it by screen width would move the tick to a different physical
340
+ * thumb distance on every device
341
+ */
342
+ const SWIPE_BACK_HAPTIC_THRESHOLD = 100;
343
+
344
+ const readAnimatedValue = (node: unknown, fallback: number): number => {
345
+ const getValue = (node as { __getValue?: () => unknown } | undefined)
346
+ ?.__getValue;
347
+ if (typeof getValue !== 'function') {
348
+ return fallback;
349
+ }
350
+
351
+ const value = getValue.call(node);
352
+ return typeof value === 'number' ? value : fallback;
353
+ };
354
+
355
+ /**
356
+ * `current.progress` is built as `gesture.interpolate(...)`, so its parent is
357
+ * the raw pan translation the finger drives. Private react-navigation / RN
358
+ * shape, verified against @react-navigation/stack 7.4.2 + react-native 0.80.1
359
+ * — duck-typed so a rename degrades to "no haptic" instead of a crash.
360
+ */
361
+ const getSwipeGestureValue = (
362
+ progress?: Animated.AnimatedInterpolation<number>,
363
+ ): Animated.Value | undefined => {
364
+ const parent = (progress as { _parent?: Animated.Value } | undefined)
365
+ ?._parent;
366
+
367
+ return typeof parent?.addListener === 'function' &&
368
+ typeof parent?.removeListener === 'function'
369
+ ? parent
370
+ : undefined;
371
+ };
372
+
373
+ /**
374
+ * one light tick once the swipe back has travelled far enough to read as
375
+ * recognised. iOS only, mirroring `gestureEnabled` in getStackOptions.
376
+ *
377
+ * the listener is scoped to the `swiping === 1` window on purpose: the same
378
+ * animated value also drives the programmatic push and pop animations, so an
379
+ * unscoped listener would buzz on every navigation.
380
+ */
381
+ const useSwipeBackHaptic = () => {
382
+ const { navigator } = useContext<any>(ApplicationContext);
383
+ const animation = useContext(CardAnimationContext);
384
+
385
+ useEffect(() => {
386
+ if (Platform.OS !== 'ios' || !animation) {
387
+ return;
388
+ }
389
+
390
+ const { swiping, inverted, current } = animation;
391
+ const gesture = getSwipeGestureValue(current?.progress);
392
+ if (!gesture || typeof swiping?.addListener !== 'function') {
393
+ return;
394
+ }
395
+
396
+ let hasFired = false;
397
+ let gestureListener: string | undefined;
398
+
399
+ const detachGesture = () => {
400
+ if (gestureListener !== undefined) {
401
+ gesture.removeListener(gestureListener);
402
+ gestureListener = undefined;
403
+ }
404
+ hasFired = false;
405
+ };
406
+
407
+ const swipingListener = swiping.addListener(({ value }) => {
408
+ if (value !== 1) {
409
+ detachGesture();
410
+ return;
411
+ }
412
+
413
+ if (gestureListener !== undefined) {
414
+ return;
415
+ }
416
+
417
+ /**
418
+ * sign, not magnitude: a leftward overdrag reports a negative
419
+ * translation and must not count as progress toward dismissal
420
+ */
421
+ const direction = readAnimatedValue(inverted, 1);
422
+ gestureListener = gesture.addListener(({ value: translation }) => {
423
+ if (translation * direction < SWIPE_BACK_HAPTIC_THRESHOLD) {
424
+ hasFired = false;
425
+ return;
426
+ }
427
+
428
+ if (!hasFired) {
429
+ hasFired = true;
430
+ navigator?.maxApi?.triggerEventVibration?.('light');
431
+ }
432
+ });
433
+ });
434
+
435
+ return () => {
436
+ detachGesture();
437
+ swiping.removeListener(swipingListener);
438
+ };
439
+ }, [animation, navigator]);
440
+ };
441
+
233
442
  /**
234
443
  * Union of the two animation runtimes an internal foundation component might
235
444
  * receive. Public Screen props still accept React Native Animated.Value only;
@@ -271,9 +480,12 @@ export {
271
480
  getStackOptions,
272
481
  getModalOptions,
273
482
  getOptions,
483
+ getStackDepth,
274
484
  exportHeaderTitle,
275
485
  setAutomationID,
276
486
  useComponentId,
277
487
  useAppState,
488
+ useNativeCanGoBack,
278
489
  useNormalizedSharedValue,
490
+ useSwipeBackHaptic,
279
491
  };
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.2",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},