react-native-smooth-clip-view 0.2.0 → 0.2.2
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 +17 -4
- package/android/src/main/cpp/SmoothClipAndroid.h +8 -0
- package/android/src/main/cpp/SmoothClipBindings.cpp +11 -0
- package/android/src/main/cpp/SmoothClipRegistry.cpp +160 -15
- package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +7 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +10 -0
- package/ios/SmoothClipRegistry.mm +187 -26
- package/ios/SmoothClipView.mm +35 -3
- package/ios/SmoothClipViewRegistry.h +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -194,10 +194,23 @@ const gesture = Gesture.Pan()
|
|
|
194
194
|
validated, monotonically increasing offsets from zero through one.
|
|
195
195
|
- `cancel()` freezes visible presentation by default. Pass `'target'` as its
|
|
196
196
|
behavior to jump to the requested endpoint.
|
|
197
|
-
- `options.onAnimationComplete` fires once per animation with its ID
|
|
198
|
-
`finished` state, including cancellation, replacement,
|
|
199
|
-
|
|
200
|
-
|
|
197
|
+
- `options.onAnimationComplete` fires exactly once per animation with its ID
|
|
198
|
+
and `finished` state, including cancellation, replacement, and native-side
|
|
199
|
+
rejection (`animateTo` then returns `0` and one `finished: false` completion
|
|
200
|
+
is delivered). Unmounting the last host mid-flight does not complete the
|
|
201
|
+
animation: the remainder is re-latched and the next displayable host resumes
|
|
202
|
+
it, so the completion arrives when it finishes there — or unfinished on
|
|
203
|
+
replacement, cancellation, `beginInteraction()`, or driver destruction.
|
|
204
|
+
- An `animateTo` issued before any host view can produce a visible frame (for
|
|
205
|
+
example from an effect in the same commit that mounts the host, or inside a
|
|
206
|
+
modal route whose subtree attaches to its window late) is held pending and
|
|
207
|
+
starts with its full duration at the first displayable registration or
|
|
208
|
+
window attach. A pending animation owns the driver: take-ownership writes
|
|
209
|
+
(`set`, `setScalars`, the hook's seed) are dropped while it is held —
|
|
210
|
+
replace it with another `animateTo`, or cancel it via `beginInteraction()`
|
|
211
|
+
or `cancel()`. If no view ever becomes displayable, it survives until it is
|
|
212
|
+
replaced, cancelled, or the driver is destroyed — at which point its single
|
|
213
|
+
`finished: false` completion is delivered.
|
|
201
214
|
|
|
202
215
|
Do not call `driver.ui` from React code — it throws on the React runtime; use
|
|
203
216
|
`driver.react` there. During a native transition, `driver.presentation.value`
|
|
@@ -22,6 +22,7 @@ struct JSmoothClipView : facebook::jni::JavaClass<JSmoothClipView> {
|
|
|
22
22
|
const NormalizedClip &clip,
|
|
23
23
|
double contentTranslateXPx,
|
|
24
24
|
double contentTranslateYPx) const;
|
|
25
|
+
bool isViewAttachedToWindow() const;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
// Called from Kotlin (SmoothClipViewManager) on the UI thread when a Fabric
|
|
@@ -44,6 +45,13 @@ void setViewHostGeometryAndroid(
|
|
|
44
45
|
void unregisterViewAndroid(
|
|
45
46
|
uint64_t driverId,
|
|
46
47
|
facebook::jni::alias_ref<JSmoothClipView> view);
|
|
48
|
+
// Called from Kotlin when a registered view attaches to a window. A latched
|
|
49
|
+
// animation may only start once a registered view can actually produce a
|
|
50
|
+
// visible frame (attached AND has host geometry); otherwise its clock would
|
|
51
|
+
// burn progress no one can see.
|
|
52
|
+
void viewBecameDisplayableAndroid(
|
|
53
|
+
uint64_t driverId,
|
|
54
|
+
facebook::jni::alias_ref<JSmoothClipView> view);
|
|
47
55
|
|
|
48
56
|
// Installs `global.__SmoothClipView` (worklet-callable host functions) into the
|
|
49
57
|
// JS runtime and wires native animation completion delivery through the
|
|
@@ -406,6 +406,15 @@ void nativeUnregisterView(
|
|
|
406
406
|
smoothclip::unregisterViewAndroid(static_cast<uint64_t>(driverId), view);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
void nativeViewBecameDisplayable(
|
|
410
|
+
jni::alias_ref<jni::JObject>,
|
|
411
|
+
jdouble driverId,
|
|
412
|
+
jni::alias_ref<smoothclip::JSmoothClipView> view) {
|
|
413
|
+
if (driverId <= 0 || !std::isfinite(driverId)) return;
|
|
414
|
+
smoothclip::viewBecameDisplayableAndroid(
|
|
415
|
+
static_cast<uint64_t>(driverId), view);
|
|
416
|
+
}
|
|
417
|
+
|
|
409
418
|
void nativeInvalidate(jni::alias_ref<jni::JObject>) {
|
|
410
419
|
smoothclip::invalidateBindings();
|
|
411
420
|
}
|
|
@@ -422,6 +431,8 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
|
|
|
422
431
|
makeNativeMethod(
|
|
423
432
|
"nativeSetViewHostGeometry", nativeSetViewHostGeometry),
|
|
424
433
|
makeNativeMethod("nativeUnregisterView", nativeUnregisterView),
|
|
434
|
+
makeNativeMethod(
|
|
435
|
+
"nativeViewBecameDisplayable", nativeViewBecameDisplayable),
|
|
425
436
|
makeNativeMethod("nativeInvalidate", nativeInvalidate),
|
|
426
437
|
});
|
|
427
438
|
});
|
|
@@ -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
|
|
@@ -454,20 +458,57 @@ Presentation prepareAnimation(
|
|
|
454
458
|
return resolvedStart;
|
|
455
459
|
}
|
|
456
460
|
|
|
461
|
+
// A view can only render the animation once it is attached to a window and
|
|
462
|
+
// has pushed real host geometry; a clock started before that burns progress
|
|
463
|
+
// no one can see (the transparentModal mount pattern).
|
|
464
|
+
bool entryDisplayable(const ViewEntry &entry) {
|
|
465
|
+
return entry.hostWidthPx > 0 && entry.hostHeightPx > 0 &&
|
|
466
|
+
entry.view->isViewAttachedToWindow();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
bool anyDisplayableView(const DriverState &state) {
|
|
470
|
+
for (const ViewEntry &entry : state.views) {
|
|
471
|
+
if (entryDisplayable(entry)) return true;
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Starts a latched animation: rebases the clock so no progress was burned
|
|
477
|
+
// while no view could display, then joins the choreographer loop.
|
|
478
|
+
void startLatchedAnimation(uint64_t driverId, DriverState &state) {
|
|
479
|
+
auto &animation = *state.animation;
|
|
480
|
+
animation.started = true;
|
|
481
|
+
animation.startedAtS = nowSeconds();
|
|
482
|
+
animation.lastFrameS = animation.startedAtS;
|
|
483
|
+
auto &active = animatingDrivers();
|
|
484
|
+
if (std::find(active.begin(), active.end(), driverId) == active.end()) {
|
|
485
|
+
active.push_back(driverId);
|
|
486
|
+
}
|
|
487
|
+
applyToViews(state, animation.start);
|
|
488
|
+
scheduleFrame();
|
|
489
|
+
}
|
|
490
|
+
|
|
457
491
|
int32_t startAnimation(
|
|
458
492
|
uint64_t driverId,
|
|
459
493
|
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
|
-
}
|
|
494
|
+
ActiveAnimation animation) {
|
|
467
495
|
animation.startedAtS = nowSeconds();
|
|
468
496
|
animation.lastFrameS = animation.startedAtS;
|
|
497
|
+
// current = start even while latched is load-bearing: cancelAnimation,
|
|
498
|
+
// beginInteraction, prepareAnimation's visibleBefore and registerView's
|
|
499
|
+
// visible all read animation->current, giving a never-rendered latch
|
|
500
|
+
// freeze-at-start / replace-from-start semantics with no extra branches.
|
|
469
501
|
animation.current = animation.start;
|
|
502
|
+
animation.started = anyDisplayableView(state);
|
|
470
503
|
state.animation = std::move(animation);
|
|
504
|
+
if (!state.animation->started) {
|
|
505
|
+
// Latch: no host view can display yet (animateTo raced the mount, or
|
|
506
|
+
// every host is detached/unsized). The first displayable registration,
|
|
507
|
+
// host-geometry push, or window attach rebases the clock, joins
|
|
508
|
+
// animatingDrivers() and schedules the frame loop. Non-zero id is still
|
|
509
|
+
// returned so the JS side does not treat this as rejection.
|
|
510
|
+
return state.animation->id;
|
|
511
|
+
}
|
|
471
512
|
auto &active = animatingDrivers();
|
|
472
513
|
if (std::find(active.begin(), active.end(), driverId) == active.end()) {
|
|
473
514
|
active.push_back(driverId);
|
|
@@ -593,6 +634,15 @@ void setPresentation(
|
|
|
593
634
|
if (!takeOwnership && state.ownership != Ownership::Interactive) return;
|
|
594
635
|
if (takeOwnership) {
|
|
595
636
|
state.destroyed = false;
|
|
637
|
+
// A held latch is strictly newer intent than this write: the latch was
|
|
638
|
+
// created after whatever value the caller read (the hook's seed replays
|
|
639
|
+
// a SharedValue that an earlier animateTo already advanced to its
|
|
640
|
+
// target). Cancelling it here would seed the target and turn the pending
|
|
641
|
+
// animation into a static jump. Callers that want to override a latch
|
|
642
|
+
// cancel it explicitly first.
|
|
643
|
+
if (state.animation.has_value() && !state.animation->started) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
596
646
|
finishActive(driverId, state, false);
|
|
597
647
|
}
|
|
598
648
|
state.latest = presentation;
|
|
@@ -652,7 +702,7 @@ int32_t animateTiming(
|
|
|
652
702
|
active.start = resolvedStart;
|
|
653
703
|
active.target = presentation;
|
|
654
704
|
active.durationS = animation.durationMs / 1000.0;
|
|
655
|
-
return startAnimation(driverId, state, std::move(active)
|
|
705
|
+
return startAnimation(driverId, state, std::move(active));
|
|
656
706
|
}
|
|
657
707
|
|
|
658
708
|
int32_t animateSpring(
|
|
@@ -695,7 +745,7 @@ int32_t animateSpring(
|
|
|
695
745
|
active.springVelocity[index] =
|
|
696
746
|
velocity * (targetChannels[index] - startChannels[index]);
|
|
697
747
|
}
|
|
698
|
-
return startAnimation(driverId, state, std::move(active)
|
|
748
|
+
return startAnimation(driverId, state, std::move(active));
|
|
699
749
|
}
|
|
700
750
|
|
|
701
751
|
int32_t animateKeyframes(
|
|
@@ -726,7 +776,7 @@ int32_t animateKeyframes(
|
|
|
726
776
|
active.start = resolvedStart;
|
|
727
777
|
active.target = presentation;
|
|
728
778
|
active.durationS = durationMs / 1000.0;
|
|
729
|
-
return startAnimation(driverId, state, std::move(active)
|
|
779
|
+
return startAnimation(driverId, state, std::move(active));
|
|
730
780
|
}
|
|
731
781
|
|
|
732
782
|
int32_t rejectAnimation(uint64_t driverId) {
|
|
@@ -785,6 +835,12 @@ void destroyDriver(uint64_t driverId) {
|
|
|
785
835
|
|
|
786
836
|
// --- Android view lifecycle ----------------------------------------------
|
|
787
837
|
|
|
838
|
+
bool JSmoothClipView::isViewAttachedToWindow() const {
|
|
839
|
+
static const auto method =
|
|
840
|
+
javaClassStatic()->getMethod<jboolean()>("isAttachedToWindow");
|
|
841
|
+
return method(self());
|
|
842
|
+
}
|
|
843
|
+
|
|
788
844
|
void JSmoothClipView::applyClip(const Presentation &presentation) const {
|
|
789
845
|
static const auto method =
|
|
790
846
|
javaClassStatic()
|
|
@@ -845,13 +901,31 @@ void registerViewAndroid(
|
|
|
845
901
|
existing.hostWidthPx = hostWidthPx;
|
|
846
902
|
existing.hostHeightPx = hostHeightPx;
|
|
847
903
|
deliverToView(existing, visible);
|
|
904
|
+
if (state.animation.has_value() && !state.animation->started &&
|
|
905
|
+
entryDisplayable(existing)) {
|
|
906
|
+
startLatchedAnimation(driverId, state);
|
|
907
|
+
}
|
|
848
908
|
return;
|
|
849
909
|
}
|
|
850
910
|
}
|
|
851
911
|
ViewEntry entry{
|
|
852
912
|
facebook::jni::make_global(view), density, hostWidthPx, hostHeightPx};
|
|
913
|
+
// `visible` above already delivered animation->current (= start) to the
|
|
914
|
+
// registering view — the correct first frame for a latched animation.
|
|
853
915
|
deliverToView(entry, visible);
|
|
854
916
|
state.views.push_back(std::move(entry));
|
|
917
|
+
if (state.animation.has_value() && !state.animation->started &&
|
|
918
|
+
entryDisplayable(state.views.back())) {
|
|
919
|
+
// Start the latched animation: rebase the clock so the first frame
|
|
920
|
+
// integrates from now, not from the pre-mount animateTo call. Gated on
|
|
921
|
+
// displayability — a mount-time registration from a detached subtree
|
|
922
|
+
// holds the latch until window attach / first host geometry.
|
|
923
|
+
// Note this function has no isOnMainThread() guard (Kotlin calls it from
|
|
924
|
+
// the view's main-thread attach path); scheduleFrame() is itself
|
|
925
|
+
// main-thread-gated, so an off-main register cannot bind the
|
|
926
|
+
// choreographer to the wrong thread.
|
|
927
|
+
startLatchedAnimation(driverId, state);
|
|
928
|
+
}
|
|
855
929
|
}
|
|
856
930
|
|
|
857
931
|
void setViewHostGeometryAndroid(
|
|
@@ -876,6 +950,29 @@ void setViewHostGeometryAndroid(
|
|
|
876
950
|
state.animation.has_value() ? state.animation->current
|
|
877
951
|
: state.latest);
|
|
878
952
|
}
|
|
953
|
+
if (state.animation.has_value() && !state.animation->started &&
|
|
954
|
+
entryDisplayable(entry)) {
|
|
955
|
+
// First real host geometry made this view displayable.
|
|
956
|
+
startLatchedAnimation(driverId, state);
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
void viewBecameDisplayableAndroid(
|
|
963
|
+
uint64_t driverId,
|
|
964
|
+
alias_ref<JSmoothClipView> view) {
|
|
965
|
+
if (!isOnMainThread()) return;
|
|
966
|
+
auto iterator = registry().find(driverId);
|
|
967
|
+
if (iterator == registry().end()) return;
|
|
968
|
+
auto &state = iterator->second;
|
|
969
|
+
if (!state.animation.has_value() || state.animation->started) return;
|
|
970
|
+
JNIEnv *env = facebook::jni::Environment::current();
|
|
971
|
+
for (auto &entry : state.views) {
|
|
972
|
+
if (!env->IsSameObject(entry.view.get(), view.get())) continue;
|
|
973
|
+
if (entryDisplayable(entry)) {
|
|
974
|
+
startLatchedAnimation(driverId, state);
|
|
975
|
+
}
|
|
879
976
|
return;
|
|
880
977
|
}
|
|
881
978
|
}
|
|
@@ -898,11 +995,59 @@ void unregisterViewAndroid(
|
|
|
898
995
|
}
|
|
899
996
|
if (removed && state.animation.has_value()) {
|
|
900
997
|
state.animation->finished = false;
|
|
901
|
-
if (state.views.empty()) {
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
|
|
905
|
-
|
|
998
|
+
if (state.views.empty() && !state.animation->started) {
|
|
999
|
+
// A held latch returns to its original zero-view state; the next
|
|
1000
|
+
// displayable registration still starts it (or destroyDriver cancels
|
|
1001
|
+
// it). Completing it here would betray the pending intent.
|
|
1002
|
+
} else if (state.views.empty()) {
|
|
1003
|
+
auto &animation = *state.animation;
|
|
1004
|
+
const double elapsedS = nowSeconds() - animation.startedAtS;
|
|
1005
|
+
const double remainingS = std::max(0.0, animation.durationS - elapsedS);
|
|
1006
|
+
const bool canResume = !state.destroyed &&
|
|
1007
|
+
(animation.kind == AnimationKind::Spring || remainingS > 0);
|
|
1008
|
+
if (!canResume) {
|
|
1009
|
+
// The animation ends here; release ownership so a later remount's
|
|
1010
|
+
// interactive updates are not dropped.
|
|
1011
|
+
state.ownership = Ownership::Interactive;
|
|
1012
|
+
finishActive(driverId, state, false);
|
|
1013
|
+
} else {
|
|
1014
|
+
// The last rendering host left mid-flight (remount, screen swap).
|
|
1015
|
+
// Completing here would leave `latest` at the target and statically
|
|
1016
|
+
// snap any re-registering host straight to it. Re-latch instead:
|
|
1017
|
+
// freeze the remaining animation at its current geometry so the
|
|
1018
|
+
// next displayable host resumes it with the true remaining time.
|
|
1019
|
+
// destroyDriver cancels a latch that never finds a host, so the
|
|
1020
|
+
// completion cannot hang.
|
|
1021
|
+
if (animation.kind == AnimationKind::Timing) {
|
|
1022
|
+
animation.durationS = remainingS;
|
|
1023
|
+
} else if (animation.kind == AnimationKind::Keyframes) {
|
|
1024
|
+
const double progress = animation.durationS <= 0
|
|
1025
|
+
? 1.0
|
|
1026
|
+
: clamp01(elapsedS / animation.durationS);
|
|
1027
|
+
std::vector<Keyframe> remaining;
|
|
1028
|
+
remaining.push_back({0, animation.current});
|
|
1029
|
+
for (const Keyframe &frame : animation.keyframes) {
|
|
1030
|
+
if (frame.offset <= progress) continue;
|
|
1031
|
+
remaining.push_back(
|
|
1032
|
+
{(frame.offset - progress) / (1 - progress),
|
|
1033
|
+
frame.presentation});
|
|
1034
|
+
}
|
|
1035
|
+
if (remaining.size() == 1) {
|
|
1036
|
+
remaining.push_back({1, animation.target});
|
|
1037
|
+
}
|
|
1038
|
+
animation.keyframes = std::move(remaining);
|
|
1039
|
+
animation.durationS = remainingS;
|
|
1040
|
+
}
|
|
1041
|
+
animation.start = animation.current;
|
|
1042
|
+
animation.started = false;
|
|
1043
|
+
// A held latch must not tick: leave animatingDrivers() without
|
|
1044
|
+
// resetting the animation (the eager-removal rule the choreographer
|
|
1045
|
+
// loop depends on).
|
|
1046
|
+
auto &active = animatingDrivers();
|
|
1047
|
+
active.erase(
|
|
1048
|
+
std::remove(active.begin(), active.end(), driverId),
|
|
1049
|
+
active.end());
|
|
1050
|
+
}
|
|
906
1051
|
}
|
|
907
1052
|
}
|
|
908
1053
|
if (state.destroyed && state.views.empty()) {
|
|
@@ -47,6 +47,13 @@ internal object SmoothClipBindings {
|
|
|
47
47
|
|
|
48
48
|
external fun nativeUnregisterView(driverId: Double, view: SmoothClipView)
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Notifies the registry that a registered view attached to a window; a
|
|
52
|
+
* latched animation may only start once a view can produce a visible
|
|
53
|
+
* frame (attached and with real host geometry).
|
|
54
|
+
*/
|
|
55
|
+
external fun nativeViewBecameDisplayable(driverId: Double, view: SmoothClipView)
|
|
56
|
+
|
|
50
57
|
/**
|
|
51
58
|
* Releases runtime-bound listener state during host teardown; must run
|
|
52
59
|
* while the JS runtime is still alive (module invalidate).
|
|
@@ -232,6 +232,16 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
override fun onAttachedToWindow() {
|
|
236
|
+
super.onAttachedToWindow()
|
|
237
|
+
if (boundDriverId != 0.0) {
|
|
238
|
+
// A latched animation may only start once this view can produce a
|
|
239
|
+
// visible frame; window attach is that signal for views that
|
|
240
|
+
// registered from a detached subtree.
|
|
241
|
+
SmoothClipBindings.nativeViewBecameDisplayable(boundDriverId, this)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
235
245
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
236
246
|
super.onConfigurationChanged(newConfig)
|
|
237
247
|
if (boundDriverId != 0.0) {
|
|
@@ -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 {
|
|
@@ -58,6 +60,10 @@ struct ActiveAnimation {
|
|
|
58
60
|
std::vector<Keyframe> keyframes;
|
|
59
61
|
double durationMs = 0;
|
|
60
62
|
CFTimeInterval startedAt = 0;
|
|
63
|
+
// False while the animation is latched: built before any host view
|
|
64
|
+
// registered, held un-rendered until the first registerView rebases
|
|
65
|
+
// startedAt and starts it. startedAt is only meaningful once started.
|
|
66
|
+
bool started = false;
|
|
61
67
|
};
|
|
62
68
|
|
|
63
69
|
struct DriverState {
|
|
@@ -170,11 +176,20 @@ int32_t allocateAnimationId(DriverState &state) {
|
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
Presentation canonicalFrozenPresentation(DriverState &state) {
|
|
173
|
-
|
|
179
|
+
// A latched animation never rendered, so state.latest already holds its
|
|
180
|
+
// target; freezing there would jump the clip. Freeze at the start instead.
|
|
181
|
+
Presentation canonical =
|
|
182
|
+
state.animation.has_value() && !state.animation->started
|
|
183
|
+
? state.animation->start
|
|
184
|
+
: state.latest;
|
|
174
185
|
bool hasCanonical = false;
|
|
175
186
|
for (const ViewKey key : state.views) {
|
|
176
|
-
|
|
177
|
-
|
|
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]) {
|
|
178
193
|
canonical = frozen;
|
|
179
194
|
hasCanonical = true;
|
|
180
195
|
}
|
|
@@ -224,9 +239,16 @@ void prepareAnimation(
|
|
|
224
239
|
Presentation resolvedAnimationStart(
|
|
225
240
|
const DriverState &state,
|
|
226
241
|
const AnimationStart &start) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
242
|
+
if (start.hasInteractiveStart && state.ownership == Ownership::Interactive) {
|
|
243
|
+
return start.interactiveStart;
|
|
244
|
+
}
|
|
245
|
+
// Replacing a latched animation (e.g. open→close before the host mounted):
|
|
246
|
+
// it never rendered, and state.latest is its target — start from where the
|
|
247
|
+
// clip actually is, the latch's own start.
|
|
248
|
+
if (state.animation.has_value() && !state.animation->started) {
|
|
249
|
+
return state.animation->start;
|
|
250
|
+
}
|
|
251
|
+
return state.latest;
|
|
230
252
|
}
|
|
231
253
|
|
|
232
254
|
// Installs the remaining part of the active animation on a view that joins
|
|
@@ -270,6 +292,33 @@ void dispatchActiveAnimationJoin(
|
|
|
270
292
|
}
|
|
271
293
|
}
|
|
272
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
|
+
|
|
273
322
|
} // namespace
|
|
274
323
|
|
|
275
324
|
void setCompletionCallback(
|
|
@@ -303,8 +352,15 @@ void registerView(
|
|
|
303
352
|
state.latest = initialPresentation;
|
|
304
353
|
state.hasLatest = true;
|
|
305
354
|
}
|
|
355
|
+
const bool hasAnimation = state.animation.has_value();
|
|
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];
|
|
306
362
|
const bool shouldReplay =
|
|
307
|
-
state.animation
|
|
363
|
+
hasAnimation && state.animation->started && !state.views.empty();
|
|
308
364
|
Presentation visible = state.latest;
|
|
309
365
|
if (shouldReplay) {
|
|
310
366
|
// A registered peer that never laid out reports zero geometry; joining
|
|
@@ -321,6 +377,10 @@ void registerView(
|
|
|
321
377
|
visible = reference != nil
|
|
322
378
|
? [reference smoothClipCurrentPresentation]
|
|
323
379
|
: state.animation->start;
|
|
380
|
+
} else if (hasLatch) {
|
|
381
|
+
// state.latest already holds the latch's TARGET; applying it here is
|
|
382
|
+
// exactly the jump this path exists to avoid.
|
|
383
|
+
visible = state.animation->start;
|
|
324
384
|
}
|
|
325
385
|
const ViewKey key = keyForView(view);
|
|
326
386
|
if (std::find(state.views.begin(), state.views.end(), key) ==
|
|
@@ -328,17 +388,57 @@ void registerView(
|
|
|
328
388
|
state.views.push_back(key);
|
|
329
389
|
}
|
|
330
390
|
[view smoothClipApplyPresentation:visible];
|
|
331
|
-
if (
|
|
332
|
-
|
|
391
|
+
if (startsLatch) {
|
|
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;
|
|
397
|
+
}
|
|
398
|
+
if (!shouldReplay) return;
|
|
333
399
|
dispatchActiveAnimationJoin(state, view, key, visible);
|
|
334
400
|
}
|
|
335
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
|
+
|
|
336
426
|
bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
|
|
337
427
|
NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
|
|
338
428
|
auto iterator = registry().find(driverId);
|
|
339
|
-
if (iterator == registry().end())
|
|
429
|
+
if (iterator == registry().end()) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
340
432
|
auto &state = iterator->second;
|
|
341
|
-
if (!state.animation.has_value() || state.views.empty())
|
|
433
|
+
if (!state.animation.has_value() || state.views.empty()) {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
// Unreachable in the normal flow (a pending install implies the animation
|
|
437
|
+
// was dispatched, which implies started), but a join must never compute
|
|
438
|
+
// elapsed time against an un-rebased latch clock.
|
|
439
|
+
if (!state.animation->started) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
342
442
|
const ViewKey key = keyForView(view);
|
|
343
443
|
if (std::find(state.views.begin(), state.views.end(), key) ==
|
|
344
444
|
state.views.end()) {
|
|
@@ -374,11 +474,50 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
|
|
|
374
474
|
state.animation->participants.erase(key) > 0) {
|
|
375
475
|
state.animation->finished = false;
|
|
376
476
|
if (state.animation->participants.empty()) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
}
|
|
382
521
|
}
|
|
383
522
|
}
|
|
384
523
|
state.views.erase(
|
|
@@ -435,6 +574,21 @@ void setPresentation(
|
|
|
435
574
|
}
|
|
436
575
|
if (takeOwnership) {
|
|
437
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
|
+
}
|
|
438
592
|
cancelActive(driverId, state, false);
|
|
439
593
|
}
|
|
440
594
|
applyPresentation(state, presentation);
|
|
@@ -480,7 +634,9 @@ int32_t animateTiming(
|
|
|
480
634
|
// documented rejection sentinel.
|
|
481
635
|
return 0;
|
|
482
636
|
} auto iterator = registry().find(driverId);
|
|
483
|
-
if (iterator == registry().end())
|
|
637
|
+
if (iterator == registry().end()) {
|
|
638
|
+
return 0;
|
|
639
|
+
}
|
|
484
640
|
auto &state = iterator->second;
|
|
485
641
|
state.destroyed = false;
|
|
486
642
|
const int32_t animationId = allocateAnimationId(state);
|
|
@@ -500,10 +656,15 @@ int32_t animateTiming(
|
|
|
500
656
|
active.durationMs = animation.durationMs;
|
|
501
657
|
active.startedAt = CACurrentMediaTime();
|
|
502
658
|
active.participants.insert(state.views.begin(), state.views.end());
|
|
659
|
+
active.started = anyDisplayableView(state);
|
|
503
660
|
state.animation = std::move(active);
|
|
504
|
-
if (state.
|
|
505
|
-
|
|
506
|
-
|
|
661
|
+
if (!state.animation->started) {
|
|
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
|
|
664
|
+
// Native — the pending animation owns rendering intent — and the first
|
|
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.
|
|
507
668
|
return animationId;
|
|
508
669
|
}
|
|
509
670
|
#if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
|
|
@@ -549,10 +710,10 @@ int32_t animateSpring(
|
|
|
549
710
|
active.spring = animation;
|
|
550
711
|
active.startedAt = CACurrentMediaTime();
|
|
551
712
|
active.participants.insert(state.views.begin(), state.views.end());
|
|
713
|
+
active.started = anyDisplayableView(state);
|
|
552
714
|
state.animation = std::move(active);
|
|
553
|
-
if (state.
|
|
554
|
-
|
|
555
|
-
emitCompletion(driverId, state, animationId, true);
|
|
715
|
+
if (!state.animation->started) {
|
|
716
|
+
// Latched until the first displayable view — see animateTiming.
|
|
556
717
|
return animationId;
|
|
557
718
|
}
|
|
558
719
|
#if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
|
|
@@ -600,10 +761,10 @@ int32_t animateKeyframes(
|
|
|
600
761
|
active.durationMs = durationMs;
|
|
601
762
|
active.startedAt = CACurrentMediaTime();
|
|
602
763
|
active.participants.insert(state.views.begin(), state.views.end());
|
|
764
|
+
active.started = anyDisplayableView(state);
|
|
603
765
|
state.animation = std::move(active);
|
|
604
|
-
if (state.
|
|
605
|
-
|
|
606
|
-
emitCompletion(driverId, state, animationId, true);
|
|
766
|
+
if (!state.animation->started) {
|
|
767
|
+
// Latched until the first displayable view — see animateTiming.
|
|
607
768
|
return animationId;
|
|
608
769
|
}
|
|
609
770
|
for (const ViewKey key : state.views) {
|
package/ios/SmoothClipView.mm
CHANGED
|
@@ -167,6 +167,13 @@ using namespace facebook::react;
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
- (void)didMoveToWindow {
|
|
171
|
+
[super didMoveToWindow];
|
|
172
|
+
if (_driverId != 0 && [self smoothClipCanDisplay]) {
|
|
173
|
+
smoothclip::viewBecameDisplayable(_driverId, self);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
170
177
|
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
|
|
171
178
|
index:(NSInteger)index {
|
|
172
179
|
NSAssert(childComponentView.superview == nil,
|
|
@@ -403,6 +410,17 @@ using namespace facebook::react;
|
|
|
403
410
|
return _hasLayout;
|
|
404
411
|
}
|
|
405
412
|
|
|
413
|
+
- (BOOL)smoothClipCanDisplay {
|
|
414
|
+
// A CA animation committed while this view's layer tree is detached from
|
|
415
|
+
// the render tree does not survive the later attach commit; installs and
|
|
416
|
+
// latch starts must wait until a frame can actually be produced.
|
|
417
|
+
return _hasLayout && self.window != nil;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
- (BOOL)smoothClipHasPendingInstall {
|
|
421
|
+
return _pendingAnimationInstall;
|
|
422
|
+
}
|
|
423
|
+
|
|
406
424
|
- (smoothclip::Presentation)smoothClipFreezePresentation {
|
|
407
425
|
const smoothclip::Presentation visible =
|
|
408
426
|
[self smoothClipCurrentPresentation];
|
|
@@ -647,7 +665,7 @@ using namespace facebook::react;
|
|
|
647
665
|
}
|
|
648
666
|
|
|
649
667
|
- (void)startAnimationToRequestedGeometryWithDuration:(CFTimeInterval)duration {
|
|
650
|
-
if (!
|
|
668
|
+
if (![self smoothClipCanDisplay]) {
|
|
651
669
|
_pendingAnimationInstall = YES;
|
|
652
670
|
return;
|
|
653
671
|
}
|
|
@@ -720,7 +738,7 @@ using namespace facebook::react;
|
|
|
720
738
|
[self storeRequestedPresentation:presentation];
|
|
721
739
|
_activeAnimationKind = 3;
|
|
722
740
|
_activeAnimationId = animationId;
|
|
723
|
-
if (!
|
|
741
|
+
if (![self smoothClipCanDisplay]) {
|
|
724
742
|
_pendingAnimationInstall = YES;
|
|
725
743
|
return;
|
|
726
744
|
}
|
|
@@ -937,8 +955,14 @@ using namespace facebook::react;
|
|
|
937
955
|
// the true remaining duration instead of jumping to the target.
|
|
938
956
|
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
|
|
939
957
|
_hasLayout = YES;
|
|
940
|
-
_pendingAnimationInstall = NO;
|
|
941
958
|
[self layoutContentContainer];
|
|
959
|
+
if (self.window == nil) {
|
|
960
|
+
// Still detached (transparentModal subtree before presentation): an
|
|
961
|
+
// install here would not survive the attach commit. Keep the install
|
|
962
|
+
// pending; didMoveToWindow completes it.
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
_pendingAnimationInstall = NO;
|
|
942
966
|
if (!smoothclip::joinActiveAnimation(_driverId, self)) {
|
|
943
967
|
// The animation finished or was cancelled while awaiting layout.
|
|
944
968
|
_activeAnimationId = 0;
|
|
@@ -984,6 +1008,14 @@ using namespace facebook::react;
|
|
|
984
1008
|
[self writeLayerRect:paused.rect radius:paused.radius];
|
|
985
1009
|
}
|
|
986
1010
|
} else if (wasAnimating) {
|
|
1011
|
+
if (![self smoothClipCanDisplay]) {
|
|
1012
|
+
// Relayout while detached, with an animation dispatched to this view
|
|
1013
|
+
// (a transparentModal subtree resized before presentation): an install
|
|
1014
|
+
// from this commit would not survive the attach commit. Keep the
|
|
1015
|
+
// install deferred; didMoveToWindow completes it via the registry join.
|
|
1016
|
+
_pendingAnimationInstall = YES;
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
987
1019
|
const int32_t animationId = _activeAnimationId;
|
|
988
1020
|
const NSInteger animationKind = _activeAnimationKind;
|
|
989
1021
|
[self stopLayerAnimationWithoutCallback];
|
|
@@ -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,
|
package/package.json
CHANGED