react-native-smooth-clip-view 0.2.1 → 0.2.4

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.
Files changed (39) hide show
  1. package/README.md +51 -16
  2. package/android/CMakeLists.txt +4 -0
  3. package/android/build.gradle +1 -0
  4. package/android/src/main/cpp/SmoothClipAndroid.h +8 -0
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +11 -0
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +200 -76
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +7 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +10 -0
  9. package/cpp/SmoothClipVelocityTracker.h +106 -0
  10. package/ios/SmoothClipRegistry.mm +153 -29
  11. package/ios/SmoothClipView.mm +67 -77
  12. package/ios/SmoothClipViewRegistry.h +8 -0
  13. package/lib/module/drivers.ios.js +14 -0
  14. package/lib/module/drivers.ios.js.map +1 -1
  15. package/lib/module/drivers.js +8 -0
  16. package/lib/module/drivers.js.map +1 -1
  17. package/lib/module/easings.js +35 -0
  18. package/lib/module/easings.js.map +1 -0
  19. package/lib/module/index.js +1 -0
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/index.web.js +1 -0
  22. package/lib/module/index.web.js.map +1 -1
  23. package/lib/typescript/src/driverTypes.d.ts +32 -3
  24. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  25. package/lib/typescript/src/drivers.d.ts.map +1 -1
  26. package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
  27. package/lib/typescript/src/easings.d.ts +33 -0
  28. package/lib/typescript/src/easings.d.ts.map +1 -0
  29. package/lib/typescript/src/index.d.ts +1 -0
  30. package/lib/typescript/src/index.d.ts.map +1 -1
  31. package/lib/typescript/src/index.web.d.ts +1 -0
  32. package/lib/typescript/src/index.web.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/driverTypes.ts +51 -19
  35. package/src/drivers.ios.ts +26 -0
  36. package/src/drivers.ts +11 -0
  37. package/src/easings.ts +32 -0
  38. package/src/index.ts +1 -0
  39. package/src/index.web.ts +1 -0
