@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.
- package/Application/StackScreen.tsx +4 -2
- package/Application/utils.tsx +75 -133
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
);
|
package/Application/utils.tsx
CHANGED
|
@@ -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
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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
|
-
|
|
343
|
+
export type AnimatedCompatValue = Animated.Value | SharedValue<number>;
|
|
352
344
|
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
|
|
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
|
|
391
|
+
progress: Animated.AnimatedInterpolation<number> | undefined,
|
|
372
392
|
): Animated.Value | undefined => {
|
|
373
|
-
const parent = (progress as { _parent?:
|
|
374
|
-
|
|
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
|
-
*
|
|
384
|
-
* the pop. iOS only, mirroring `gestureEnabled` in getStackOptions.
|
|
409
|
+
* Haptic tick for the iOS swipe back gesture.
|
|
385
410
|
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
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
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
454
|
+
if (!isSwiping) {
|
|
472
455
|
return;
|
|
473
456
|
}
|
|
474
457
|
|
|
475
|
-
|
|
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
|
-
|
|
489
|
+
SwipeBackHaptic,
|
|
548
490
|
};
|