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

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/README.md CHANGED
@@ -198,6 +198,13 @@ const gesture = Gesture.Pan()
198
198
  `finished` state, including cancellation, replacement, participant unmount,
199
199
  and native-side rejection (`animateTo` then returns `0` and one
200
200
  `finished: false` completion is delivered).
201
+ - An `animateTo` issued before any host view has registered (for example from
202
+ an effect in the same commit that mounts the host, or inside a modal route
203
+ that attaches late) is held pending and starts with its full duration when
204
+ the first view registers. If no view ever registers, the pending animation
205
+ survives until it is replaced, cancelled, overridden by a take-ownership
206
+ write, or the driver is destroyed — at which point its single
207
+ `finished: false` completion is delivered.
201
208
 
202
209
  Do not call `driver.ui` from React code — it throws on the React runtime; use
203
210
  `driver.react` there. During a native transition, `driver.presentation.value`
@@ -47,6 +47,10 @@ struct ActiveAnimation {
47
47
  std::array<double, 7> springVelocity{};
48
48
  double lastFrameS = 0;
49
49
  bool finished = true;
50
+ // False while the animation is latched: built before any host view
51
+ // registered, held un-rendered (and out of animatingDrivers()) until the
52
+ // first registerViewAndroid rebases the clock and starts it.
53
+ bool started = false;
50
54
  };
51
55
 
52
56
  // Per-view fanout state. Density and host metrics are pushed from Kotlin at