@@ -0,0 +1,106 @@
1
+ #pragma once
2
+
3
+ #include <array>
4
+ #include <cmath>
5
+
6
+ namespace smoothclip {
7
+
8
+ // The two most recent interactive samples used by `initialVelocity: 'inherit'`.
9
+ // Channels are the seven presentation scalars (x, y, width, height, radius,
10
+ // contentTranslateX, contentTranslateY) but the tracker is value-agnostic:
11
+ // iOS records normalized (host-clamped) geometry, Android raw DIP — the
12
+ // projection only needs both samples and the target in the same space.
13
+ // Shared by ios/SmoothClipView.mm and android/.../SmoothClipRegistry.cpp; a
14
+ // behavior change here changes both platforms.
15
+ struct VelocitySampleHistory {
16
+ bool hasPrevious = false;
17
+ bool hasLatest = false;
18
+ std::array<double, 7> previous{};
19
+ std::array<double, 7> latest{};
20
+ double previousTimeS = 0;
21
+ double latestTimeS = 0;
22
+ };
23
+
24
+ // Two records closer than this are one observation of the same frame (an
25
+ // ACTION_UP adoption or an animateTo `from` seed lands in the same input
26
+ // batch as the last drag write, ~1 ms apart). The window sits well above
27
+ // batch spacing and below the 8.33 ms 120 Hz frame, so legitimate
28
+ // consecutive-frame samples never coalesce.
29
+ inline constexpr double kSampleCoalesceWindowS = 0.004;
30
+ // A latest sample older than this no longer describes the finger; 'inherit'
31
+ // yields zero instead of replaying stale motion.
32
+ inline constexpr double kVelocityStalenessS = 0.1;
33
+
34
+ inline void recordVelocitySample(
35
+ VelocitySampleHistory &history,
36
+ const std::array<double, 7> &channels,
37
+ double nowS) {
38
+ if (history.hasLatest && channels == history.latest) {
39
+ // Identical re-record (a `from` seed equal to the last interactive
40
+ // write): keep the pair and its timestamps, so the last real motion
41
+ // still ages out through the staleness guard instead of zeroing the
42
+ // sample deltas.
43
+ return;
44
+ }
45
+ if (history.hasLatest &&
46
+ nowS - history.latestTimeS < kSampleCoalesceWindowS) {
47
+ // Same-frame re-record: replace the latest sample in place instead of
48
+ // rotating, so the surviving pair spans a real frame interval. Rotating
49
+ // here would divide the extra sub-frame displacement by microseconds and
50
+ // explode the inherited velocity.
51
+ history.latest = channels;
52
+ history.latestTimeS = nowS;
53
+ return;
54
+ }
55
+ history.hasPrevious = history.hasLatest;
56
+ history.previous = history.latest;
57
+ history.previousTimeS = history.latestTimeS;
58
+ history.hasLatest = true;
59
+ history.latest = channels;
60
+ history.latestTimeS = nowS;
61
+ }
62
+
63
+ // beginInteraction semantics: history restarts from a single fresh sample;
64
+ // one more record is needed before 'inherit' can measure motion.
65
+ inline void resetVelocitySamples(
66
+ VelocitySampleHistory &history,
67
+ const std::array<double, 7> &channels,
68
+ double nowS) {
69
+ history.hasPrevious = false;
70
+ history.hasLatest = true;
71
+ history.latest = channels;
72
+ history.latestTimeS = nowS;
73
+ }
74
+
75
+ inline void clearVelocitySamples(VelocitySampleHistory &history) {
76
+ history = VelocitySampleHistory{};
77
+ }
78
+
79
+ // Normalized velocity of the recorded motion projected onto the
80
+ // latest→target trajectory, in units of remaining-distance per second
81
+ // (1 covers the remaining distance in one second) — the CASpringAnimation
82
+ // initialVelocity convention. iOS passes the scalar unchanged per keypath;
83
+ // Android seeds each integrator channel with scalar·displacement.
84
+ inline double inheritedVelocity(
85
+ const VelocitySampleHistory &history,
86
+ const std::array<double, 7> &target,
87
+ double nowS) {
88
+ if (!history.hasPrevious || !history.hasLatest) return 0;
89
+ const double elapsed = history.latestTimeS - history.previousTimeS;
90
+ if (elapsed <= 0 || nowS - history.latestTimeS > kVelocityStalenessS) {
91
+ return 0;
92
+ }
93
+ double numerator = 0;
94
+ double denominator = 0;
95
+ for (int index = 0; index < 7; index += 1) {
96
+ const double sampleDelta = history.latest[index] - history.previous[index];
97
+ const double destinationDelta = target[index] - history.latest[index];
98
+ numerator += sampleDelta * destinationDelta;
99
+ denominator += destinationDelta * destinationDelta;
100
+ }
101
+ if (denominator <= 1e-12) return 0;
102
+ const double result = numerator / elapsed / denominator;
103
+ return std::isfinite(result) ? result : 0;
104
+ }
105
+
106
+ } // namespace smoothclip
@@ -35,6 +35,8 @@
35
35
  animationId:(int32_t)animationId;
36
36
  - (void)smoothClipCancelAnimationUsingTarget:(BOOL)useTarget;
37
37
  - (BOOL)smoothClipIsJoinable;
38
+ - (BOOL)smoothClipCanDisplay;
39
+ - (BOOL)smoothClipHasPendingInstall;
38
40
  @end
39
41
 
