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
package/README.md
CHANGED
|
@@ -141,10 +141,16 @@ const gesture = Gesture.Pan()
|
|
|
141
141
|
// bookkeeping. Assigning driver.presentation.value also works.
|
|
142
142
|
driver.ui.setScalars(clip.x, clip.y, clip.width, clip.height, clip.radius, 0, 0);
|
|
143
143
|
})
|
|
144
|
-
.onEnd(() => {
|
|
144
|
+
.onEnd((event) => {
|
|
145
|
+
// The release event is fresher than the last onUpdate (on Android,
|
|
146
|
+
// ACTION_UP carries a position no MOVE ever delivered). Pass the final
|
|
147
|
+
// geometry as `from` so the animation starts from exactly that value —
|
|
148
|
+
// it fuses a setScalars hot write with the handoff in one call.
|
|
149
|
+
const clip = geometryForDrag(dragStart.value, event.translationY);
|
|
145
150
|
driver.ui.animateTo(createClipPresentation(expandedClip), {
|
|
146
151
|
type: 'spring',
|
|
147
152
|
initialVelocity: 'inherit',
|
|
153
|
+
from: createClipPresentation(clip),
|
|
148
154
|
});
|
|
149
155
|
});
|
|
150
156
|
```
|
|
@@ -175,35 +181,64 @@ const gesture = Gesture.Pan()
|
|
|
175
181
|
streams such as gestures. `driver.presentation.value` is stale after hot
|
|
176
182
|
writes by design; `beginInteraction()` remains the source of truth for
|
|
177
183
|
visible geometry, and `animateTo()` after hot writes starts from the native
|
|
178
|
-
registry's latest value rather than the stale SharedValue.
|
|
179
|
-
|
|
184
|
+
registry's latest value rather than the stale SharedValue. To start from a
|
|
185
|
+
value fresher than the last hot write (a gesture's release sample), pass it
|
|
186
|
+
as `animation.from` — `animateTo` then performs the hot write and the
|
|
187
|
+
handoff in one call. Do not interleave `setScalars` with
|
|
188
|
+
`presentation.value` writes on the same driver.
|
|
180
189
|
- Spring `initialVelocity` is one normalized scalar along the current-to-target
|
|
181
190
|
trajectory, in units of the remaining distance per second (`1` covers the
|
|
182
191
|
remaining distance in one second). Every geometry channel continues with the
|
|
183
192
|
same normalized rate, so grab/release preserves the felt direction and
|
|
184
193
|
speed. `'inherit'` (the default) estimates the scalar from the last two
|
|
185
194
|
interactive samples on iOS and Android; samples older than 100 ms — and the
|
|
186
|
-
web fallback — fall back to zero.
|
|
195
|
+
web fallback — fall back to zero. Two writes landing inside the same frame
|
|
196
|
+
(< 4 ms apart, e.g. a release-sample `from` seed right after the last drag
|
|
197
|
+
write) coalesce into one sample, and an identical re-write is ignored, so a
|
|
198
|
+
fused handoff can neither zero nor inflate the inherited velocity.
|
|
187
199
|
- `driver.react` exposes `beginInteraction`, `set`, `animateTo`, and `cancel`
|
|
188
200
|
as Promises (`setScalars` is UI-worklet-only). React code never blocks
|
|
189
201
|
waiting for main/UI-thread work. An immediate animation request resolves
|
|
190
202
|
before its completion callback is delivered.
|
|
191
203
|
- `animateTo()` transfers ownership to native animation. Timing uses
|
|
192
|
-
cubic Bézier control points
|
|
204
|
+
cubic Bézier control points — `ClipEasings` exports exact-form presets
|
|
205
|
+
(`easeOutCubic` = `Easing.out(Easing.cubic)` etc.) so a parallel Reanimated
|
|
206
|
+
animation can run the identical curve without hand-deriving it; springs
|
|
207
|
+
accept mass, stiffness, damping, and an
|
|
193
208
|
explicit normalized velocity or `'inherit'` (the default). Keyframes accept
|
|
194
|
-
validated, monotonically increasing offsets from zero through one.
|
|
209
|
+
validated, monotonically increasing offsets from zero through one. Every
|
|
210
|
+
kind accepts an optional `from` presentation — a fused take-ownership hot
|
|
211
|
+
write issued immediately before the handoff, so the animation starts from
|
|
212
|
+
exactly that value (pass `frames[0].presentation` for keyframes, which
|
|
213
|
+
interpolate absolutely). A non-finite `from` rejects the whole call; against
|
|
214
|
+
a held pending-animation latch the seed is dropped — the latch is newer
|
|
215
|
+
intent, and nothing is displayable yet anyway. `from` behaves the same on
|
|
216
|
+
both platforms (it is driver-layer, not native): on iOS the seed stops any
|
|
217
|
+
running Core Animation and writes the model layer first; keyframes then
|
|
218
|
+
start exactly at `from`, while timing/spring sample their from-value off
|
|
219
|
+
the presentation layer — the last committed frame, at most one frame
|
|
220
|
+
behind `from` — identical to the explicit two-call pattern.
|
|
195
221
|
- `cancel()` freezes visible presentation by default. Pass `'target'` as its
|
|
196
222
|
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
|
-
`finished: false` completion
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
the
|
|
205
|
-
|
|
206
|
-
|
|
223
|
+
- `options.onAnimationComplete` fires exactly once per animation with its ID
|
|
224
|
+
and `finished` state, including cancellation, replacement, and native-side
|
|
225
|
+
rejection (`animateTo` then returns a fresh non-zero id whose single
|
|
226
|
+
`finished: false` completion follows — key completion handling by the
|
|
227
|
+
returned id, never by `0`). `animateTo` returns `0` — and delivers no
|
|
228
|
+
completion — only when the driver entry no longer exists (destroyed) or the
|
|
229
|
+
call ran off the main thread. Unmounting the last host mid-flight does not complete the
|
|
230
|
+
animation: the remainder is re-latched and the next displayable host resumes
|
|
231
|
+
it, so the completion arrives when it finishes there — or unfinished on
|
|
232
|
+
replacement, cancellation, `beginInteraction()`, or driver destruction.
|
|
233
|
+
- An `animateTo` issued before any host view can produce a visible frame (for
|
|
234
|
+
example from an effect in the same commit that mounts the host, or inside a
|
|
235
|
+
modal route whose subtree attaches to its window late) is held pending and
|
|
236
|
+
starts with its full duration at the first displayable registration or
|
|
237
|
+
window attach. A pending animation owns the driver: take-ownership writes
|
|
238
|
+
(`set`, `setScalars`, the hook's seed) are dropped while it is held —
|
|
239
|
+
replace it with another `animateTo`, or cancel it via `beginInteraction()`
|
|
240
|
+
or `cancel()`. If no view ever becomes displayable, it survives until it is
|
|
241
|
+
replaced, cancelled, or the driver is destroyed — at which point its single
|
|
207
242
|
`finished: false` completion is delivered.
|
|
208
243
|
|
|
209
244
|
Do not call `driver.ui` from React code — it throws on the React runtime; use
|
package/android/CMakeLists.txt
CHANGED
|
@@ -16,6 +16,10 @@ file(GLOB smoothclip_SOURCES CONFIGURE_DEPENDS
|
|
|
16
16
|
|
|
17
17
|
add_library(smoothclipview SHARED ${smoothclip_SOURCES})
|
|
18
18
|
|
|
19
|
+
# 16 KB page-size support for NDKs where ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES
|
|
20
|
+
# is unavailable (< r26.3) or ignored.
|
|
21
|
+
target_link_options(smoothclipview PRIVATE "-Wl,-z,max-page-size=16384")
|
|
22
|
+
|
|
19
23
|
# C++20, RTTI, exceptions and React Native macros.
|
|
20
24
|
include("${REACT_NATIVE_DIR}/ReactCommon/cmake-utils/react-native-flags.cmake")
|
|
21
25
|
target_compile_reactnative_options(smoothclipview PUBLIC)
|
package/android/build.gradle
CHANGED
|
@@ -86,6 +86,7 @@ android {
|
|
|
86
86
|
cmake {
|
|
87
87
|
cppFlags "-O2", "-frtti", "-fexceptions", "-Wall", "-std=c++20"
|
|
88
88
|
arguments "-DANDROID_STL=c++_shared",
|
|
89
|
+
"-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
|
|
89
90
|
"-DREACT_NATIVE_DIR=${reactNativeRootDir}"
|
|
90
91
|
abiFilters(*reactNativeArchitectures())
|
|
91
92
|
}
|
|
@@ -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
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#include "SmoothClipAndroid.h"
|
|
2
|
+
#include "SmoothClipVelocityTracker.h"
|
|
2
3
|
|
|
3
4
|
#include <android/choreographer.h>
|
|
4
5
|
#include <fbjni/fbjni.h>
|
|
@@ -42,6 +43,11 @@ struct ActiveAnimation {
|
|
|
42
43
|
std::vector<Keyframe> keyframes;
|
|
43
44
|
double durationS = 0;
|
|
44
45
|
double startedAtS = 0;
|
|
46
|
+
// Wall-clock twin of startedAtS: stamped together with it, NEVER rebased
|
|
47
|
+
// by the frame-clock anchor. Readers that need honest wall elapsed (the
|
|
48
|
+
// unregister re-latch remainder, the run-ahead guard) use this; the
|
|
49
|
+
// animation curve itself paces on the anchored startedAtS frame axis.
|
|
50
|
+
double wallStartedAtS = 0;
|
|
45
51
|
// Integrated spring state (channels: x, y, width, height, radius, tx, ty).
|
|
46
52
|
std::array<double, 7> springPosition{};
|
|
47
53
|
std::array<double, 7> springVelocity{};
|
|
@@ -51,6 +57,10 @@ struct ActiveAnimation {
|
|
|
51
57
|
// registered, held un-rendered (and out of animatingDrivers()) until the
|
|
52
58
|
// first registerViewAndroid rebases the clock and starts it.
|
|
53
59
|
bool started = false;
|
|
60
|
+
// False until the first advance() translates the wall-clock start stamp
|
|
61
|
+
// onto the choreographer frame-time axis (elapsed-preserving). Every
|
|
62
|
+
// startedAtS stamp clears it so a re-latched resume re-anchors too.
|
|
63
|
+
bool frameClockAnchored = false;
|
|
54
64
|
};
|
|
55
65
|
|
|
56
66
|
// Per-view fanout state. Density and host metrics are pushed from Kotlin at
|
|
@@ -75,12 +85,9 @@ struct DriverState {
|
|
|
75
85
|
// last view leaves and revived by a take-ownership setPresentation.
|
|
76
86
|
bool destroyed = false;
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Presentation latestSample{{0, 0, 0, 0, 0}, 0, 0};
|
|
82
|
-
double previousSampleTimeS = 0;
|
|
83
|
-
double latestSampleTimeS = 0;
|
|
88
|
+
// 'inherit' velocity samples; recording/coalescing/projection live in the
|
|
89
|
+
// shared cpp/SmoothClipVelocityTracker.h (behavior-paired with iOS).
|
|
90
|
+
VelocitySampleHistory samples;
|
|
84
91
|
};
|
|
85
92
|
|
|
86
93
|
struct CompletionSink {
|
|
@@ -221,6 +228,10 @@ constexpr double kSpringSettleVelocity = 1.0;
|
|
|
221
228
|
// Settle-based termination is backed by a hard cap so a pathological
|
|
222
229
|
// configuration cannot run the frame loop forever.
|
|
223
230
|
constexpr double kSpringMaxDurationS = 10.0;
|
|
231
|
+
// One vsync of slack for the post-stall run-ahead guard in advance():
|
|
232
|
+
// frame-axis elapsed may lead wall elapsed by at most this much before the
|
|
233
|
+
// animation is re-anchored onto honest wall time.
|
|
234
|
+
constexpr double kMaxFrameLeadS = 0.017;
|
|
224
235
|
|
|
225
236
|
// Semi-implicit Euler per channel, substepped: one frame-sized step is only
|
|
226
237
|
// stable for omega*dt < 2, and accepted stiffness/mass ratios exceed that at
|
|
@@ -367,46 +378,6 @@ void applyToViews(DriverState &state, const Presentation &presentation) {
|
|
|
367
378
|
}
|
|
368
379
|
}
|
|
369
380
|
|
|
370
|
-
void recordInteractiveSample(DriverState &state, const Presentation &presentation) {
|
|
371
|
-
state.hasPreviousSample = state.hasLatestSample;
|
|
372
|
-
state.previousSample = state.latestSample;
|
|
373
|
-
state.previousSampleTimeS = state.latestSampleTimeS;
|
|
374
|
-
state.hasLatestSample = true;
|
|
375
|
-
state.latestSample = presentation;
|
|
376
|
-
state.latestSampleTimeS = nowSeconds();
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
double inheritedVelocity(DriverState &state, const Presentation &target) {
|
|
380
|
-
if (!state.hasPreviousSample || !state.hasLatestSample) return 0;
|
|
381
|
-
const double elapsed = state.latestSampleTimeS - state.previousSampleTimeS;
|
|
382
|
-
if (elapsed <= 0 || nowSeconds() - state.latestSampleTimeS > 0.1) return 0;
|
|
383
|
-
const double sample[7] = {
|
|
384
|
-
state.latestSample.clip.x - state.previousSample.clip.x,
|
|
385
|
-
state.latestSample.clip.y - state.previousSample.clip.y,
|
|
386
|
-
state.latestSample.clip.width - state.previousSample.clip.width,
|
|
387
|
-
state.latestSample.clip.height - state.previousSample.clip.height,
|
|
388
|
-
state.latestSample.clip.radius - state.previousSample.clip.radius,
|
|
389
|
-
state.latestSample.contentTranslateX - state.previousSample.contentTranslateX,
|
|
390
|
-
state.latestSample.contentTranslateY - state.previousSample.contentTranslateY};
|
|
391
|
-
const double destination[7] = {
|
|
392
|
-
target.clip.x - state.latestSample.clip.x,
|
|
393
|
-
target.clip.y - state.latestSample.clip.y,
|
|
394
|
-
target.clip.width - state.latestSample.clip.width,
|
|
395
|
-
target.clip.height - state.latestSample.clip.height,
|
|
396
|
-
target.clip.radius - state.latestSample.clip.radius,
|
|
397
|
-
target.contentTranslateX - state.latestSample.contentTranslateX,
|
|
398
|
-
target.contentTranslateY - state.latestSample.contentTranslateY};
|
|
399
|
-
double numerator = 0;
|
|
400
|
-
double denominator = 0;
|
|
401
|
-
for (int index = 0; index < 7; index += 1) {
|
|
402
|
-
numerator += sample[index] * destination[index];
|
|
403
|
-
denominator += destination[index] * destination[index];
|
|
404
|
-
}
|
|
405
|
-
if (denominator <= 1e-12) return 0;
|
|
406
|
-
const double result = numerator / elapsed / denominator;
|
|
407
|
-
return std::isfinite(result) ? result : 0;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
381
|
void applyPresentation(DriverState &state, const Presentation &presentation) {
|
|
411
382
|
state.latest = presentation;
|
|
412
383
|
state.hasLatest = true;
|
|
@@ -458,24 +429,62 @@ Presentation prepareAnimation(
|
|
|
458
429
|
return resolvedStart;
|
|
459
430
|
}
|
|
460
431
|
|
|
432
|
+
// A view can only render the animation once it is attached to a window and
|
|
433
|
+
// has pushed real host geometry; a clock started before that burns progress
|
|
434
|
+
// no one can see (the transparentModal mount pattern).
|
|
435
|
+
bool entryDisplayable(const ViewEntry &entry) {
|
|
436
|
+
return entry.hostWidthPx > 0 && entry.hostHeightPx > 0 &&
|
|
437
|
+
entry.view->isViewAttachedToWindow();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
bool anyDisplayableView(const DriverState &state) {
|
|
441
|
+
for (const ViewEntry &entry : state.views) {
|
|
442
|
+
if (entryDisplayable(entry)) return true;
|
|
443
|
+
}
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Starts a latched animation: rebases the clock so no progress was burned
|
|
448
|
+
// while no view could display, then joins the choreographer loop.
|
|
449
|
+
void startLatchedAnimation(uint64_t driverId, DriverState &state) {
|
|
450
|
+
auto &animation = *state.animation;
|
|
451
|
+
animation.started = true;
|
|
452
|
+
animation.startedAtS = nowSeconds();
|
|
453
|
+
animation.lastFrameS = animation.startedAtS;
|
|
454
|
+
animation.wallStartedAtS = animation.startedAtS;
|
|
455
|
+
// Load-bearing for re-latch resumes: unregisterViewAndroid rewrites this
|
|
456
|
+
// ActiveAnimation in place, so a stale anchor would replay the fraction-0
|
|
457
|
+
// first frame the anchor exists to remove.
|
|
458
|
+
animation.frameClockAnchored = false;
|
|
459
|
+
auto &active = animatingDrivers();
|
|
460
|
+
if (std::find(active.begin(), active.end(), driverId) == active.end()) {
|
|
461
|
+
active.push_back(driverId);
|
|
462
|
+
}
|
|
463
|
+
applyToViews(state, animation.start);
|
|
464
|
+
scheduleFrame();
|
|
465
|
+
}
|
|
466
|
+
|
|
461
467
|
int32_t startAnimation(
|
|
462
468
|
uint64_t driverId,
|
|
463
469
|
DriverState &state,
|
|
464
470
|
ActiveAnimation animation) {
|
|
465
471
|
animation.startedAtS = nowSeconds();
|
|
466
472
|
animation.lastFrameS = animation.startedAtS;
|
|
473
|
+
animation.wallStartedAtS = animation.startedAtS;
|
|
474
|
+
animation.frameClockAnchored = false;
|
|
467
475
|
// current = start even while latched is load-bearing: cancelAnimation,
|
|
468
476
|
// beginInteraction, prepareAnimation's visibleBefore and registerView's
|
|
469
477
|
// visible all read animation->current, giving a never-rendered latch
|
|
470
478
|
// freeze-at-start / replace-from-start semantics with no extra branches.
|
|
471
479
|
animation.current = animation.start;
|
|
472
|
-
animation.started =
|
|
480
|
+
animation.started = anyDisplayableView(state);
|
|
473
481
|
state.animation = std::move(animation);
|
|
474
482
|
if (!state.animation->started) {
|
|
475
|
-
// Latch: no host view yet (animateTo raced the mount
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
483
|
+
// Latch: no host view can display yet (animateTo raced the mount, or
|
|
484
|
+
// every host is detached/unsized). The first displayable registration,
|
|
485
|
+
// host-geometry push, or window attach rebases the clock, joins
|
|
486
|
+
// animatingDrivers() and schedules the frame loop. Non-zero id is still
|
|
487
|
+
// returned so the JS side does not treat this as rejection.
|
|
479
488
|
return state.animation->id;
|
|
480
489
|
}
|
|
481
490
|
auto &active = animatingDrivers();
|
|
@@ -490,6 +499,38 @@ int32_t startAnimation(
|
|
|
490
499
|
void advance(uint64_t driverId, DriverState &state, double now) {
|
|
491
500
|
ActiveAnimation &animation = *state.animation;
|
|
492
501
|
|
|
502
|
+
if (!animation.frameClockAnchored) {
|
|
503
|
+
// startedAtS/lastFrameS hold nowSeconds() sampled mid-frame at the
|
|
504
|
+
// animateTo call / latch attach, but `now` is the frame's vsync
|
|
505
|
+
// timestamp — the same CLOCK_MONOTONIC timebase at an earlier sampling
|
|
506
|
+
// point. Un-anchored, the first fraction clamps to 0 and re-renders the
|
|
507
|
+
// start (1-2 duplicated frames at every interactive→animateTo handoff).
|
|
508
|
+
// Re-anchor onto the frame-time axis keeping the real elapsed time, so
|
|
509
|
+
// the first rendered frame advances honestly and later frames pace on
|
|
510
|
+
// vsync. On the 32-bit path `now` is itself nowSeconds() → ~identity.
|
|
511
|
+
const double wallElapsedS =
|
|
512
|
+
std::max(0.0, nowSeconds() - animation.startedAtS);
|
|
513
|
+
animation.startedAtS = now - wallElapsedS;
|
|
514
|
+
animation.lastFrameS = animation.startedAtS;
|
|
515
|
+
animation.frameClockAnchored = true;
|
|
516
|
+
// wallStartedAtS deliberately keeps the wall axis through this rebase.
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Run-ahead guard: the anchor's one-time shift bakes the first callback's
|
|
520
|
+
// dispatch latency into the frame axis. If a stalled handoff anchored
|
|
521
|
+
// against a stale frame stamp and later stamps catch up to real time, the
|
|
522
|
+
// frame axis leads wall time by up to the stall length and the animation
|
|
523
|
+
// completes early. Re-anchor forward when the lead exceeds one vsync of
|
|
524
|
+
// slack; on calm frames the condition (first callback's latency exceeding
|
|
525
|
+
// this frame's by > 17 ms) cannot hold, so the hot path is unchanged, and
|
|
526
|
+
// the 32-bit path (frame axis == wall axis) never fires it. lastFrameS is
|
|
527
|
+
// deliberately untouched: springs pace on dt, already clamped to 64 ms.
|
|
528
|
+
const double wallElapsedNowS =
|
|
529
|
+
std::max(0.0, nowSeconds() - animation.wallStartedAtS);
|
|
530
|
+
if (now - animation.startedAtS > wallElapsedNowS + kMaxFrameLeadS) {
|
|
531
|
+
animation.startedAtS = now - wallElapsedNowS;
|
|
532
|
+
}
|
|
533
|
+
|
|
493
534
|
bool done = false;
|
|
494
535
|
if (animation.kind == AnimationKind::Spring) {
|
|
495
536
|
done = advanceSpring(animation, now);
|
|
@@ -603,12 +644,21 @@ void setPresentation(
|
|
|
603
644
|
if (!takeOwnership && state.ownership != Ownership::Interactive) return;
|
|
604
645
|
if (takeOwnership) {
|
|
605
646
|
state.destroyed = false;
|
|
647
|
+
// A held latch is strictly newer intent than this write: the latch was
|
|
648
|
+
// created after whatever value the caller read (the hook's seed replays
|
|
649
|
+
// a SharedValue that an earlier animateTo already advanced to its
|
|
650
|
+
// target). Cancelling it here would seed the target and turn the pending
|
|
651
|
+
// animation into a static jump. Callers that want to override a latch
|
|
652
|
+
// cancel it explicitly first.
|
|
653
|
+
if (state.animation.has_value() && !state.animation->started) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
606
656
|
finishActive(driverId, state, false);
|
|
607
657
|
}
|
|
608
658
|
state.latest = presentation;
|
|
609
659
|
state.hasLatest = true;
|
|
610
660
|
state.ownership = Ownership::Interactive;
|
|
611
|
-
|
|
661
|
+
recordVelocitySample(state.samples, toChannels(presentation), nowSeconds());
|
|
612
662
|
applyToViews(state, presentation);
|
|
613
663
|
}
|
|
614
664
|
|
|
@@ -628,10 +678,7 @@ Presentation beginInteraction(uint64_t driverId) {
|
|
|
628
678
|
state.latest = current;
|
|
629
679
|
state.hasLatest = true;
|
|
630
680
|
state.ownership = Ownership::Interactive;
|
|
631
|
-
state.
|
|
632
|
-
state.hasLatestSample = true;
|
|
633
|
-
state.latestSample = current;
|
|
634
|
-
state.latestSampleTimeS = nowSeconds();
|
|
681
|
+
resetVelocitySamples(state.samples, toChannels(current), nowSeconds());
|
|
635
682
|
applyToViews(state, current);
|
|
636
683
|
emitCompletion(driverId, animationId, false);
|
|
637
684
|
return current;
|
|
@@ -680,7 +727,7 @@ int32_t animateSpring(
|
|
|
680
727
|
// seeded with velocity·displacement to match the iOS CASpringAnimation
|
|
681
728
|
// per-keypath behavior.
|
|
682
729
|
const double velocity = animation.inheritVelocity
|
|
683
|
-
? inheritedVelocity(state, presentation)
|
|
730
|
+
? inheritedVelocity(state.samples, toChannels(presentation), nowSeconds())
|
|
684
731
|
: animation.initialVelocity;
|
|
685
732
|
const Presentation resolvedStart =
|
|
686
733
|
prepareAnimation(driverId, state, start, presentation);
|
|
@@ -795,6 +842,12 @@ void destroyDriver(uint64_t driverId) {
|
|
|
795
842
|
|
|
796
843
|
// --- Android view lifecycle ----------------------------------------------
|
|
797
844
|
|
|
845
|
+
bool JSmoothClipView::isViewAttachedToWindow() const {
|
|
846
|
+
static const auto method =
|
|
847
|
+
javaClassStatic()->getMethod<jboolean()>("isAttachedToWindow");
|
|
848
|
+
return method(self());
|
|
849
|
+
}
|
|
850
|
+
|
|
798
851
|
void JSmoothClipView::applyClip(const Presentation &presentation) const {
|
|
799
852
|
static const auto method =
|
|
800
853
|
javaClassStatic()
|
|
@@ -849,15 +902,16 @@ void registerViewAndroid(
|
|
|
849
902
|
? state.animation->current
|
|
850
903
|
: state.latest;
|
|
851
904
|
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
905
|
for (auto &existing : state.views) {
|
|
856
906
|
if (env->IsSameObject(existing.view.get(), view.get())) {
|
|
857
907
|
existing.density = density;
|
|
858
908
|
existing.hostWidthPx = hostWidthPx;
|
|
859
909
|
existing.hostHeightPx = hostHeightPx;
|
|
860
910
|
deliverToView(existing, visible);
|
|
911
|
+
if (state.animation.has_value() && !state.animation->started &&
|
|
912
|
+
entryDisplayable(existing)) {
|
|
913
|
+
startLatchedAnimation(driverId, state);
|
|
914
|
+
}
|
|
861
915
|
return;
|
|
862
916
|
}
|
|
863
917
|
}
|
|
@@ -867,22 +921,17 @@ void registerViewAndroid(
|
|
|
867
921
|
// registering view — the correct first frame for a latched animation.
|
|
868
922
|
deliverToView(entry, visible);
|
|
869
923
|
state.views.push_back(std::move(entry));
|
|
870
|
-
if (state.animation.has_value() && !state.animation->started
|
|
924
|
+
if (state.animation.has_value() && !state.animation->started &&
|
|
925
|
+
entryDisplayable(state.views.back())) {
|
|
871
926
|
// Start the latched animation: rebase the clock so the first frame
|
|
872
|
-
// integrates from now, not from the pre-mount animateTo call.
|
|
927
|
+
// integrates from now, not from the pre-mount animateTo call. Gated on
|
|
928
|
+
// displayability — a mount-time registration from a detached subtree
|
|
929
|
+
// holds the latch until window attach / first host geometry.
|
|
873
930
|
// Note this function has no isOnMainThread() guard (Kotlin calls it from
|
|
874
931
|
// the view's main-thread attach path); scheduleFrame() is itself
|
|
875
932
|
// main-thread-gated, so an off-main register cannot bind the
|
|
876
933
|
// 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();
|
|
934
|
+
startLatchedAnimation(driverId, state);
|
|
886
935
|
}
|
|
887
936
|
}
|
|
888
937
|
|
|
@@ -908,6 +957,29 @@ void setViewHostGeometryAndroid(
|
|
|
908
957
|
state.animation.has_value() ? state.animation->current
|
|
909
958
|
: state.latest);
|
|
910
959
|
}
|
|
960
|
+
if (state.animation.has_value() && !state.animation->started &&
|
|
961
|
+
entryDisplayable(entry)) {
|
|
962
|
+
// First real host geometry made this view displayable.
|
|
963
|
+
startLatchedAnimation(driverId, state);
|
|
964
|
+
}
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
void viewBecameDisplayableAndroid(
|
|
970
|
+
uint64_t driverId,
|
|
971
|
+
alias_ref<JSmoothClipView> view) {
|
|
972
|
+
if (!isOnMainThread()) return;
|
|
973
|
+
auto iterator = registry().find(driverId);
|
|
974
|
+
if (iterator == registry().end()) return;
|
|
975
|
+
auto &state = iterator->second;
|
|
976
|
+
if (!state.animation.has_value() || state.animation->started) return;
|
|
977
|
+
JNIEnv *env = facebook::jni::Environment::current();
|
|
978
|
+
for (auto &entry : state.views) {
|
|
979
|
+
if (!env->IsSameObject(entry.view.get(), view.get())) continue;
|
|
980
|
+
if (entryDisplayable(entry)) {
|
|
981
|
+
startLatchedAnimation(driverId, state);
|
|
982
|
+
}
|
|
911
983
|
return;
|
|
912
984
|
}
|
|
913
985
|
}
|
|
@@ -930,11 +1002,63 @@ void unregisterViewAndroid(
|
|
|
930
1002
|
}
|
|
931
1003
|
if (removed && state.animation.has_value()) {
|
|
932
1004
|
state.animation->finished = false;
|
|
933
|
-
if (state.views.empty()) {
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
1005
|
+
if (state.views.empty() && !state.animation->started) {
|
|
1006
|
+
// A held latch returns to its original zero-view state; the next
|
|
1007
|
+
// displayable registration still starts it (or destroyDriver cancels
|
|
1008
|
+
// it). Completing it here would betray the pending intent.
|
|
1009
|
+
} else if (state.views.empty()) {
|
|
1010
|
+
auto &animation = *state.animation;
|
|
1011
|
+
// wallStartedAtS, not startedAtS: post-anchor the latter lives on the
|
|
1012
|
+
// shifted frame axis and would overstate elapsed by the first
|
|
1013
|
+
// callback's dispatch latency, shortening the resumed remainder and
|
|
1014
|
+
// advancing the keyframe rebase past the rendered position.
|
|
1015
|
+
const double elapsedS = nowSeconds() - animation.wallStartedAtS;
|
|
1016
|
+
const double remainingS = std::max(0.0, animation.durationS - elapsedS);
|
|
1017
|
+
const bool canResume = !state.destroyed &&
|
|
1018
|
+
(animation.kind == AnimationKind::Spring || remainingS > 0);
|
|
1019
|
+
if (!canResume) {
|
|
1020
|
+
// The animation ends here; release ownership so a later remount's
|
|
1021
|
+
// interactive updates are not dropped.
|
|
1022
|
+
state.ownership = Ownership::Interactive;
|
|
1023
|
+
finishActive(driverId, state, false);
|
|
1024
|
+
} else {
|
|
1025
|
+
// The last rendering host left mid-flight (remount, screen swap).
|
|
1026
|
+
// Completing here would leave `latest` at the target and statically
|
|
1027
|
+
// snap any re-registering host straight to it. Re-latch instead:
|
|
1028
|
+
// freeze the remaining animation at its current geometry so the
|
|
1029
|
+
// next displayable host resumes it with the true remaining time.
|
|
1030
|
+
// destroyDriver cancels a latch that never finds a host, so the
|
|
1031
|
+
// completion cannot hang.
|
|
1032
|
+
if (animation.kind == AnimationKind::Timing) {
|
|
1033
|
+
animation.durationS = remainingS;
|
|
1034
|
+
} else if (animation.kind == AnimationKind::Keyframes) {
|
|
1035
|
+
const double progress = animation.durationS <= 0
|
|
1036
|
+
? 1.0
|
|
1037
|
+
: clamp01(elapsedS / animation.durationS);
|
|
1038
|
+
std::vector<Keyframe> remaining;
|
|
1039
|
+
remaining.push_back({0, animation.current});
|
|
1040
|
+
for (const Keyframe &frame : animation.keyframes) {
|
|
1041
|
+
if (frame.offset <= progress) continue;
|
|
1042
|
+
remaining.push_back(
|
|
1043
|
+
{(frame.offset - progress) / (1 - progress),
|
|
1044
|
+
frame.presentation});
|
|
1045
|
+
}
|
|
1046
|
+
if (remaining.size() == 1) {
|
|
1047
|
+
remaining.push_back({1, animation.target});
|
|
1048
|
+
}
|
|
1049
|
+
animation.keyframes = std::move(remaining);
|
|
1050
|
+
animation.durationS = remainingS;
|
|
1051
|
+
}
|
|
1052
|
+
animation.start = animation.current;
|
|
1053
|
+
animation.started = false;
|
|
1054
|
+
// A held latch must not tick: leave animatingDrivers() without
|
|
1055
|
+
// resetting the animation (the eager-removal rule the choreographer
|
|
1056
|
+
// loop depends on).
|
|
1057
|
+
auto &active = animatingDrivers();
|
|
1058
|
+
active.erase(
|
|
1059
|
+
std::remove(active.begin(), active.end(), driverId),
|
|
1060
|
+
active.end());
|
|
1061
|
+
}
|
|
938
1062
|
}
|
|
939
1063
|
}
|
|
940
1064
|
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) {
|