react-native-smooth-clip-view 0.2.6 → 0.2.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.
@@ -43,6 +43,7 @@
43
43
  - (BOOL)smoothClipCanDisplay;
44
44
  - (BOOL)smoothClipHasPendingInstall;
45
45
  - (double)smoothClipSpringContinuationVelocity;
46
+ - (void)smoothClipClearVelocitySamples;
46
47
  @end
47
48
 
48
49
  namespace smoothclip {
@@ -56,6 +57,10 @@ enum class AnimationKind { Timing, Spring, Keyframes };
56
57
  struct ActiveAnimation {
57
58
  int32_t animationId;
58
59
  std::unordered_set<ViewKey> participants;
60
+ // Hosts that installed this id and temporarily lost displayability. They
61
+ // may rejoin the same remainder; completion is false if any remain
62
+ // unresolved when the run ends or unregister.
63
+ std::unordered_set<ViewKey> suspendedParticipants;
59
64
  bool finished = true;
60
65
  AnimationKind kind = AnimationKind::Timing;
61
66
  // Geometry the transition started from. Used when a view must join the
@@ -69,10 +74,10 @@ struct ActiveAnimation {
69
74
  // False while the animation is latched. Two ways in: built before any host
70
75
  // view could display (pre-registration, or a host in a detached subtree), or
71
76
  // re-latched by unregisterView when the last DISPLAYABLE host left
72
- // mid-flight. Started by whichever of registerView, viewBecameDisplayable
73
- // (window attach or first layout) or joinActiveAnimation first finds a host
74
- // that can produce a frame; each rebases startedAt through
75
- // startLatchedAnimation. startedAt is only meaningful once started.
77
+ // mid-flight. Started by whichever registration/displayability update or
78
+ // joinActiveAnimation first finds a host that can produce a frame; each
79
+ // rebases startedAt through startLatchedAnimation. startedAt is only
80
+ // meaningful once started.
76
81
  bool started = false;
77
82
  };
78
83
 
@@ -98,6 +103,41 @@ std::unordered_map<uint64_t, DriverState> &registry() {
98
103
  return value;
99
104
  }
100
105
 
106
+ // The transitions must be observed for the whole process lifetime, not per
107
+ // view: the state below is process-global, and a notification delivered while
108
+ // no SmoothClipView happens to be alive (the last host unmounted during
109
+ // inactivity, or the app backgrounded between screens) must still land.
110
+ // View-held observers die with their views and left the flag permanently
111
+ // stale in exactly those windows.
112
+ void installApplicationStateObservers() {
113
+ static dispatch_once_t once;
114
+ dispatch_once(&once, ^{
115
+ NSNotificationCenter *center = NSNotificationCenter.defaultCenter;
116
+ [center addObserverForName:UIApplicationWillResignActiveNotification
117
+ object:nil
118
+ queue:nil
119
+ usingBlock:^(NSNotification *) {
120
+ applicationWillResignActive();
121
+ }];
122
+ [center addObserverForName:UIApplicationDidBecomeActiveNotification
123
+ object:nil
124
+ queue:nil
125
+ usingBlock:^(NSNotification *) {
126
+ applicationDidBecomeActive();
127
+ }];
128
+ });
129
+ }
130
+
131
+ bool &applicationActiveState() {
132
+ installApplicationStateObservers();
133
+ // The first SmoothClipView can be created after the app already entered the
134
+ // background, too late to observe WillResignActive. Seed from UIKit rather
135
+ // than assuming foreground so that first registration cannot start offscreen.
136
+ static bool active = UIApplication.sharedApplication.applicationState ==
137
+ UIApplicationStateActive;
138
+ return active;
139
+ }
140
+
101
141
  CompletionSink &completionSink() {
102
142
  static CompletionSink sink;
103
143
  return sink;
@@ -176,7 +216,9 @@ void emitStandaloneCompletion(
176
216
  #endif
177
217
  }
178
218
 
179
- Presentation canonicalFrozenPresentation(DriverState &state) {
219
+ Presentation canonicalFrozenPresentation(
220
+ DriverState &state,
221
+ SmoothClipView *preferred = nil) {
180
222
  // A latched animation never rendered, so state.latest already holds its
181
223
  // target; freezing there would jump the clip. Freeze at the start instead.
182
224
  Presentation canonical =
@@ -184,17 +226,35 @@ Presentation canonicalFrozenPresentation(DriverState &state) {
184
226
  ? state.animation->start
185
227
  : state.latest;
186
228
  bool hasCanonical = false;
229
+ bool hasParticipantFallback = false;
230
+ Presentation participantFallback{{0, 0, 0, 0, 0}, 0, 0};
187
231
  for (const ViewKey key : state.views) {
188
232
  // Freeze every view (the teardown side effect must reach all of them),
189
233
  // but only a laid-out view may define the canonical geometry — an
190
234
  // unlaid-out one reports {0,0,0,0} and would collapse the clip.
191
235
  SmoothClipView *candidate = viewForKey(key);
192
236
  const Presentation frozen = [candidate smoothClipFreezePresentation];
193
- if (!hasCanonical && [candidate smoothClipIsJoinable]) {
237
+ if (candidate == preferred && [candidate smoothClipIsJoinable]) {
238
+ canonical = frozen;
239
+ hasCanonical = true;
240
+ } else if (!hasCanonical && [candidate smoothClipIsJoinable]) {
194
241
  canonical = frozen;
195
242
  hasCanonical = true;
243
+ } else if (!hasParticipantFallback && state.animation.has_value() &&
244
+ (state.animation->participants.count(key) > 0 ||
245
+ state.animation->suspendedParticipants.count(key) > 0)) {
246
+ // An installed participant whose HOST just lost its size still holds
247
+ // real mid-flight geometry on its clip layer (host bounds do not feed
248
+ // the clip container). Without this fallback, a sole running host
249
+ // resized to zero froze the re-latch at state.latest — the target —
250
+ // and the resumed animation snapped to its endpoint.
251
+ hasParticipantFallback = true;
252
+ participantFallback = frozen;
196
253
  }
197
254
  }
255
+ if (!hasCanonical && hasParticipantFallback) {
256
+ canonical = participantFallback;
257
+ }
198
258
  return canonical;
199
259
  }
200
260
 
@@ -219,7 +279,8 @@ void finishIfNoInstalledParticipants(uint64_t driverId, DriverState &state) {
219
279
  return;
220
280
  }
221
281
  const int32_t animationId = state.animation->animationId;
222
- const bool finished = state.animation->finished;
282
+ const bool finished = state.animation->finished &&
283
+ state.animation->suspendedParticipants.empty();
223
284
  // A detached/unlaid-out view can have a requested animation without any CA
224
285
  // delegate capable of completing it. Once every installed animation ends,
225
286
  // make those deferred views statically correct and invalidate their pending
@@ -231,12 +292,16 @@ void finishIfNoInstalledParticipants(uint64_t driverId, DriverState &state) {
231
292
  emitCompletion(driverId, state, animationId, finished);
232
293
  }
233
294
 
234
- void applyPresentation(DriverState &state, Presentation presentation) {
295
+ void applyPresentation(
296
+ DriverState &state,
297
+ Presentation presentation,
298
+ bool recordVelocitySample = true) {
235
299
  state.latest = presentation;
236
300
  state.hasLatest = true;
237
301
  state.ownership = Ownership::Interactive;
238
302
  for (const ViewKey key : state.views) {
239
- [viewForKey(key) smoothClipApplyPresentation:presentation];
303
+ [viewForKey(key) smoothClipApplyPresentation:presentation
304
+ recordVelocitySample:recordVelocitySample];
240
305
  }
241
306
  }
242
307
 
@@ -324,7 +389,10 @@ void dispatchActiveAnimationJoin(
324
389
  durationMs:remainingMs
325
390
  animationId:active.animationId];
326
391
  }
327
- if (installed) active.participants.insert(key);
392
+ if (installed) {
393
+ active.participants.insert(key);
394
+ active.suspendedParticipants.erase(key);
395
+ }
328
396
  }
329
397
 
330
398
  // An animation may only start once a registered view can actually produce a
@@ -339,10 +407,83 @@ bool anyDisplayableView(const DriverState &state) {
339
407
  return false;
340
408
  }
341
409
 
410
+ void relatchActiveAnimation(
411
+ uint64_t driverId,
412
+ DriverState &state,
413
+ SmoothClipView *preferred = nil) {
414
+ if (!state.animation.has_value() || !state.animation->started) return;
415
+ auto &active = *state.animation;
416
+ double springVelocity = 0;
417
+ if (active.kind == AnimationKind::Spring) {
418
+ SmoothClipView *source = preferred;
419
+ if (source == nil ||
420
+ active.participants.count(keyForView(source)) == 0) {
421
+ source = active.participants.empty()
422
+ ? nil
423
+ : viewForKey(*active.participants.begin());
424
+ }
425
+ if (source != nil) {
426
+ springVelocity = [source smoothClipSpringContinuationVelocity];
427
+ }
428
+ }
429
+ const Presentation frozen = canonicalFrozenPresentation(state, preferred);
430
+ // Captured before the migration below: only hosts that were ALREADY
431
+ // suspended are unresolved. The active participants being frozen right now
432
+ // rendered every frame up to this instant.
433
+ const bool hadUnresolvedSuspended = !active.suspendedParticipants.empty();
434
+ active.suspendedParticipants.insert(
435
+ active.participants.begin(), active.participants.end());
436
+ active.participants.clear();
437
+
438
+ const double elapsedMs = MAX(
439
+ 0, (CACurrentMediaTime() - active.startedAt) * 1000.0);
440
+ const double progress = active.durationMs <= 0
441
+ ? 1
442
+ : MIN(1, MAX(0, elapsedMs / active.durationMs));
443
+ if (active.kind == AnimationKind::Timing) {
444
+ active.timing = timingRemainder(active.timing, progress).animation;
445
+ active.durationMs = active.timing.durationMs;
446
+ } else if (active.kind == AnimationKind::Keyframes) {
447
+ const KeyframeContinuation continuation = keyframeContinuation(
448
+ active.keyframes,
449
+ frozen,
450
+ state.latest,
451
+ active.durationMs,
452
+ progress);
453
+ active.keyframes = continuation.frames;
454
+ active.durationMs = continuation.durationMs;
455
+ } else {
456
+ active.spring.inheritVelocity = false;
457
+ active.spring.initialVelocity = springVelocity;
458
+ }
459
+
460
+ if (state.destroyed ||
461
+ (active.kind != AnimationKind::Spring && active.durationMs <= 0)) {
462
+ // A residual trimmed to zero means the curve fully elapsed while a host
463
+ // displayed every frame — only the asynchronous didStop was outstanding
464
+ // when this freeze arrived. Report that run honestly instead of stamping
465
+ // the freeze-path false; hosts that were suspended before the freeze are
466
+ // unresolved and still poison it.
467
+ const bool ranToEnd = !state.destroyed && progress >= 1 &&
468
+ active.finished && !hadUnresolvedSuspended;
469
+ // Deferred peers hold pending installs for this id; invalidate them and
470
+ // finalize statically at the target, exactly as the didStop completion
471
+ // path does. Frozen participants already have no animation to cancel.
472
+ for (const ViewKey key : state.views) {
473
+ [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
474
+ }
475
+ state.ownership = Ownership::Interactive;
476
+ emitCompletion(driverId, state, active.animationId, ranToEnd);
477
+ return;
478
+ }
479
+ active.start = frozen;
480
+ active.started = false;
481
+ }
482
+
342
483
  // Starts a latched animation: rebases the clock so no progress was burned
343
484
  // while no view could display, then dispatches the join to every registered
344
485
  // view. Views that still cannot display defer their install per-view and
345
- // resume via viewBecameDisplayable.
486
+ // resume via a later positive displayability update.
346
487
  void startLatchedAnimation(DriverState &state) {
347
488
  state.animation->startedAt = CACurrentMediaTime();
348
489
  state.animation->started = true;
@@ -352,13 +493,39 @@ void startLatchedAnimation(DriverState &state) {
352
493
  const Presentation start = state.animation->start;
353
494
  for (const ViewKey key : state.views) {
354
495
  SmoothClipView *participant = viewForKey(key);
355
- [participant smoothClipApplyPresentation:start];
496
+ [participant smoothClipApplyPresentation:start recordVelocitySample:NO];
356
497
  dispatchActiveAnimationJoin(state, participant, key, start);
357
498
  }
358
499
  }
359
500
 
360
501
  } // namespace
361
502
 
503
+ bool applicationIsActive() {
504
+ return applicationActiveState();
505
+ }
506
+
507
+ void applicationWillResignActive() {
508
+ NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
509
+ if (!applicationActiveState()) return;
510
+ applicationActiveState() = false;
511
+ for (auto &[driverId, state] : registry()) {
512
+ relatchActiveAnimation(driverId, state);
513
+ }
514
+ }
515
+
516
+ void applicationDidBecomeActive() {
517
+ NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
518
+ if (applicationActiveState()) return;
519
+ applicationActiveState() = true;
520
+ for (auto &[driverId, state] : registry()) {
521
+ (void)driverId;
522
+ if (state.animation.has_value() && !state.animation->started &&
523
+ anyDisplayableView(state)) {
524
+ startLatchedAnimation(state);
525
+ }
526
+ }
527
+ }
528
+
362
529
  void setCompletionCallback(
363
530
  const void *owner,
364
531
  CompletionCallback callback) {
@@ -394,8 +561,8 @@ void registerView(
394
561
  const bool hasLatch = hasAnimation && !state.animation->started;
395
562
  // A latch may only start once this view can produce a visible frame; a
396
563
  // registration from a detached subtree (transparentModal before its view
397
- // controller is presented) keeps the latch held and viewBecameDisplayable
398
- // starts it inside the attach commit.
564
+ // controller is presented) keeps the latch held; the positive
565
+ // displayability update starts it inside the attach commit.
399
566
  const bool startsLatch = hasLatch && [view smoothClipCanDisplay];
400
567
  const bool shouldReplay =
401
568
  hasAnimation && state.animation->started && !state.views.empty();
@@ -425,7 +592,7 @@ void registerView(
425
592
  state.views.end()) {
426
593
  state.views.push_back(key);
427
594
  }
428
- [view smoothClipApplyPresentation:visible];
595
+ [view smoothClipApplyPresentation:visible recordVelocitySample:NO];
429
596
  if (startsLatch) {
430
597
  // Rebase the join clock and dispatch to every registered view. Only views
431
598
  // that actually install their CA groups become completion participants.
@@ -436,7 +603,7 @@ void registerView(
436
603
  dispatchActiveAnimationJoin(state, view, key, visible, reference);
437
604
  }
438
605
 
439
- void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view) {
606
+ void viewDisplayabilityChanged(uint64_t driverId, SmoothClipView *view) {
440
607
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
441
608
  auto iterator = registry().find(driverId);
442
609
  if (iterator == registry().end()) return;
@@ -447,15 +614,25 @@ void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view) {
447
614
  state.views.end()) {
448
615
  return;
449
616
  }
617
+ if (![view smoothClipCanDisplay]) {
618
+ if (!state.animation->started) return;
619
+ if (state.animation->participants.count(key) == 0) return;
620
+ if (!anyDisplayableView(state)) {
621
+ relatchActiveAnimation(driverId, state, view);
622
+ return;
623
+ }
624
+ [view smoothClipFreezePresentation];
625
+ state.animation->participants.erase(key);
626
+ state.animation->suspendedParticipants.insert(key);
627
+ return;
628
+ }
450
629
  if (!state.animation->started) {
451
- if (![view smoothClipCanDisplay]) return;
452
630
  startLatchedAnimation(state);
453
631
  return;
454
632
  }
455
- // A running animation whose install this view deferred while it could not
456
- // display: resume through the standard mid-flight join (original clock —
457
- // late joiners stay in sync with peers that were already rendering).
458
- if ([view smoothClipHasPendingInstall]) {
633
+ // A deferred or suspended host joins from the registry's rebased remainder.
634
+ if ([view smoothClipHasPendingInstall] ||
635
+ state.animation->suspendedParticipants.count(key) > 0) {
459
636
  joinActiveAnimation(driverId, view);
460
637
  }
461
638
  }
@@ -503,7 +680,7 @@ bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
503
680
  const Presentation visible = reference != nil
504
681
  ? [reference smoothClipCurrentPresentation]
505
682
  : state.animation->start;
506
- [view smoothClipApplyPresentation:visible];
683
+ [view smoothClipApplyPresentation:visible recordVelocitySample:NO];
507
684
  dispatchActiveAnimationJoin(state, view, key, visible, reference);
508
685
  return true;
509
686
  }
@@ -515,117 +692,33 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
515
692
 
516
693
  auto &state = iterator->second;
517
694
  const ViewKey key = keyForView(view);
518
- state.views.erase(
519
- std::remove(state.views.begin(), state.views.end(), key),
520
- state.views.end());
521
- if (state.animation.has_value() &&
522
- state.animation->participants.erase(key) > 0) {
523
- state.animation->finished = false;
524
- if (!state.animation->started) {
525
- // A held latch remains pending for the next displayable registration.
526
- // Removing this participant also makes any late callback a no-op.
527
- } else if (!anyDisplayableView(state)) {
528
- auto &active = *state.animation;
529
- // Freeze before removing/reusing the layer. stopLayerAnimation invalidates
530
- // its delegate, and erasing the participant above independently rejects
531
- // any callback already queued by CoreAnimation.
532
- //
533
- // The freeze is a teardown side effect that must reach the departing
534
- // view either way, but only a laid-out view may *define* the frozen
535
- // geometry — smoothClipCurrentPresentation reports {0,0,0,0} for one
536
- // that never laid out, and re-latching there would resume the animation
537
- // out of a collapsed clip. Same filter canonicalFrozenPresentation uses:
538
- // this view if joinable, else a laid-out survivor, else the start.
539
- double springContinuationVelocity = 0;
540
- if (active.kind == AnimationKind::Spring &&
541
- [view smoothClipIsJoinable]) {
542
- springContinuationVelocity =
543
- [view smoothClipSpringContinuationVelocity];
544
- }
545
- const Presentation frozen = [view smoothClipFreezePresentation];
546
- Presentation visible = active.start;
547
- if ([view smoothClipIsJoinable]) {
548
- visible = frozen;
549
- } else {
550
- for (const ViewKey candidate : state.views) {
551
- SmoothClipView *candidateView = viewForKey(candidate);
552
- if (![candidateView smoothClipIsJoinable]) continue;
553
- visible = [candidateView smoothClipCurrentPresentation];
554
- if (active.kind == AnimationKind::Spring) {
555
- springContinuationVelocity =
556
- [candidateView smoothClipSpringContinuationVelocity];
557
- }
695
+ bool installed = false;
696
+ if (state.animation.has_value()) {
697
+ installed = state.animation->participants.count(key) > 0 ||
698
+ state.animation->suspendedParticipants.count(key) > 0;
699
+ if (state.animation->started &&
700
+ state.animation->participants.count(key) > 0) {
701
+ bool otherDisplayable = false;
702
+ for (const ViewKey candidate : state.views) {
703
+ if (candidate != key && [viewForKey(candidate) smoothClipCanDisplay]) {
704
+ otherDisplayable = true;
558
705
  break;
559
706
  }
560
707
  }
561
- const double elapsedMs =
562
- (CACurrentMediaTime() - active.startedAt) * 1000.0;
563
- const double remainingMs = MAX(0, active.durationMs - elapsedMs);
564
- const bool canResume = !state.destroyed &&
565
- (active.kind == AnimationKind::Spring || remainingMs > 0);
566
- if (!canResume) {
567
- // The animation ends here; without releasing ownership a host that
568
- // remounts on this driver later would drop every interactive update.
569
- state.ownership = Ownership::Interactive;
570
- emitCompletion(driverId, state, active.animationId, false);
571
- } else {
572
- // The last displayable host left mid-flight. A registered detached or
573
- // unlaid-out peer must not let the animation finish invisibly.
574
- // Completing here would leave `latest` at the target and statically
575
- // snap any re-registering host straight to it. Re-latch instead:
576
- // freeze the remaining animation at the visible geometry resolved
577
- // above so the next displayable host resumes it with the true
578
- // remaining time. Ownership stays Native — the pending animation
579
- // still owns rendering intent; destroyDriver cancels a latch that
580
- // never finds a host, so the completion cannot hang.
581
- if (active.kind == AnimationKind::Timing) {
582
- const double progress = active.durationMs <= 0
583
- ? 1
584
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
585
- active.timing = timingRemainder(active.timing, progress).animation;
586
- active.durationMs = active.timing.durationMs;
587
- } else if (active.kind == AnimationKind::Keyframes) {
588
- const double progress = active.durationMs <= 0
589
- ? 1
590
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
591
- std::vector<Keyframe> remaining;
592
- remaining.push_back({0, visible});
593
- for (const Keyframe &frame : active.keyframes) {
594
- if (frame.offset <= progress) continue;
595
- const double offset = (frame.offset - progress) / (1 - progress);
596
- remaining.push_back({offset, frame.presentation});
597
- }
598
- if (remaining.size() == 1) remaining.push_back({1, state.latest});
599
- active.keyframes = std::move(remaining);
600
- active.durationMs = remainingMs;
601
- } else if (active.kind == AnimationKind::Spring) {
602
- // The departing view resolved `inherit` before installing its CA
603
- // spring. Re-express that spring's analytic velocity against the
604
- // frozen remaining distance, so the resumed spring has the same
605
- // physical derivative instead of restarting at rest.
606
- active.spring.inheritVelocity = false;
607
- active.spring.initialVelocity = springContinuationVelocity;
608
- }
609
- active.start = visible;
610
- active.started = false;
708
+ if (!otherDisplayable) {
709
+ relatchActiveAnimation(driverId, state, view);
611
710
  }
612
- } else if (state.animation->participants.empty()) {
613
- // Every participant has either reported its stop or unregistered, yet a
614
- // registered host can still display. viewAnimationDidStop is the only
615
- // other exhaustion path and it can never run again for this id, so
616
- // nothing is left to complete the animation: end it here.
617
- //
618
- // Reachable only with several hosts on one driver, where per-view spring
619
- // settling durations let one host finish while another is still running
620
- // (smoothClipAnimateSpring resolves 'inherit' per view, and
621
- // springSettlingDurationWithVelocity derives the group duration from it).
622
- //
623
- // This branch MUST stay below the displayability test. A sole host that
624
- // unregisters mid-flight also empties `participants`, and that case must
625
- // RE-LATCH rather than complete — the next displayable host resuming the
626
- // remainder is the entire point of the latch. Testing displayability
627
- // first keeps the one-view-per-driver path on the branch above, which
628
- // makes this one unreachable for a single host by construction.
711
+ }
712
+ }
713
+
714
+ state.views.erase(
715
+ std::remove(state.views.begin(), state.views.end(), key),
716
+ state.views.end());
717
+ if (state.animation.has_value()) {
718
+ state.animation->participants.erase(key);
719
+ state.animation->suspendedParticipants.erase(key);
720
+ if (installed) state.animation->finished = false;
721
+ if (state.animation->started && state.animation->participants.empty()) {
629
722
  finishIfNoInstalledParticipants(driverId, state);
630
723
  }
631
724
  }
@@ -753,7 +846,7 @@ int32_t animateTiming(
753
846
  prepareAnimation(driverId, state, start, presentation);
754
847
 
755
848
  if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
756
- applyPresentation(state, presentation);
849
+ applyPresentation(state, presentation, false);
757
850
  emitCompletion(driverId, state, animationId, true);
758
851
  return animationId;
759
852
  }
@@ -815,7 +908,7 @@ int32_t animateSpring(
815
908
  prepareAnimation(driverId, state, start, presentation);
816
909
 
817
910
  if (shouldReduceMotion(animation.reduceMotion)) {
818
- applyPresentation(state, presentation);
911
+ applyPresentation(state, presentation, false);
819
912
  emitCompletion(driverId, state, animationId, true);
820
913
  return animationId;
821
914
  }
@@ -872,7 +965,7 @@ int32_t animateKeyframes(
872
965
  const Presentation resolvedStart = resolvedAnimationStart(state, start);
873
966
  prepareAnimation(driverId, state, start, presentation);
874
967
  if (shouldReduceMotion(reduceMotion) || durationMs <= 0) {
875
- applyPresentation(state, presentation);
968
+ applyPresentation(state, presentation, false);
876
969
  emitCompletion(driverId, state, animationId, true);
877
970
  return animationId;
878
971
  }
@@ -977,6 +1070,12 @@ void destroyDriver(uint64_t driverId) {
977
1070
  if (iterator == registry().end()) return;
978
1071
  auto &state = iterator->second;
979
1072
  cancelActive(driverId, state, false);
1073
+ // A destroyed driver's interaction history must not seed a revived
1074
+ // incarnation: the revival seed would otherwise pair with these samples and
1075
+ // refresh the staleness clock with motion no finger produced.
1076
+ for (const ViewKey key : state.views) {
1077
+ [viewForKey(key) smoothClipClearVelocitySamples];
1078
+ }
980
1079
  if (state.views.empty()) {
981
1080
  registry().erase(iterator);
982
1081
  } else {