40
42
  namespace smoothclip {
@@ -182,8 +184,12 @@ Presentation canonicalFrozenPresentation(DriverState &state) {
182
184
  : state.latest;
183
185
  bool hasCanonical = false;
184
186
  for (const ViewKey key : state.views) {
185
- const Presentation frozen = [viewForKey(key) smoothClipFreezePresentation];
186
- if (!hasCanonical) {
187
+ // Freeze every view (the teardown side effect must reach all of them),
188
+ // but only a laid-out view may define the canonical geometry — an
189
+ // unlaid-out one reports {0,0,0,0} and would collapse the clip.
190
+ SmoothClipView *candidate = viewForKey(key);
191
+ const Presentation frozen = [candidate smoothClipFreezePresentation];
192
+ if (!hasCanonical && [candidate smoothClipIsJoinable]) {
187
193
  canonical = frozen;
188
194
  hasCanonical = true;
189
195
  }
@@ -286,6 +292,33 @@ void dispatchActiveAnimationJoin(
286
292
  }
287
293
  }
288
294
 
295
+ // An animation may only start once a registered view can actually produce a
296
+ // visible frame. A CA animation committed while its layer tree is detached
297
+ // from the render tree (a transparentModal subtree before UIKit presents its
298
+ // view controller) is removed at the attach commit with finished=NO, which
299
+ // snaps the layer to its model values — the animation's target.
300
+ bool anyDisplayableView(const DriverState &state) {
301
+ for (const ViewKey key : state.views) {
302
+ if ([viewForKey(key) smoothClipCanDisplay]) return true;
303
+ }
304
+ return false;
305
+ }
306
+
307
+ // Starts a latched animation: rebases the clock so no progress was burned
308
+ // while no view could display, then dispatches the join to every registered
309
+ // view. Views that still cannot display defer their install per-view and
310
+ // resume via viewBecameDisplayable.
311
+ void startLatchedAnimation(DriverState &state) {
312
+ state.animation->startedAt = CACurrentMediaTime();
313
+ state.animation->started = true;
314
+ const Presentation start = state.animation->start;
315
+ for (const ViewKey key : state.views) {
316
+ SmoothClipView *participant = viewForKey(key);
317
+ [participant smoothClipApplyPresentation:start];
318
+ dispatchActiveAnimationJoin(state, participant, key, start);
319
+ }
320
+ }
321
+
289
322
  } // namespace
290
323
 
291
324
  void setCompletionCallback(
@@ -320,7 +353,12 @@ void registerView(
320
353
  state.hasLatest = true;
321
354
  }
322
355
  const bool hasAnimation = state.animation.has_value();
323
- const bool startsLatch = hasAnimation && !state.animation->started;
356
+ const bool hasLatch = hasAnimation && !state.animation->started;
357
+ // A latch may only start once this view can produce a visible frame; a
358
+ // registration from a detached subtree (transparentModal before its view
359
+ // controller is presented) keeps the latch held and viewBecameDisplayable
360
+ // starts it inside the attach commit.
361
+ const bool startsLatch = hasLatch && [view smoothClipCanDisplay];
324
362
  const bool shouldReplay =
325
363
  hasAnimation && state.animation->started && !state.views.empty();
326
364
  Presentation visible = state.latest;
@@ -339,7 +377,7 @@ void registerView(
339
377
  visible = reference != nil
340
378
  ? [reference smoothClipCurrentPresentation]
341
379
  : state.animation->start;
342
- } else if (startsLatch) {
380
+ } else if (hasLatch) {
343
381
  // state.latest already holds the latch's TARGET; applying it here is
344
382
  // exactly the jump this path exists to avoid.
345
383
  visible = state.animation->start;
@@ -351,28 +389,56 @@ void registerView(
351
389
  }
352
390
  [view smoothClipApplyPresentation:visible];
353
391
  if (startsLatch) {
354
- // Start the latched animation: rebase the join clock before any elapsed
355
- // math runs (dispatch below and the first-layout joinActiveAnimation).
356
- state.animation->startedAt = CACurrentMediaTime();
357
- state.animation->started = true;
392
+ // Rebases the join clock and dispatches to every registered view (the
393
+ // participant inserts inside the joins also keep unregisterView
394
+ // orphan-safe for a mount-then-unmount before first layout).
395
+ startLatchedAnimation(state);
396
+ return;
358
397
  }
359
- if (!(shouldReplay || startsLatch)) return;
360
-
361
- // The participant insert inside the join is also what keeps unregisterView
362
- // orphan-safe for a mount-then-unmount before first layout.
398
+ if (!shouldReplay) return;
363
399
  dispatchActiveAnimationJoin(state, view, key, visible);
364
400
  }
365
401
 
402
+ void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view) {
403
+ NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
404
+ auto iterator = registry().find(driverId);
405
+ if (iterator == registry().end()) return;
406
+ auto &state = iterator->second;
407
+ if (!state.animation.has_value()) return;
408
+ const ViewKey key = keyForView(view);
409
+ if (std::find(state.views.begin(), state.views.end(), key) ==
410
+ state.views.end()) {
411
+ return;
412
+ }
413
+ if (!state.animation->started) {
414
+ if (![view smoothClipCanDisplay]) return;
415
+ startLatchedAnimation(state);
416
+ return;
417
+ }
418
+ // A running animation whose install this view deferred while it could not
419
+ // display: resume through the standard mid-flight join (original clock —
420
+ // late joiners stay in sync with peers that were already rendering).
421
+ if ([view smoothClipHasPendingInstall]) {
422
+ joinActiveAnimation(driverId, view);
423
+ }
424
+ }
425
+
366
426
  bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
367
427
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
368
428
  auto iterator = registry().find(driverId);
369
- if (iterator == registry().end()) return false;
429
+ if (iterator == registry().end()) {
430
+ return false;
431
+ }
370
432
  auto &state = iterator->second;
371
- if (!state.animation.has_value() || state.views.empty()) return false;
433
+ if (!state.animation.has_value() || state.views.empty()) {
434
+ return false;
435
+ }
372
436
  // Unreachable in the normal flow (a pending install implies the animation
373
437
  // was dispatched, which implies started), but a join must never compute
374
438
  // elapsed time against an un-rebased latch clock.
375
- if (!state.animation->started) return false;
439
+ if (!state.animation->started) {
440
+ return false;
441
+ }
376
442
  const ViewKey key = keyForView(view);
377
443
  if (std::find(state.views.begin(), state.views.end(), key) ==
378
444
  state.views.end()) {
@@ -408,11 +474,50 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
408
474
  state.animation->participants.erase(key) > 0) {
409
475
  state.animation->finished = false;
410
476
  if (state.animation->participants.empty()) {
411
- // The animation ends here; without releasing ownership a host that
412
- // remounts on this driver later would drop every interactive update.
413
- state.ownership = Ownership::Interactive;
414
- emitCompletion(
415
- driverId, state, state.animation->animationId, false);
477
+ auto &active = *state.animation;
478
+ const double elapsedMs =
479
+ (CACurrentMediaTime() - active.startedAt) * 1000.0;
480
+ const double remainingMs = MAX(0, active.durationMs - elapsedMs);
481
+ const bool canResume = !state.destroyed &&
482
+ (active.kind == AnimationKind::Spring || remainingMs > 0);
483
+ if (!canResume) {
484
+ // The animation ends here; without releasing ownership a host that
485
+ // remounts on this driver later would drop every interactive update.
486
+ state.ownership = Ownership::Interactive;
487
+ emitCompletion(driverId, state, active.animationId, false);
488
+ } else {
489
+ // The last rendering host left mid-flight (remount, screen swap).
490
+ // Completing here would leave `latest` at the target and statically
491
+ // snap any re-registering host straight to it. Re-latch instead:
492
+ // freeze the remaining animation at the departing view's visible
493
+ // geometry so the next displayable host resumes it with the true
494
+ // remaining time. Ownership stays Native — the pending animation
495
+ // still owns rendering intent; destroyDriver cancels a latch that
496
+ // never finds a host, so the completion cannot hang.
497
+ const Presentation visible = [view smoothClipIsJoinable]
498
+ ? [view smoothClipCurrentPresentation]
499
+ : active.start;
500
+ if (active.kind == AnimationKind::Timing) {
501
+ active.durationMs = remainingMs;
502
+ active.timing.durationMs = remainingMs;
503
+ } else if (active.kind == AnimationKind::Keyframes) {
504
+ const double progress = active.durationMs <= 0
505
+ ? 1
506
+ : MIN(1, MAX(0, elapsedMs / active.durationMs));
507
+ std::vector<Keyframe> remaining;
508
+ remaining.push_back({0, visible});
509
+ for (const Keyframe &frame : active.keyframes) {
510
+ if (frame.offset <= progress) continue;
511
+ const double offset = (frame.offset - progress) / (1 - progress);
512
+ remaining.push_back({offset, frame.presentation});
513
+ }
514
+ if (remaining.size() == 1) remaining.push_back({1, state.latest});
515
+ active.keyframes = std::move(remaining);
516
+ active.durationMs = remainingMs;
517
+ }
518
+ active.start = visible;
519
+ active.started = false;
520
+ }
416
521
  }
417
522
  }
418
523
  state.views.erase(
@@ -469,6 +574,21 @@ void setPresentation(
469
574
  }
470
575
  if (takeOwnership) {
471
576
  state.destroyed = false;
577
+ // A held latch is strictly newer intent than this write: the latch was
578
+ // created after whatever value the caller read (the hook's seed replays
579
+ // a SharedValue that an earlier animateTo already advanced to its
580
+ // target). Cancelling it here would seed the target and turn the pending
581
+ // animation into a static jump. Callers that want to override a latch
582
+ // cancel it explicitly first.
583
+ if (state.animation.has_value() && !state.animation->started) {
584
+ #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
585
+ if (signpostsEnabled) {
586
+ os_signpost_interval_end(
587
+ signpostLog(), identifier, "lookup-fanout", "ignored=held-latch");
588
+ }
589
+ #endif
590
+ return;
591
+ }
472
592
  cancelActive(driverId, state, false);
473
593
  }
474
594
  applyPresentation(state, presentation);
@@ -514,7 +634,9 @@ int32_t animateTiming(
514
634
  // documented rejection sentinel.
515
635
  return 0;
516
636
  } auto iterator = registry().find(driverId);
517
- if (iterator == registry().end()) return 0;
637
+ if (iterator == registry().end()) {
638
+ return 0;
639
+ }
518
640
  auto &state = iterator->second;
519
641
  state.destroyed = false;
520
642
  const int32_t animationId = allocateAnimationId(state);
@@ -534,13 +656,15 @@ int32_t animateTiming(
534
656
  active.durationMs = animation.durationMs;
535
657
  active.startedAt = CACurrentMediaTime();
536
658
  active.participants.insert(state.views.begin(), state.views.end());
537
- active.started = !state.views.empty();
659
+ active.started = anyDisplayableView(state);
538
660
  state.animation = std::move(active);
539
661
  if (!state.animation->started) {
540
- // Latch: no host view yet (animateTo raced the mount). Ownership stays
662
+ // Latch: no host view can display yet (animateTo raced the mount, or the
663
+ // host sits in a detached subtree awaiting presentation). Ownership stays
541
664
  // Native — the pending animation owns rendering intent — and the first
542
- // registerView rebases startedAt and starts it. The non-zero id is still
543
- // returned synchronously so the JS side does not treat this as rejection.
665
+ // displayable registration/attach rebases startedAt and starts it. The
666
+ // non-zero id is still returned synchronously so the JS side does not
667
+ // treat this as rejection.
544
668
  return animationId;
545
669
  }
546
670
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -586,10 +710,10 @@ int32_t animateSpring(
586
710
  active.spring = animation;
587
711
  active.startedAt = CACurrentMediaTime();
588
712
  active.participants.insert(state.views.begin(), state.views.end());
589
- active.started = !state.views.empty();
713
+ active.started = anyDisplayableView(state);
590
714
  state.animation = std::move(active);
591
715
  if (!state.animation->started) {
592
- // Latched until the first registerView — see animateTiming.
716
+ // Latched until the first displayable view — see animateTiming.
593
717
  return animationId;
594
718
  }
595
719
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -637,10 +761,10 @@ int32_t animateKeyframes(
637
761
  active.durationMs = durationMs;
638
762
  active.startedAt = CACurrentMediaTime();
639
763
  active.participants.insert(state.views.begin(), state.views.end());
640
- active.started = !state.views.empty();
764
+ active.started = anyDisplayableView(state);
641
765
  state.animation = std::move(active);
642
766
  if (!state.animation->started) {
643
- // Latched until the first registerView — see animateTiming.
767
+ // Latched until the first displayable view — see animateTiming.
644
768
  return animationId;
645
769
  }
646
770
  for (const ViewKey key : state.views) {
@@ -3,6 +3,8 @@
3
3
  #import "SmoothClipGeometry.h"
4
4
  #import "SmoothClipViewRegistry.h"
5
5
 
6
+ #include "SmoothClipVelocityTracker.h"
7
+
6
8
  #import <QuartzCore/QuartzCore.h>
7
9
  #import <React/RCTConversions.h>
8
10
  #import <React/RCTFabricComponentsPlugins.h>
@@ -48,6 +50,17 @@ using namespace facebook::react;
48
50
  finished:(BOOL)finished;
49
51
  @end
50
52
 
53
+ static std::array<double, 7> SmoothClipVelocityChannels(
54
+ CGRect rect, CGFloat radius, CGPoint contentTranslation) {
55
+ return {CGRectGetMinX(rect),
56
+ CGRectGetMinY(rect),
57
+ CGRectGetWidth(rect),
58
+ CGRectGetHeight(rect),
59
+ radius,
60
+ contentTranslation.x,
61
+ contentTranslation.y};
62
+ }
63
+
51
64
  @implementation SmoothClipView {
52
65
  SmoothClipContainerView *_clipContainer;
53
66
  SmoothClipContainerView *_contentContainer;
@@ -82,16 +95,10 @@ using namespace facebook::react;
82
95
  CGPoint _pausedContentTranslation;
83
96
  CFTimeInterval _pausedRemaining;
84
97
 
85
- BOOL _hasPreviousInteractiveSample;
86
- BOOL _hasInteractiveSample;
87
- CGRect _previousInteractiveRect;
88
- CGRect _interactiveRect;
89
- CGFloat _previousInteractiveRadius;
90
- CGFloat _interactiveRadius;
91
- CGPoint _previousInteractiveContentTranslation;
92
- CGPoint _interactiveContentTranslation;
93
- CFTimeInterval _previousInteractiveTime;
94
- CFTimeInterval _interactiveTime;
98
+ // 'inherit' velocity samples (normalized geometry, per view); recording/
99
+ // coalescing/projection live in the shared cpp/SmoothClipVelocityTracker.h
100
+ // (behavior-paired with Android's per-driver history).
101
+ smoothclip::VelocitySampleHistory _velocitySamples;
95
102
  }
96
103
 
97
104
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -144,8 +151,7 @@ using namespace facebook::react;
144
151
  _activeAnimationKind = 0;
145
152
  _pendingAnimationInstall = NO;
146
153
  _animationPaused = NO;
147
- _hasPreviousInteractiveSample = NO;
148
- _hasInteractiveSample = NO;
154
+ smoothclip::clearVelocitySamples(_velocitySamples);
149
155
  [[NSNotificationCenter defaultCenter]
150
156
  addObserver:self
151
157
  selector:@selector(applicationWillResignActive)
@@ -167,6 +173,13 @@ using namespace facebook::react;
167
173
  }
168
174
  }
169
175
 
176
+ - (void)didMoveToWindow {
177
+ [super didMoveToWindow];
178
+ if (_driverId != 0 && [self smoothClipCanDisplay]) {
179
+ smoothclip::viewBecameDisplayable(_driverId, self);
180
+ }
181
+ }
182
+
170
183
  - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
171
184
  index:(NSInteger)index {
172
185
  NSAssert(childComponentView.superview == nil,
@@ -298,71 +311,24 @@ using namespace facebook::react;
298
311
  - (void)recordInteractiveRect:(CGRect)rect
299
312
  radius:(CGFloat)radius
300
313
  contentTranslation:(CGPoint)contentTranslation {
301
- if (_hasInteractiveSample &&
302
- CGRectEqualToRect(_interactiveRect, rect) &&
303
- _interactiveRadius == radius &&
304
- CGPointEqualToPoint(
305
- _interactiveContentTranslation, contentTranslation)) {
306
- return;
307
- }
308
- if (_hasInteractiveSample) {
309
- _hasPreviousInteractiveSample = YES;
310
- _previousInteractiveRect = _interactiveRect;
311
- _previousInteractiveRadius = _interactiveRadius;
312
- _previousInteractiveContentTranslation =
313
- _interactiveContentTranslation;
314
- _previousInteractiveTime = _interactiveTime;
315
- }
316
- _hasInteractiveSample = YES;
317
- _interactiveRect = rect;
318
- _interactiveRadius = radius;
319
- _interactiveContentTranslation = contentTranslation;
320
- _interactiveTime = CACurrentMediaTime();
314
+ // Identical-value dedupe and same-frame coalescing live in the shared
315
+ // tracker; coalescing keeps a distinct re-record issued sub-frame after
316
+ // the last one (e.g. a fused animateTo `from` seed at gesture release)
317
+ // from exploding the inherited velocity.
318
+ smoothclip::recordVelocitySample(
319
+ _velocitySamples,
320
+ SmoothClipVelocityChannels(rect, radius, contentTranslation),
321
+ CACurrentMediaTime());
321
322
  }
322
323
 
323
324
  - (CGFloat)inheritedVelocityToRect:(CGRect)target
324
325
  radius:(CGFloat)targetRadius
325
326
  contentTranslation:(CGPoint)targetContentTranslation {
326
- if (!_hasPreviousInteractiveSample || !_hasInteractiveSample) return 0;
327
- const CFTimeInterval elapsed =
328
- _interactiveTime - _previousInteractiveTime;
329
- if (elapsed <= 0 || CACurrentMediaTime() - _interactiveTime > 0.1) return 0;
330
-
331
- const CGFloat current[] = {
332
- CGRectGetMinX(_interactiveRect),
333
- CGRectGetMinY(_interactiveRect),
334
- CGRectGetWidth(_interactiveRect),
335
- CGRectGetHeight(_interactiveRect),
336
- _interactiveRadius,
337
- _interactiveContentTranslation.x,
338
- _interactiveContentTranslation.y};
339
- const CGFloat previous[] = {
340
- CGRectGetMinX(_previousInteractiveRect),
341
- CGRectGetMinY(_previousInteractiveRect),
342
- CGRectGetWidth(_previousInteractiveRect),
343
- CGRectGetHeight(_previousInteractiveRect),
344
- _previousInteractiveRadius,
345
- _previousInteractiveContentTranslation.x,
346
- _previousInteractiveContentTranslation.y};
347
- const CGFloat destination[] = {
348
- CGRectGetMinX(target),
349
- CGRectGetMinY(target),
350
- CGRectGetWidth(target),
351
- CGRectGetHeight(target),
352
- targetRadius,
353
- targetContentTranslation.x,
354
- targetContentTranslation.y};
355
- double numerator = 0;
356
- double denominator = 0;
357
- for (NSUInteger index = 0; index < 7; index += 1) {
358
- const double velocity = (current[index] - previous[index]) / elapsed;
359
- const double displacement = destination[index] - current[index];
360
- numerator += velocity * displacement;
361
- denominator += displacement * displacement;
362
- }
363
- if (denominator <= DBL_EPSILON) return 0;
364
- const double result = numerator / denominator;
365
- return isfinite(result) ? result : 0;
327
+ return smoothclip::inheritedVelocity(
328
+ _velocitySamples,
329
+ SmoothClipVelocityChannels(
330
+ target, targetRadius, targetContentTranslation),
331
+ CACurrentMediaTime());
366
332
  }
367
333
 
368
334
  - (void)smoothClipApplyPresentation:(smoothclip::Presentation)presentation {
@@ -403,6 +369,17 @@ using namespace facebook::react;
403
369
  return _hasLayout;
404
370
  }
405
371
 
372
+ - (BOOL)smoothClipCanDisplay {
373
+ // A CA animation committed while this view's layer tree is detached from
374
+ // the render tree does not survive the later attach commit; installs and
375
+ // latch starts must wait until a frame can actually be produced.
376
+ return _hasLayout && self.window != nil;
377
+ }
378
+
379
+ - (BOOL)smoothClipHasPendingInstall {
380
+ return _pendingAnimationInstall;
381
+ }
382
+
406
383
  - (smoothclip::Presentation)smoothClipFreezePresentation {
407
384
  const smoothclip::Presentation visible =
408
385
  [self smoothClipCurrentPresentation];
@@ -647,7 +624,7 @@ using namespace facebook::react;
647
624
  }
648
625
 
649
626
  - (void)startAnimationToRequestedGeometryWithDuration:(CFTimeInterval)duration {
650
- if (!_hasLayout) {
627
+ if (![self smoothClipCanDisplay]) {
651
628
  _pendingAnimationInstall = YES;
652
629
  return;
653
630
  }
@@ -720,7 +697,7 @@ using namespace facebook::react;
720
697
  [self storeRequestedPresentation:presentation];
721
698
  _activeAnimationKind = 3;
722
699
  _activeAnimationId = animationId;
723
- if (!_hasLayout) {
700
+ if (![self smoothClipCanDisplay]) {
724
701
  _pendingAnimationInstall = YES;
725
702
  return;
726
703
  }
@@ -937,8 +914,14 @@ using namespace facebook::react;
937
914
  // the true remaining duration instead of jumping to the target.
938
915
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
939
916
  _hasLayout = YES;
940
- _pendingAnimationInstall = NO;
941
917
  [self layoutContentContainer];
918
+ if (self.window == nil) {
919
+ // Still detached (transparentModal subtree before presentation): an
920
+ // install here would not survive the attach commit. Keep the install
921
+ // pending; didMoveToWindow completes it.
922
+ return;
923
+ }
924
+ _pendingAnimationInstall = NO;
942
925
  if (!smoothclip::joinActiveAnimation(_driverId, self)) {
943
926
  // The animation finished or was cancelled while awaiting layout.
944
927
  _activeAnimationId = 0;
@@ -984,6 +967,14 @@ using namespace facebook::react;
984
967
  [self writeLayerRect:paused.rect radius:paused.radius];
985
968
  }
986
969
  } else if (wasAnimating) {
970
+ if (![self smoothClipCanDisplay]) {
971
+ // Relayout while detached, with an animation dispatched to this view
972
+ // (a transparentModal subtree resized before presentation): an install
973
+ // from this commit would not survive the attach commit. Keep the
974
+ // install deferred; didMoveToWindow completes it via the registry join.
975
+ _pendingAnimationInstall = YES;
976
+ return;
977
+ }
987
978
  const int32_t animationId = _activeAnimationId;
988
979
  const NSInteger animationKind = _activeAnimationKind;
989
980
  [self stopLayerAnimationWithoutCallback];
@@ -1081,8 +1072,7 @@ using namespace facebook::react;
1081
1072
  [self stopLayerAnimationWithoutCallback];
1082
1073
  _pendingAnimationInstall = NO;
1083
1074
  _animationPaused = NO;
1084
- _hasPreviousInteractiveSample = NO;
1085
- _hasInteractiveSample = NO;
1075
+ smoothclip::clearVelocitySamples(_velocitySamples);
1086
1076
  [super prepareForRecycle];
1087
1077
  _requestedClip = CGRectZero;
1088
1078
  _normalizedClip = CGRectZero;
@@ -16,6 +16,14 @@ void unregisterView(uint64_t driverId, SmoothClipView *view);
16
16
  // install was deferred because it had no layout yet. Returns false when the
17
17
  // driver or its active animation no longer exists.
18
18
  bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view);
19
+ // Called when a registered view first becomes able to produce a visible
20
+ // frame (has layout AND is attached to a window). Starts a latched animation
21
+ // — rebasing its clock so no progress is burned while the view was
22
+ // detached — or completes a deferred install for an already-running one.
23
+ // A CA animation committed while the host's layer tree is detached (e.g. a
24
+ // transparentModal subtree before its view controller is presented) does not
25
+ // survive the attach commit; installs must therefore wait for this signal.
26
+ void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view);
19
27
  void viewAnimationDidStop(
20
28
  uint64_t driverId,
21
29
  int32_t animationId,
@@ -45,6 +45,11 @@ function reduceMotionCode(value) {
45
45
  function animationIsFinite(target, animation) {
46
46
  'worklet';
47
47
 
48
+ if (animation.from !== undefined && !isFiniteClipPresentation(animation.from)) {
49
+ // Silently dropping an invalid explicit start would re-open the exact
50
+ // stale-start handoff `from` exists to close — reject the whole call.
51
+ return false;
52
+ }
48
53
  if (animation.type === 'timing') {
49
54
  return Number.isFinite(animation.duration) && animation.controlPoints.every(Number.isFinite);
50
55
  }
@@ -165,6 +170,15 @@ export function useSmoothClipDriver(initialValue, options = {}) {
165
170
  if (!isFiniteClipPresentation(target) || !animationIsFinite(target, animation)) {
166
171
  return rejectAnimationHostFunction(driverId);
167
172
  }
173
+ const from = animation.from;
174
+ if (from !== undefined) {
175
+ // Fused hot write: exactly setScalars(from…) issued immediately
176
+ // before the handoff, so native's latest value — the animation
177
+ // start below — is the caller's explicit presentation. Also
178
+ // re-grabs from a running animation, which the implicit
179
+ // interactive-start path would silently skip.
180
+ setScalarsOnUI(from.clip.x, from.clip.y, from.clip.width, from.clip.height, from.clip.radius, from.contentTranslateX, from.contentTranslateY);
181
+ }
168
182
 
169
183
  // After a setScalars hot write the SharedValue no longer matches what
170
184
  // is on screen; passing it as the start would snap the animation back.