@@ -457,17 +461,23 @@ Presentation prepareAnimation(
457
461
  int32_t startAnimation(
458
462
  uint64_t driverId,
459
463
  DriverState &state,
460
- ActiveAnimation animation,
461
- const Presentation &target) {
462
- if (state.views.empty()) {
463
- applyPresentation(state, target);
464
- emitCompletion(driverId, animation.id, true);
465
- return animation.id;
466
- }
464
+ ActiveAnimation animation) {
467
465
  animation.startedAtS = nowSeconds();
468
466
  animation.lastFrameS = animation.startedAtS;
467
+ // current = start even while latched is load-bearing: cancelAnimation,
468
+ // beginInteraction, prepareAnimation's visibleBefore and registerView's
469
+ // visible all read animation->current, giving a never-rendered latch
470
+ // freeze-at-start / replace-from-start semantics with no extra branches.
469
471
  animation.current = animation.start;
472
+ animation.started = !state.views.empty();
470
473
  state.animation = std::move(animation);
474
+ if (!state.animation->started) {
475
+ // Latch: no host view yet (animateTo raced the mount). The first
476
+ // registerViewAndroid rebases the clock, joins animatingDrivers() and
477
+ // schedules the frame loop. Non-zero id is still returned so the JS
478
+ // side does not treat this as rejection.
479
+ return state.animation->id;
480
+ }
471
481
  auto &active = animatingDrivers();
472
482
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
473
483
  active.push_back(driverId);
@@ -652,7 +662,7 @@ int32_t animateTiming(
652
662
  active.start = resolvedStart;
653
663
  active.target = presentation;
654
664
  active.durationS = animation.durationMs / 1000.0;
655
- return startAnimation(driverId, state, std::move(active), presentation);
665
+ return startAnimation(driverId, state, std::move(active));
656
666
  }
657
667
 
658
668
  int32_t animateSpring(
@@ -695,7 +705,7 @@ int32_t animateSpring(
695
705
  active.springVelocity[index] =
696
706
  velocity * (targetChannels[index] - startChannels[index]);
697
707
  }
698
- return startAnimation(driverId, state, std::move(active), presentation);
708
+ return startAnimation(driverId, state, std::move(active));
699
709
  }
700
710
 
701
711
  int32_t animateKeyframes(
@@ -726,7 +736,7 @@ int32_t animateKeyframes(
726
736
  active.start = resolvedStart;
727
737
  active.target = presentation;
728
738
  active.durationS = durationMs / 1000.0;
729
- return startAnimation(driverId, state, std::move(active), presentation);
739
+ return startAnimation(driverId, state, std::move(active));
730
740
  }
731
741
 
732
742
  int32_t rejectAnimation(uint64_t driverId) {
@@ -839,6 +849,9 @@ void registerViewAndroid(
839
849
  ? state.animation->current
840
850
  : state.latest;
841
851
  JNIEnv *env = facebook::jni::Environment::current();
852
+ // The existing-view branch below is unreachable while an animation is
853
+ // latched (a latch implies views was empty at animate time), so only the
854
+ // new-view path needs to start latches.
842
855
  for (auto &existing : state.views) {
843
856
  if (env->IsSameObject(existing.view.get(), view.get())) {
844
857
  existing.density = density;
@@ -850,8 +863,27 @@ void registerViewAndroid(
850
863
  }
851
864
  ViewEntry entry{
852
865
  facebook::jni::make_global(view), density, hostWidthPx, hostHeightPx};
866
+ // `visible` above already delivered animation->current (= start) to the
867
+ // registering view — the correct first frame for a latched animation.
853
868
  deliverToView(entry, visible);
854
869
  state.views.push_back(std::move(entry));
870
+ if (state.animation.has_value() && !state.animation->started) {
871
+ // Start the latched animation: rebase the clock so the first frame
872
+ // integrates from now, not from the pre-mount animateTo call.
873
+ // Note this function has no isOnMainThread() guard (Kotlin calls it from
874
+ // the view's main-thread attach path); scheduleFrame() is itself
875
+ // main-thread-gated, so an off-main register cannot bind the
876
+ // choreographer to the wrong thread.
877
+ auto &animation = *state.animation;
878
+ animation.started = true;
879
+ animation.startedAtS = nowSeconds();
880
+ animation.lastFrameS = animation.startedAtS;
881
+ auto &active = animatingDrivers();
882
+ if (std::find(active.begin(), active.end(), driverId) == active.end()) {
883
+ active.push_back(driverId);
884
+ }
885
+ scheduleFrame();
886
+ }
855
887
  }
856
888
 
857
889
  void setViewHostGeometryAndroid(
@@ -58,6 +58,10 @@ struct ActiveAnimation {
58
58
  std::vector<Keyframe> keyframes;
59
59
  double durationMs = 0;
60
60
  CFTimeInterval startedAt = 0;
61
+ // False while the animation is latched: built before any host view
62
+ // registered, held un-rendered until the first registerView rebases
63
+ // startedAt and starts it. startedAt is only meaningful once started.
64
+ bool started = false;
61
65
  };
62
66
 
63
67
  struct DriverState {
@@ -170,7 +174,12 @@ int32_t allocateAnimationId(DriverState &state) {
170
174
  }
171
175
 
172
176
  Presentation canonicalFrozenPresentation(DriverState &state) {
173
- Presentation canonical = state.latest;
177
+ // A latched animation never rendered, so state.latest already holds its
178
+ // target; freezing there would jump the clip. Freeze at the start instead.
179
+ Presentation canonical =
180
+ state.animation.has_value() && !state.animation->started
181
+ ? state.animation->start
182
+ : state.latest;
174
183
  bool hasCanonical = false;
175
184
  for (const ViewKey key : state.views) {
176
185
  const Presentation frozen = [viewForKey(key) smoothClipFreezePresentation];
@@ -224,9 +233,16 @@ void prepareAnimation(
224
233
  Presentation resolvedAnimationStart(
225
234
  const DriverState &state,
226
235
  const AnimationStart &start) {
227
- return start.hasInteractiveStart && state.ownership == Ownership::Interactive
228
- ? start.interactiveStart
229
- : state.latest;
236
+ if (start.hasInteractiveStart && state.ownership == Ownership::Interactive) {
237
+ return start.interactiveStart;
238
+ }
239
+ // Replacing a latched animation (e.g. open→close before the host mounted):
240
+ // it never rendered, and state.latest is its target — start from where the
241
+ // clip actually is, the latch's own start.
242
+ if (state.animation.has_value() && !state.animation->started) {
243
+ return state.animation->start;
244
+ }
245
+ return state.latest;
230
246
  }
231
247
 
232
248
  // Installs the remaining part of the active animation on a view that joins
@@ -303,8 +319,10 @@ void registerView(
303
319
  state.latest = initialPresentation;
304
320
  state.hasLatest = true;
305
321
  }
322
+ const bool hasAnimation = state.animation.has_value();
323
+ const bool startsLatch = hasAnimation && !state.animation->started;
306
324
  const bool shouldReplay =
307
- state.animation.has_value() && !state.views.empty();
325
+ hasAnimation && state.animation->started && !state.views.empty();
308
326
  Presentation visible = state.latest;
309
327
  if (shouldReplay) {
310
328
  // A registered peer that never laid out reports zero geometry; joining
@@ -321,6 +339,10 @@ void registerView(
321
339
  visible = reference != nil
322
340
  ? [reference smoothClipCurrentPresentation]
323
341
  : state.animation->start;
342
+ } else if (startsLatch) {
343
+ // state.latest already holds the latch's TARGET; applying it here is
344
+ // exactly the jump this path exists to avoid.
345
+ visible = state.animation->start;
324
346
  }
325
347
  const ViewKey key = keyForView(view);
326
348
  if (std::find(state.views.begin(), state.views.end(), key) ==
@@ -328,8 +350,16 @@ void registerView(
328
350
  state.views.push_back(key);
329
351
  }
330
352
  [view smoothClipApplyPresentation:visible];
331
- if (!shouldReplay || !state.animation.has_value()) return;
353
+ 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;
358
+ }
359
+ if (!(shouldReplay || startsLatch)) return;
332
360
 
361
+ // The participant insert inside the join is also what keeps unregisterView
362
+ // orphan-safe for a mount-then-unmount before first layout.
333
363
  dispatchActiveAnimationJoin(state, view, key, visible);
334
364
  }
335
365
 
@@ -339,6 +369,10 @@ bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
339
369
  if (iterator == registry().end()) return false;
340
370
  auto &state = iterator->second;
341
371
  if (!state.animation.has_value() || state.views.empty()) return false;
372
+ // Unreachable in the normal flow (a pending install implies the animation
373
+ // was dispatched, which implies started), but a join must never compute
374
+ // elapsed time against an un-rebased latch clock.
375
+ if (!state.animation->started) return false;
342
376
  const ViewKey key = keyForView(view);
343
377
  if (std::find(state.views.begin(), state.views.end(), key) ==
344
378
  state.views.end()) {
@@ -500,10 +534,13 @@ int32_t animateTiming(
500
534
  active.durationMs = animation.durationMs;
501
535
  active.startedAt = CACurrentMediaTime();
502
536
  active.participants.insert(state.views.begin(), state.views.end());
537
+ active.started = !state.views.empty();
503
538
  state.animation = std::move(active);
504
- if (state.views.empty()) {
505
- state.ownership = Ownership::Interactive;
506
- emitCompletion(driverId, state, animationId, true);
539
+ if (!state.animation->started) {
540
+ // Latch: no host view yet (animateTo raced the mount). Ownership stays
541
+ // 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.
507
544
  return animationId;
508
545
  }
509
546
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -549,10 +586,10 @@ int32_t animateSpring(
549
586
  active.spring = animation;
550
587
  active.startedAt = CACurrentMediaTime();
551
588
  active.participants.insert(state.views.begin(), state.views.end());
589
+ active.started = !state.views.empty();
552
590
  state.animation = std::move(active);
553
- if (state.views.empty()) {
554
- state.ownership = Ownership::Interactive;
555
- emitCompletion(driverId, state, animationId, true);
591
+ if (!state.animation->started) {
592
+ // Latched until the first registerView — see animateTiming.
556
593
  return animationId;
557
594
  }
558
595
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -600,10 +637,10 @@ int32_t animateKeyframes(
600
637
  active.durationMs = durationMs;
601
638
  active.startedAt = CACurrentMediaTime();
602
639
  active.participants.insert(state.views.begin(), state.views.end());
640
+ active.started = !state.views.empty();
603
641
  state.animation = std::move(active);
604
- if (state.views.empty()) {
605
- state.ownership = Ownership::Interactive;
606
- emitCompletion(driverId, state, animationId, true);
642
+ if (!state.animation->started) {
643
+ // Latched until the first registerView — see animateTiming.
607
644
  return animationId;
608
645
  }
609
646
  for (const ViewKey key : state.views) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-smooth-clip-view",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Layout-free animated rounded clipping for React Native Fabric",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",