@momo-kits/foundation 0.164.1-beta.6 → 0.164.1-beta.8

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.
@@ -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, useSwipeBackHaptic } from './utils';
9
+ import { SwipeBackHaptic, useAppState } from './utils';
10
10
  import { TooltipPortalHost, TooltipPortalProvider } from './TooltipPortal';
11
11
 
12
12
  const runAfterInteractions = InteractionManager.runAfterInteractions;
@@ -51,7 +51,6 @@ 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();
55
54
 
56
55
  const data = {
57
56
  ...initialParams,
@@ -414,6 +413,9 @@ const StackScreen: React.FC<any> = props => {
414
413
  <Component heightHeader={heightHeader} {...data} />
415
414
  {showGrid && <GridSystem />}
416
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 />
417
419
  </TooltipPortalProvider>
418
420
  </ScreenContext.Provider>
419
421
  );
@@ -17,12 +17,12 @@ import type {
17
17
  } from '@react-navigation/stack';
18
18
  import type { NavigationState, PartialState } from '@react-navigation/native';
19
19
  import type { HeaderTitleProps, NavigationOptions } from './types';
20
+ import { ApplicationContext } from '../Context';
20
21
  import { Colors, Spacing } from '../Consts';
21
22
  import { Animated, AppState, NativeModules, Platform } from 'react-native';
22
23
  import type { SharedValue } from 'react-native-reanimated';
23
24
  import { useSharedValue } from 'react-native-reanimated';
24
25
  import {
25
- ApplicationContext,
26
26
  MiniAppContext,
27
27
  ScreenContext,
28
28
  TrackingScopeContext,
@@ -335,43 +335,64 @@ const useNativeCanGoBack = () => {
335
335
  };
336
336
 
337
337
  /**
338
- * fraction of screen width at which releasing commits the pop. deliberately
339
- * pinned to react-navigation's own rule: Card.tsx (GestureState.END,
340
- * @react-navigation/stack 7.4.2) closes the card when
341
- * `(translation + velocity * gestureVelocityImpact) * inverted > distance / 2`
342
- * with `distance = layout.width` for a horizontal gesture, and exposes no
343
- * option to override it. so the tick means "release now and you go back"
344
- * instead of marking a distance of its own — keep the two in sync.
345
- *
346
- * mid drag only translation is known, velocity exists solely at release, so a
347
- * short fast flick can commit without ever crossing this and pops with no
348
- * tick. that asymmetry is intended: modelling velocity per frame would tick
349
- * on swipes that then do not commit, which is the worse error
338
+ * Union of the two animation runtimes an internal foundation component might
339
+ * receive. Public Screen props still accept React Native Animated.Value only;
340
+ * this type keeps lower-level header/floating components compatible while they
341
+ * render with reanimated.
350
342
  */
351
- const SWIPE_BACK_HAPTIC_COMMIT_RATIO = 0.5;
343
+ export type AnimatedCompatValue = Animated.Value | SharedValue<number>;
352
344
 
353
- const readAnimatedValue = (node: unknown, fallback: number): number => {
354
- const getValue = (node as { __getValue?: () => unknown } | undefined)
355
- ?.__getValue;
356
- if (typeof getValue !== 'function') {
357
- return fallback;
358
- }
345
+ const isRNAnimatedValue = (v: unknown): v is Animated.Value =>
346
+ !!v &&
347
+ typeof (v as Animated.Value).setValue === 'function' &&
348
+ typeof (v as Animated.Value).interpolate === 'function';
359
349
 
360
- const value = getValue.call(node);
361
- return typeof value === 'number' ? value : fallback;
350
+ const isSharedValue = (v: unknown): v is SharedValue<number> =>
351
+ !!v && !isRNAnimatedValue(v) && 'value' in (v as object);
352
+
353
+ const useNormalizedSharedValue = (
354
+ input?: AnimatedCompatValue,
355
+ ): SharedValue<number> => {
356
+ const fallback = useSharedValue(0);
357
+
358
+ useEffect(() => {
359
+ if (!isRNAnimatedValue(input)) {
360
+ return;
361
+ }
362
+ const id = input.addListener(({ value }) => {
363
+ fallback.value = value;
364
+ });
365
+ return () => input.removeListener(id);
366
+ }, [input, fallback]);
367
+
368
+ if (isSharedValue(input)) {
369
+ return input;
370
+ }
371
+ return fallback;
362
372
  };
363
373
 
364
374
  /**
365
- * `current.progress` is built as `gesture.interpolate(...)`, so its parent is
366
- * the raw pan translation the finger drives. Private react-navigation / RN
367
- * shape, verified against @react-navigation/stack 7.4.2 + react-native 0.80.1
368
- * — duck-typed so a rename degrades to "no haptic" instead of a crash.
375
+ * Travel, in points, at which the swipe back gesture ticks. Flat and well short of
376
+ * react-navigation's own `distance / 2` commit rule: the tick marks "the gesture is
377
+ * recognised", not "you are past the point of no return".
378
+ */
379
+ const SWIPE_BACK_HAPTIC_THRESHOLD = 50;
380
+
381
+ /**
382
+ * `current.progress` is `gesture.interpolate(...)` built by CardStack
383
+ * (@react-navigation/stack 7.4.2, views/Stack/CardStack.tsx `getProgressFromGesture`).
384
+ * Only `Animated.Value.addListener` bridges native driver updates back to js, so the raw
385
+ * translation has to be read off the interpolation's parent.
386
+ *
387
+ * Reaches into a private field. Every hop is optional and duck typed, so a shape change in
388
+ * a future @react-navigation/stack degrades to "no haptic" rather than a crash.
369
389
  */
370
390
  const getSwipeGestureValue = (
371
- progress?: Animated.AnimatedInterpolation<number>,
391
+ progress: Animated.AnimatedInterpolation<number> | undefined,
372
392
  ): Animated.Value | undefined => {
373
- const parent = (progress as { _parent?: Animated.Value } | undefined)
374
- ?._parent;
393
+ const parent = (progress as { _parent?: unknown } | undefined)?._parent as
394
+ | Animated.Value
395
+ | undefined;
375
396
 
376
397
  return typeof parent?.addListener === 'function' &&
377
398
  typeof parent?.removeListener === 'function'
@@ -379,30 +400,28 @@ const getSwipeGestureValue = (
379
400
  : undefined;
380
401
  };
381
402
 
403
+ const readAnimatedValue = (node: unknown, fallback: number): number => {
404
+ const value = (node as { __getValue?: () => unknown } | undefined)?.__getValue?.();
405
+ return typeof value === 'number' ? value : fallback;
406
+ };
407
+
382
408
  /**
383
- * one light tick once the swipe back passes the point where releasing commits
384
- * the pop. iOS only, mirroring `gestureEnabled` in getStackOptions.
409
+ * Haptic tick for the iOS swipe back gesture.
385
410
  *
386
- * both listeners are installed on mount and never lazily inside the gesture:
387
- * on the New Architecture `addListener` queues `startListeningToAnimatedNodeValue`
388
- * via `queueOperationBlock`, and that queue is only drained by a React mount
389
- * commit which never happens during a native driven pan. subscribing when the
390
- * swipe starts installs an observer that the release flush tears down again
391
- * before a single frame is delivered, so it must stay on mount.
411
+ * Deliberately a leaf that renders nothing. Consuming `CardAnimationContext` re-renders on
412
+ * every `Card` render, and an earlier version put that on `StackScreen` itself — which then
413
+ * re-rendered the whole screen mid gesture and cost the pop: `Card` defers `onClose()` by
414
+ * 32ms and clears that timer from `animate()`, which `componentDidUpdate` re-enters on any
415
+ * render in the window. Keeping the subscription in a null leaf isolates it.
392
416
  *
393
- * `gesture` also drives the programmatic push and pop animations, so it is the
394
- * JS `isSwiping` flag, not the subscription lifetime, that keeps the tick
395
- * exclusive to a real finger drag.
417
+ * Both listeners are installed on mount, never lazily inside the gesture: on the New
418
+ * Architecture `addListener` queues `startListeningToAnimatedNodeValue` via
419
+ * `queueOperationBlock`, and that queue is only drained by a React mount commit — which
420
+ * never happens during a native driven pan.
396
421
  */
397
- const useSwipeBackHaptic = () => {
422
+ const SwipeBackHaptic = () => {
398
423
  const { navigator } = useContext<any>(ApplicationContext);
399
424
  const animation = useContext(CardAnimationContext);
400
-
401
- /**
402
- * the latch has to outlive the effect. `animation` identity changes on every
403
- * navigator render, so a `hasFired` local re-arms several times per drag and
404
- * the tick then repeats on every frame past the threshold
405
- */
406
425
  const hasFired = useRef(false);
407
426
 
408
427
  useEffect(() => {
@@ -410,71 +429,33 @@ const useSwipeBackHaptic = () => {
410
429
  return;
411
430
  }
412
431
 
413
- const { swiping, inverted, current, layouts } = animation;
432
+ const { swiping, inverted, current } = animation;
414
433
  const gesture = getSwipeGestureValue(current?.progress);
415
434
  if (!gesture || typeof swiping?.addListener !== 'function') {
416
435
  return;
417
436
  }
418
437
 
419
- /**
420
- * `animation` identity is not stable, so this effect can re-run mid swipe.
421
- * seed from the live value instead of waiting for a 0 -> 1 transition that
422
- * has already been missed
423
- */
424
438
  let isSwiping = readAnimatedValue(swiping, 0) === 1;
425
439
 
426
440
  /**
427
- * sign, not magnitude: a leftward overdrag reports a negative
428
- * translation and must not count as progress toward dismissal
441
+ * sign, not magnitude: a leftward overdrag reports a negative translation and must
442
+ * not count as progress toward dismissal
429
443
  */
430
444
  const direction = readAnimatedValue(inverted, 1);
431
445
 
432
- /**
433
- * plain JS numbers, not animated nodes (`StackCardInterpolationProps`).
434
- * screen layout can still be unmeasured on an early render, and a zero
435
- * width would put the threshold at 0 and tick the moment the finger moves
436
- */
437
- const commitThreshold =
438
- (layouts?.screen?.width ?? 0) * SWIPE_BACK_HAPTIC_COMMIT_RATIO;
439
-
440
- /**
441
- * TEMPORARY DIAGNOSTIC (DS-760) — remove once the swipe-back no-pop is understood.
442
- *
443
- * The test device cannot run Xcode, so this borrows the one channel proven to reach it.
444
- * On release past the commit threshold react-navigation should pop; if this screen is
445
- * still mounted shortly after, the pop was requested and then swallowed — `Card` clears
446
- * its 32ms `pendingGestureCallback` from `animate()`, and `componentDidUpdate` re-enters
447
- * `animate()` for any re-render in that window.
448
- *
449
- * A `heavy` tick after release therefore means "gesture completed, pop lost". Silence
450
- * means the gesture never reached the threshold, or was cancelled before END.
451
- */
452
- let lastTravel = 0;
453
- let pendingPopProbe: ReturnType<typeof setTimeout> | undefined;
454
-
455
446
  const swipingListener = swiping.addListener(({ value }) => {
456
- const wasSwiping = isSwiping;
457
447
  isSwiping = value === 1;
458
- if (isSwiping) {
459
- return;
460
- }
461
- hasFired.current = false;
462
- if (wasSwiping && commitThreshold > 0 && lastTravel >= commitThreshold) {
463
- pendingPopProbe = setTimeout(() => {
464
- navigator?.maxApi?.triggerEventVibration?.('heavy');
465
- }, 500);
448
+ if (!isSwiping) {
449
+ hasFired.current = false;
466
450
  }
467
- lastTravel = 0;
468
451
  });
469
452
 
470
453
  const gestureListener = gesture.addListener(({ value: translation }) => {
471
- if (!isSwiping || commitThreshold <= 0) {
454
+ if (!isSwiping) {
472
455
  return;
473
456
  }
474
457
 
475
- lastTravel = translation * direction;
476
-
477
- if (lastTravel < commitThreshold) {
458
+ if (translation * direction < SWIPE_BACK_HAPTIC_THRESHOLD) {
478
459
  hasFired.current = false;
479
460
  return;
480
461
  }
@@ -486,51 +467,12 @@ const useSwipeBackHaptic = () => {
486
467
  });
487
468
 
488
469
  return () => {
489
- // unmount cancels the probe: the screen popped, which is the success case
490
- if (pendingPopProbe !== undefined) {
491
- clearTimeout(pendingPopProbe);
492
- }
493
470
  gesture.removeListener(gestureListener);
494
471
  swiping.removeListener(swipingListener);
495
472
  };
496
473
  }, [animation, navigator]);
497
- };
498
474
 
499
- /**
500
- * Union of the two animation runtimes an internal foundation component might
501
- * receive. Public Screen props still accept React Native Animated.Value only;
502
- * this type keeps lower-level header/floating components compatible while they
503
- * render with reanimated.
504
- */
505
- export type AnimatedCompatValue = Animated.Value | SharedValue<number>;
506
-
507
- const isRNAnimatedValue = (v: unknown): v is Animated.Value =>
508
- !!v &&
509
- typeof (v as Animated.Value).setValue === 'function' &&
510
- typeof (v as Animated.Value).interpolate === 'function';
511
-
512
- const isSharedValue = (v: unknown): v is SharedValue<number> =>
513
- !!v && !isRNAnimatedValue(v) && 'value' in (v as object);
514
-
515
- const useNormalizedSharedValue = (
516
- input?: AnimatedCompatValue,
517
- ): SharedValue<number> => {
518
- const fallback = useSharedValue(0);
519
-
520
- useEffect(() => {
521
- if (!isRNAnimatedValue(input)) {
522
- return;
523
- }
524
- const id = input.addListener(({ value }) => {
525
- fallback.value = value;
526
- });
527
- return () => input.removeListener(id);
528
- }, [input, fallback]);
529
-
530
- if (isSharedValue(input)) {
531
- return input;
532
- }
533
- return fallback;
475
+ return null;
534
476
  };
535
477
 
536
478
  export {
@@ -544,5 +486,5 @@ export {
544
486
  useAppState,
545
487
  useNativeCanGoBack,
546
488
  useNormalizedSharedValue,
547
- useSwipeBackHaptic,
489
+ SwipeBackHaptic,
548
490
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.164.1-beta.6",
3
+ "version": "0.164.1-beta.8",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},