react-native-smooth-clip-view 0.2.1 → 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 +16 -10
- 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 +137 -24
- 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 +153 -29
- package/ios/SmoothClipView.mm +35 -3
- package/ios/SmoothClipViewRegistry.h +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -194,16 +194,22 @@ 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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
207
213
|
`finished: false` completion is delivered.
|
|
208
214
|
|
|
209
215
|
Do not call `driver.ui` from React code — it throws on the React runtime; use
|
|
@@ -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
|
});
|
|
@@ -458,6 +458,36 @@ Presentation prepareAnimation(
|
|
|
458
458
|
return resolvedStart;
|
|
459
459
|
}
|
|
460
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
|
+
|
|
461
491
|
int32_t startAnimation(
|
|
462
492
|
uint64_t driverId,
|
|
463
493
|
DriverState &state,
|
|
@@ -469,13 +499,14 @@ int32_t startAnimation(
|
|
|
469
499
|
// visible all read animation->current, giving a never-rendered latch
|
|
470
500
|
// freeze-at-start / replace-from-start semantics with no extra branches.
|
|
471
501
|
animation.current = animation.start;
|
|
472
|
-
animation.started =
|
|
502
|
+
animation.started = anyDisplayableView(state);
|
|
473
503
|
state.animation = std::move(animation);
|
|
474
504
|
if (!state.animation->started) {
|
|
475
|
-
// Latch: no host view yet (animateTo raced the mount
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
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.
|
|
479
510
|
return state.animation->id;
|
|
480
511
|
}
|
|
481
512
|
auto &active = animatingDrivers();
|
|
@@ -603,6 +634,15 @@ void setPresentation(
|
|
|
603
634
|
if (!takeOwnership && state.ownership != Ownership::Interactive) return;
|
|
604
635
|
if (takeOwnership) {
|
|
605
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
|
+
}
|
|
606
646
|
finishActive(driverId, state, false);
|
|
607
647
|
}
|
|
608
648
|
state.latest = presentation;
|
|
@@ -795,6 +835,12 @@ void destroyDriver(uint64_t driverId) {
|
|
|
795
835
|
|
|
796
836
|
// --- Android view lifecycle ----------------------------------------------
|
|
797
837
|
|
|
838
|
+
bool JSmoothClipView::isViewAttachedToWindow() const {
|
|
839
|
+
static const auto method =
|
|
840
|
+
javaClassStatic()->getMethod<jboolean()>("isAttachedToWindow");
|
|
841
|
+
return method(self());
|
|
842
|
+
}
|
|
843
|
+
|
|
798
844
|
void JSmoothClipView::applyClip(const Presentation &presentation) const {
|
|
799
845
|
static const auto method =
|
|
800
846
|
javaClassStatic()
|
|
@@ -849,15 +895,16 @@ void registerViewAndroid(
|
|
|
849
895
|
? state.animation->current
|
|
850
896
|
: state.latest;
|
|
851
897
|
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.
|
|
855
898
|
for (auto &existing : state.views) {
|
|
856
899
|
if (env->IsSameObject(existing.view.get(), view.get())) {
|
|
857
900
|
existing.density = density;
|
|
858
901
|
existing.hostWidthPx = hostWidthPx;
|
|
859
902
|
existing.hostHeightPx = hostHeightPx;
|
|
860
903
|
deliverToView(existing, visible);
|
|
904
|
+
if (state.animation.has_value() && !state.animation->started &&
|
|
905
|
+
entryDisplayable(existing)) {
|
|
906
|
+
startLatchedAnimation(driverId, state);
|
|
907
|
+
}
|
|
861
908
|
return;
|
|
862
909
|
}
|
|
863
910
|
}
|
|
@@ -867,22 +914,17 @@ void registerViewAndroid(
|
|
|
867
914
|
// registering view — the correct first frame for a latched animation.
|
|
868
915
|
deliverToView(entry, visible);
|
|
869
916
|
state.views.push_back(std::move(entry));
|
|
870
|
-
if (state.animation.has_value() && !state.animation->started
|
|
917
|
+
if (state.animation.has_value() && !state.animation->started &&
|
|
918
|
+
entryDisplayable(state.views.back())) {
|
|
871
919
|
// Start the latched animation: rebase the clock so the first frame
|
|
872
|
-
// integrates from now, not from the pre-mount animateTo call.
|
|
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.
|
|
873
923
|
// Note this function has no isOnMainThread() guard (Kotlin calls it from
|
|
874
924
|
// the view's main-thread attach path); scheduleFrame() is itself
|
|
875
925
|
// main-thread-gated, so an off-main register cannot bind the
|
|
876
926
|
// choreographer to the wrong thread.
|
|
877
|
-
|
|
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();
|
|
927
|
+
startLatchedAnimation(driverId, state);
|
|
886
928
|
}
|
|
887
929
|
}
|
|
888
930
|
|
|
@@ -908,6 +950,29 @@ void setViewHostGeometryAndroid(
|
|
|
908
950
|
state.animation.has_value() ? state.animation->current
|
|
909
951
|
: state.latest);
|
|
910
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
|
+
}
|
|
911
976
|
return;
|
|
912
977
|
}
|
|
913
978
|
}
|
|
@@ -930,11 +995,59 @@ void unregisterViewAndroid(
|
|
|
930
995
|
}
|
|
931
996
|
if (removed && state.animation.has_value()) {
|
|
932
997
|
state.animation->finished = false;
|
|
933
|
-
if (state.views.empty()) {
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
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
|
+
}
|
|
938
1051
|
}
|
|
939
1052
|
}
|
|
940
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 {
|
|
@@ -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
|
-
|
|
186
|
-
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
state
|
|
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 (!
|
|
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())
|
|
429
|
+
if (iterator == registry().end()) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
370
432
|
auto &state = iterator->second;
|
|
371
|
-
if (!state.animation.has_value() || state.views.empty())
|
|
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)
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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())
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
543
|
-
// returned synchronously so the JS side does not
|
|
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 =
|
|
713
|
+
active.started = anyDisplayableView(state);
|
|
590
714
|
state.animation = std::move(active);
|
|
591
715
|
if (!state.animation->started) {
|
|
592
|
-
// Latched until the first
|
|
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 =
|
|
764
|
+
active.started = anyDisplayableView(state);
|
|
641
765
|
state.animation = std::move(active);
|
|
642
766
|
if (!state.animation->started) {
|
|
643
|
-
// Latched until the first
|
|
767
|
+
// Latched until the first displayable view — see animateTiming.
|
|
644
768
|
return animationId;
|
|
645
769
|
}
|
|
646
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