react-native-smooth-clip-view 0.2.1 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -16
- package/android/CMakeLists.txt +4 -0
- package/android/build.gradle +1 -0
- 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 +200 -76
- package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +7 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +10 -0
- package/cpp/SmoothClipVelocityTracker.h +106 -0
- package/ios/SmoothClipRegistry.mm +153 -29
- package/ios/SmoothClipView.mm +67 -77
- package/ios/SmoothClipViewRegistry.h +8 -0
- package/lib/module/drivers.ios.js +14 -0
- package/lib/module/drivers.ios.js.map +1 -1
- package/lib/module/drivers.js +8 -0
- package/lib/module/drivers.js.map +1 -1
- package/lib/module/easings.js +35 -0
- package/lib/module/easings.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/typescript/src/driverTypes.d.ts +32 -3
- package/lib/typescript/src/driverTypes.d.ts.map +1 -1
- package/lib/typescript/src/drivers.d.ts.map +1 -1
- package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
- package/lib/typescript/src/easings.d.ts +33 -0
- package/lib/typescript/src/easings.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/index.web.d.ts +1 -0
- package/lib/typescript/src/index.web.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/driverTypes.ts +51 -19
- package/src/drivers.ios.ts +26 -0
- package/src/drivers.ts +11 -0
- package/src/easings.ts +32 -0
- package/src/index.ts +1 -0
- package/src/index.web.ts +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#include <array>
|
|
4
|
+
#include <cmath>
|
|
5
|
+
|
|
6
|
+
namespace smoothclip {
|
|
7
|
+
|
|
8
|
+
// The two most recent interactive samples used by `initialVelocity: 'inherit'`.
|
|
9
|
+
// Channels are the seven presentation scalars (x, y, width, height, radius,
|
|
10
|
+
// contentTranslateX, contentTranslateY) but the tracker is value-agnostic:
|
|
11
|
+
// iOS records normalized (host-clamped) geometry, Android raw DIP — the
|
|
12
|
+
// projection only needs both samples and the target in the same space.
|
|
13
|
+
// Shared by ios/SmoothClipView.mm and android/.../SmoothClipRegistry.cpp; a
|
|
14
|
+
// behavior change here changes both platforms.
|
|
15
|
+
struct VelocitySampleHistory {
|
|
16
|
+
bool hasPrevious = false;
|
|
17
|
+
bool hasLatest = false;
|
|
18
|
+
std::array<double, 7> previous{};
|
|
19
|
+
std::array<double, 7> latest{};
|
|
20
|
+
double previousTimeS = 0;
|
|
21
|
+
double latestTimeS = 0;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Two records closer than this are one observation of the same frame (an
|
|
25
|
+
// ACTION_UP adoption or an animateTo `from` seed lands in the same input
|
|
26
|
+
// batch as the last drag write, ~1 ms apart). The window sits well above
|
|
27
|
+
// batch spacing and below the 8.33 ms 120 Hz frame, so legitimate
|
|
28
|
+
// consecutive-frame samples never coalesce.
|
|
29
|
+
inline constexpr double kSampleCoalesceWindowS = 0.004;
|
|
30
|
+
// A latest sample older than this no longer describes the finger; 'inherit'
|
|
31
|
+
// yields zero instead of replaying stale motion.
|
|
32
|
+
inline constexpr double kVelocityStalenessS = 0.1;
|
|
33
|
+
|
|
34
|
+
inline void recordVelocitySample(
|
|
35
|
+
VelocitySampleHistory &history,
|
|
36
|
+
const std::array<double, 7> &channels,
|
|
37
|
+
double nowS) {
|
|
38
|
+
if (history.hasLatest && channels == history.latest) {
|
|
39
|
+
// Identical re-record (a `from` seed equal to the last interactive
|
|
40
|
+
// write): keep the pair and its timestamps, so the last real motion
|
|
41
|
+
// still ages out through the staleness guard instead of zeroing the
|
|
42
|
+
// sample deltas.
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (history.hasLatest &&
|
|
46
|
+
nowS - history.latestTimeS < kSampleCoalesceWindowS) {
|
|
47
|
+
// Same-frame re-record: replace the latest sample in place instead of
|
|
48
|
+
// rotating, so the surviving pair spans a real frame interval. Rotating
|
|
49
|
+
// here would divide the extra sub-frame displacement by microseconds and
|
|
50
|
+
// explode the inherited velocity.
|
|
51
|
+
history.latest = channels;
|
|
52
|
+
history.latestTimeS = nowS;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
history.hasPrevious = history.hasLatest;
|
|
56
|
+
history.previous = history.latest;
|
|
57
|
+
history.previousTimeS = history.latestTimeS;
|
|
58
|
+
history.hasLatest = true;
|
|
59
|
+
history.latest = channels;
|
|
60
|
+
history.latestTimeS = nowS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// beginInteraction semantics: history restarts from a single fresh sample;
|
|
64
|
+
// one more record is needed before 'inherit' can measure motion.
|
|
65
|
+
inline void resetVelocitySamples(
|
|
66
|
+
VelocitySampleHistory &history,
|
|
67
|
+
const std::array<double, 7> &channels,
|
|
68
|
+
double nowS) {
|
|
69
|
+
history.hasPrevious = false;
|
|
70
|
+
history.hasLatest = true;
|
|
71
|
+
history.latest = channels;
|
|
72
|
+
history.latestTimeS = nowS;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
inline void clearVelocitySamples(VelocitySampleHistory &history) {
|
|
76
|
+
history = VelocitySampleHistory{};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Normalized velocity of the recorded motion projected onto the
|
|
80
|
+
// latest→target trajectory, in units of remaining-distance per second
|
|
81
|
+
// (1 covers the remaining distance in one second) — the CASpringAnimation
|
|
82
|
+
// initialVelocity convention. iOS passes the scalar unchanged per keypath;
|
|
83
|
+
// Android seeds each integrator channel with scalar·displacement.
|
|
84
|
+
inline double inheritedVelocity(
|
|
85
|
+
const VelocitySampleHistory &history,
|
|
86
|
+
const std::array<double, 7> &target,
|
|
87
|
+
double nowS) {
|
|
88
|
+
if (!history.hasPrevious || !history.hasLatest) return 0;
|
|
89
|
+
const double elapsed = history.latestTimeS - history.previousTimeS;
|
|
90
|
+
if (elapsed <= 0 || nowS - history.latestTimeS > kVelocityStalenessS) {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
double numerator = 0;
|
|
94
|
+
double denominator = 0;
|
|
95
|
+
for (int index = 0; index < 7; index += 1) {
|
|
96
|
+
const double sampleDelta = history.latest[index] - history.previous[index];
|
|
97
|
+
const double destinationDelta = target[index] - history.latest[index];
|
|
98
|
+
numerator += sampleDelta * destinationDelta;
|
|
99
|
+
denominator += destinationDelta * destinationDelta;
|
|
100
|
+
}
|
|
101
|
+
if (denominator <= 1e-12) return 0;
|
|
102
|
+
const double result = numerator / elapsed / denominator;
|
|
103
|
+
return std::isfinite(result) ? result : 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
} // namespace smoothclip
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
animationId:(int32_t)animationId;
|
|
36
36
|
- (void)smoothClipCancelAnimationUsingTarget:(BOOL)useTarget;
|
|
37
37
|
- (BOOL)smoothClipIsJoinable;
|
|
38
|
+
- (BOOL)smoothClipCanDisplay;
|
|
39
|
+
- (BOOL)smoothClipHasPendingInstall;
|
|
38
40
|
@end
|
|
39
41
|
|
|
40
42
|
namespace smoothclip {
|
|
@@ -182,8 +184,12 @@ Presentation canonicalFrozenPresentation(DriverState &state) {
|
|
|
182
184
|
: state.latest;
|
|
183
185
|
bool hasCanonical = false;
|
|
184
186
|
for (const ViewKey key : state.views) {
|
|
185
|
-
|
|
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
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
#import "SmoothClipGeometry.h"
|
|
4
4
|
#import "SmoothClipViewRegistry.h"
|
|
5
5
|
|
|
6
|
+
#include "SmoothClipVelocityTracker.h"
|
|
7
|
+
|
|
6
8
|
#import <QuartzCore/QuartzCore.h>
|
|
7
9
|
#import <React/RCTConversions.h>
|
|
8
10
|
#import <React/RCTFabricComponentsPlugins.h>
|
|
@@ -48,6 +50,17 @@ using namespace facebook::react;
|
|
|
48
50
|
finished:(BOOL)finished;
|
|
49
51
|
@end
|
|
50
52
|
|
|
53
|
+
static std::array<double, 7> SmoothClipVelocityChannels(
|
|
54
|
+
CGRect rect, CGFloat radius, CGPoint contentTranslation) {
|
|
55
|
+
return {CGRectGetMinX(rect),
|
|
56
|
+
CGRectGetMinY(rect),
|
|
57
|
+
CGRectGetWidth(rect),
|
|
58
|
+
CGRectGetHeight(rect),
|
|
59
|
+
radius,
|
|
60
|
+
contentTranslation.x,
|
|
61
|
+
contentTranslation.y};
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
@implementation SmoothClipView {
|
|
52
65
|
SmoothClipContainerView *_clipContainer;
|
|
53
66
|
SmoothClipContainerView *_contentContainer;
|
|
@@ -82,16 +95,10 @@ using namespace facebook::react;
|
|
|
82
95
|
CGPoint _pausedContentTranslation;
|
|
83
96
|
CFTimeInterval _pausedRemaining;
|
|
84
97
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
CGFloat _previousInteractiveRadius;
|
|
90
|
-
CGFloat _interactiveRadius;
|
|
91
|
-
CGPoint _previousInteractiveContentTranslation;
|
|
92
|
-
CGPoint _interactiveContentTranslation;
|
|
93
|
-
CFTimeInterval _previousInteractiveTime;
|
|
94
|
-
CFTimeInterval _interactiveTime;
|
|
98
|
+
// 'inherit' velocity samples (normalized geometry, per view); recording/
|
|
99
|
+
// coalescing/projection live in the shared cpp/SmoothClipVelocityTracker.h
|
|
100
|
+
// (behavior-paired with Android's per-driver history).
|
|
101
|
+
smoothclip::VelocitySampleHistory _velocitySamples;
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
+ (ComponentDescriptorProvider)componentDescriptorProvider {
|
|
@@ -144,8 +151,7 @@ using namespace facebook::react;
|
|
|
144
151
|
_activeAnimationKind = 0;
|
|
145
152
|
_pendingAnimationInstall = NO;
|
|
146
153
|
_animationPaused = NO;
|
|
147
|
-
|
|
148
|
-
_hasInteractiveSample = NO;
|
|
154
|
+
smoothclip::clearVelocitySamples(_velocitySamples);
|
|
149
155
|
[[NSNotificationCenter defaultCenter]
|
|
150
156
|
addObserver:self
|
|
151
157
|
selector:@selector(applicationWillResignActive)
|
|
@@ -167,6 +173,13 @@ using namespace facebook::react;
|
|
|
167
173
|
}
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
- (void)didMoveToWindow {
|
|
177
|
+
[super didMoveToWindow];
|
|
178
|
+
if (_driverId != 0 && [self smoothClipCanDisplay]) {
|
|
179
|
+
smoothclip::viewBecameDisplayable(_driverId, self);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
170
183
|
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
|
|
171
184
|
index:(NSInteger)index {
|
|
172
185
|
NSAssert(childComponentView.superview == nil,
|
|
@@ -298,71 +311,24 @@ using namespace facebook::react;
|
|
|
298
311
|
- (void)recordInteractiveRect:(CGRect)rect
|
|
299
312
|
radius:(CGFloat)radius
|
|
300
313
|
contentTranslation:(CGPoint)contentTranslation {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
_hasPreviousInteractiveSample = YES;
|
|
310
|
-
_previousInteractiveRect = _interactiveRect;
|
|
311
|
-
_previousInteractiveRadius = _interactiveRadius;
|
|
312
|
-
_previousInteractiveContentTranslation =
|
|
313
|
-
_interactiveContentTranslation;
|
|
314
|
-
_previousInteractiveTime = _interactiveTime;
|
|
315
|
-
}
|
|
316
|
-
_hasInteractiveSample = YES;
|
|
317
|
-
_interactiveRect = rect;
|
|
318
|
-
_interactiveRadius = radius;
|
|
319
|
-
_interactiveContentTranslation = contentTranslation;
|
|
320
|
-
_interactiveTime = CACurrentMediaTime();
|
|
314
|
+
// Identical-value dedupe and same-frame coalescing live in the shared
|
|
315
|
+
// tracker; coalescing keeps a distinct re-record issued sub-frame after
|
|
316
|
+
// the last one (e.g. a fused animateTo `from` seed at gesture release)
|
|
317
|
+
// from exploding the inherited velocity.
|
|
318
|
+
smoothclip::recordVelocitySample(
|
|
319
|
+
_velocitySamples,
|
|
320
|
+
SmoothClipVelocityChannels(rect, radius, contentTranslation),
|
|
321
|
+
CACurrentMediaTime());
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
- (CGFloat)inheritedVelocityToRect:(CGRect)target
|
|
324
325
|
radius:(CGFloat)targetRadius
|
|
325
326
|
contentTranslation:(CGPoint)targetContentTranslation {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const CGFloat current[] = {
|
|
332
|
-
CGRectGetMinX(_interactiveRect),
|
|
333
|
-
CGRectGetMinY(_interactiveRect),
|
|
334
|
-
CGRectGetWidth(_interactiveRect),
|
|
335
|
-
CGRectGetHeight(_interactiveRect),
|
|
336
|
-
_interactiveRadius,
|
|
337
|
-
_interactiveContentTranslation.x,
|
|
338
|
-
_interactiveContentTranslation.y};
|
|
339
|
-
const CGFloat previous[] = {
|
|
340
|
-
CGRectGetMinX(_previousInteractiveRect),
|
|
341
|
-
CGRectGetMinY(_previousInteractiveRect),
|
|
342
|
-
CGRectGetWidth(_previousInteractiveRect),
|
|
343
|
-
CGRectGetHeight(_previousInteractiveRect),
|
|
344
|
-
_previousInteractiveRadius,
|
|
345
|
-
_previousInteractiveContentTranslation.x,
|
|
346
|
-
_previousInteractiveContentTranslation.y};
|
|
347
|
-
const CGFloat destination[] = {
|
|
348
|
-
CGRectGetMinX(target),
|
|
349
|
-
CGRectGetMinY(target),
|
|
350
|
-
CGRectGetWidth(target),
|
|
351
|
-
CGRectGetHeight(target),
|
|
352
|
-
targetRadius,
|
|
353
|
-
targetContentTranslation.x,
|
|
354
|
-
targetContentTranslation.y};
|
|
355
|
-
double numerator = 0;
|
|
356
|
-
double denominator = 0;
|
|
357
|
-
for (NSUInteger index = 0; index < 7; index += 1) {
|
|
358
|
-
const double velocity = (current[index] - previous[index]) / elapsed;
|
|
359
|
-
const double displacement = destination[index] - current[index];
|
|
360
|
-
numerator += velocity * displacement;
|
|
361
|
-
denominator += displacement * displacement;
|
|
362
|
-
}
|
|
363
|
-
if (denominator <= DBL_EPSILON) return 0;
|
|
364
|
-
const double result = numerator / denominator;
|
|
365
|
-
return isfinite(result) ? result : 0;
|
|
327
|
+
return smoothclip::inheritedVelocity(
|
|
328
|
+
_velocitySamples,
|
|
329
|
+
SmoothClipVelocityChannels(
|
|
330
|
+
target, targetRadius, targetContentTranslation),
|
|
331
|
+
CACurrentMediaTime());
|
|
366
332
|
}
|
|
367
333
|
|
|
368
334
|
- (void)smoothClipApplyPresentation:(smoothclip::Presentation)presentation {
|
|
@@ -403,6 +369,17 @@ using namespace facebook::react;
|
|
|
403
369
|
return _hasLayout;
|
|
404
370
|
}
|
|
405
371
|
|
|
372
|
+
- (BOOL)smoothClipCanDisplay {
|
|
373
|
+
// A CA animation committed while this view's layer tree is detached from
|
|
374
|
+
// the render tree does not survive the later attach commit; installs and
|
|
375
|
+
// latch starts must wait until a frame can actually be produced.
|
|
376
|
+
return _hasLayout && self.window != nil;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
- (BOOL)smoothClipHasPendingInstall {
|
|
380
|
+
return _pendingAnimationInstall;
|
|
381
|
+
}
|
|
382
|
+
|
|
406
383
|
- (smoothclip::Presentation)smoothClipFreezePresentation {
|
|
407
384
|
const smoothclip::Presentation visible =
|
|
408
385
|
[self smoothClipCurrentPresentation];
|
|
@@ -647,7 +624,7 @@ using namespace facebook::react;
|
|
|
647
624
|
}
|
|
648
625
|
|
|
649
626
|
- (void)startAnimationToRequestedGeometryWithDuration:(CFTimeInterval)duration {
|
|
650
|
-
if (!
|
|
627
|
+
if (![self smoothClipCanDisplay]) {
|
|
651
628
|
_pendingAnimationInstall = YES;
|
|
652
629
|
return;
|
|
653
630
|
}
|
|
@@ -720,7 +697,7 @@ using namespace facebook::react;
|
|
|
720
697
|
[self storeRequestedPresentation:presentation];
|
|
721
698
|
_activeAnimationKind = 3;
|
|
722
699
|
_activeAnimationId = animationId;
|
|
723
|
-
if (!
|
|
700
|
+
if (![self smoothClipCanDisplay]) {
|
|
724
701
|
_pendingAnimationInstall = YES;
|
|
725
702
|
return;
|
|
726
703
|
}
|
|
@@ -937,8 +914,14 @@ using namespace facebook::react;
|
|
|
937
914
|
// the true remaining duration instead of jumping to the target.
|
|
938
915
|
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
|
|
939
916
|
_hasLayout = YES;
|
|
940
|
-
_pendingAnimationInstall = NO;
|
|
941
917
|
[self layoutContentContainer];
|
|
918
|
+
if (self.window == nil) {
|
|
919
|
+
// Still detached (transparentModal subtree before presentation): an
|
|
920
|
+
// install here would not survive the attach commit. Keep the install
|
|
921
|
+
// pending; didMoveToWindow completes it.
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
_pendingAnimationInstall = NO;
|
|
942
925
|
if (!smoothclip::joinActiveAnimation(_driverId, self)) {
|
|
943
926
|
// The animation finished or was cancelled while awaiting layout.
|
|
944
927
|
_activeAnimationId = 0;
|
|
@@ -984,6 +967,14 @@ using namespace facebook::react;
|
|
|
984
967
|
[self writeLayerRect:paused.rect radius:paused.radius];
|
|
985
968
|
}
|
|
986
969
|
} else if (wasAnimating) {
|
|
970
|
+
if (![self smoothClipCanDisplay]) {
|
|
971
|
+
// Relayout while detached, with an animation dispatched to this view
|
|
972
|
+
// (a transparentModal subtree resized before presentation): an install
|
|
973
|
+
// from this commit would not survive the attach commit. Keep the
|
|
974
|
+
// install deferred; didMoveToWindow completes it via the registry join.
|
|
975
|
+
_pendingAnimationInstall = YES;
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
987
978
|
const int32_t animationId = _activeAnimationId;
|
|
988
979
|
const NSInteger animationKind = _activeAnimationKind;
|
|
989
980
|
[self stopLayerAnimationWithoutCallback];
|
|
@@ -1081,8 +1072,7 @@ using namespace facebook::react;
|
|
|
1081
1072
|
[self stopLayerAnimationWithoutCallback];
|
|
1082
1073
|
_pendingAnimationInstall = NO;
|
|
1083
1074
|
_animationPaused = NO;
|
|
1084
|
-
|
|
1085
|
-
_hasInteractiveSample = NO;
|
|
1075
|
+
smoothclip::clearVelocitySamples(_velocitySamples);
|
|
1086
1076
|
[super prepareForRecycle];
|
|
1087
1077
|
_requestedClip = CGRectZero;
|
|
1088
1078
|
_normalizedClip = CGRectZero;
|
|
@@ -16,6 +16,14 @@ void unregisterView(uint64_t driverId, SmoothClipView *view);
|
|
|
16
16
|
// install was deferred because it had no layout yet. Returns false when the
|
|
17
17
|
// driver or its active animation no longer exists.
|
|
18
18
|
bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view);
|
|
19
|
+
// Called when a registered view first becomes able to produce a visible
|
|
20
|
+
// frame (has layout AND is attached to a window). Starts a latched animation
|
|
21
|
+
// — rebasing its clock so no progress is burned while the view was
|
|
22
|
+
// detached — or completes a deferred install for an already-running one.
|
|
23
|
+
// A CA animation committed while the host's layer tree is detached (e.g. a
|
|
24
|
+
// transparentModal subtree before its view controller is presented) does not
|
|
25
|
+
// survive the attach commit; installs must therefore wait for this signal.
|
|
26
|
+
void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view);
|
|
19
27
|
void viewAnimationDidStop(
|
|
20
28
|
uint64_t driverId,
|
|
21
29
|
int32_t animationId,
|
|
@@ -45,6 +45,11 @@ function reduceMotionCode(value) {
|
|
|
45
45
|
function animationIsFinite(target, animation) {
|
|
46
46
|
'worklet';
|
|
47
47
|
|
|
48
|
+
if (animation.from !== undefined && !isFiniteClipPresentation(animation.from)) {
|
|
49
|
+
// Silently dropping an invalid explicit start would re-open the exact
|
|
50
|
+
// stale-start handoff `from` exists to close — reject the whole call.
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
48
53
|
if (animation.type === 'timing') {
|
|
49
54
|
return Number.isFinite(animation.duration) && animation.controlPoints.every(Number.isFinite);
|
|
50
55
|
}
|
|
@@ -165,6 +170,15 @@ export function useSmoothClipDriver(initialValue, options = {}) {
|
|
|
165
170
|
if (!isFiniteClipPresentation(target) || !animationIsFinite(target, animation)) {
|
|
166
171
|
return rejectAnimationHostFunction(driverId);
|
|
167
172
|
}
|
|
173
|
+
const from = animation.from;
|
|
174
|
+
if (from !== undefined) {
|
|
175
|
+
// Fused hot write: exactly setScalars(from…) issued immediately
|
|
176
|
+
// before the handoff, so native's latest value — the animation
|
|
177
|
+
// start below — is the caller's explicit presentation. Also
|
|
178
|
+
// re-grabs from a running animation, which the implicit
|
|
179
|
+
// interactive-start path would silently skip.
|
|
180
|
+
setScalarsOnUI(from.clip.x, from.clip.y, from.clip.width, from.clip.height, from.clip.radius, from.contentTranslateX, from.contentTranslateY);
|
|
181
|
+
}
|
|
168
182
|
|
|
169
183
|
// After a setScalars hot write the SharedValue no longer matches what
|
|
170
184
|
// is on screen; passing it as the start would snap the animation back.
|