react-native-smooth-clip-view 0.2.2 → 0.2.5
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 +52 -14
- package/android/CMakeLists.txt +4 -0
- package/android/build.gradle +1 -0
- package/android/src/main/cpp/SmoothClipRegistry.cpp +72 -53
- package/cpp/SmoothClipVelocityTracker.h +101 -0
- package/ios/SmoothClipView.mm +32 -74
- package/lib/module/drivers.android.js +3 -1
- package/lib/module/drivers.android.js.map +1 -1
- package/lib/module/drivers.ios.js +3 -377
- 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/drivers.native.js +401 -0
- package/lib/module/drivers.native.js.map +1 -0
- 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 +34 -3
- package/lib/typescript/src/driverTypes.d.ts.map +1 -1
- package/lib/typescript/src/drivers.android.d.ts +2 -2
- package/lib/typescript/src/drivers.android.d.ts.map +1 -1
- package/lib/typescript/src/drivers.d.ts.map +1 -1
- package/lib/typescript/src/drivers.ios.d.ts +2 -4
- package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
- package/lib/typescript/src/drivers.native.d.ts +5 -0
- package/lib/typescript/src/drivers.native.d.ts.map +1 -0
- 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 +53 -19
- package/src/drivers.android.ts +4 -2
- package/src/drivers.ios.ts +4 -661
- package/src/drivers.native.ts +698 -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
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/react-native-smooth-clip-view)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## High-performance geometry animations for React Native
|
|
6
6
|
|
|
7
|
-
`
|
|
8
|
-
updates only the native clipping layer. It is
|
|
9
|
-
useful for expanding cards, sheets, maps, media, zoom transitions, and other reveals where
|
|
10
|
-
animating layout width and height cause lag because of yoga/fabric calculating layout every frame.
|
|
7
|
+
`react-native-smooth-clip-view` lets you animate `x`, `y`, `width`, `height`, and `borderRadius` with Reanimated without triggering expensive layout work on every frame.
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
Instead of resizing the Yoga layout, `SmoothClipView` keeps a fixed footprint and updates only the native clipping layer. This makes geometry-heavy animations smooth and inexpensive—even for shared-element transitions, zoom transitions, expanding cards, reveals, sheets, maps, and media.
|
|
10
|
+
|
|
11
|
+
Use it for transitions that previously struggled with performance when animating layout dimensions directly.
|
|
13
12
|
|
|
14
13
|
## Requirements
|
|
15
14
|
|
|
@@ -141,10 +140,16 @@ const gesture = Gesture.Pan()
|
|
|
141
140
|
// bookkeeping. Assigning driver.presentation.value also works.
|
|
142
141
|
driver.ui.setScalars(clip.x, clip.y, clip.width, clip.height, clip.radius, 0, 0);
|
|
143
142
|
})
|
|
144
|
-
.onEnd(() => {
|
|
143
|
+
.onEnd((event) => {
|
|
144
|
+
// The release event is fresher than the last onUpdate (on Android,
|
|
145
|
+
// ACTION_UP carries a position no MOVE ever delivered). Pass the final
|
|
146
|
+
// geometry as `from` so the animation starts from exactly that value —
|
|
147
|
+
// it fuses a setScalars hot write with the handoff in one call.
|
|
148
|
+
const clip = geometryForDrag(dragStart.value, event.translationY);
|
|
145
149
|
driver.ui.animateTo(createClipPresentation(expandedClip), {
|
|
146
150
|
type: 'spring',
|
|
147
151
|
initialVelocity: 'inherit',
|
|
152
|
+
from: createClipPresentation(clip),
|
|
148
153
|
});
|
|
149
154
|
});
|
|
150
155
|
```
|
|
@@ -175,32 +180,65 @@ const gesture = Gesture.Pan()
|
|
|
175
180
|
streams such as gestures. `driver.presentation.value` is stale after hot
|
|
176
181
|
writes by design; `beginInteraction()` remains the source of truth for
|
|
177
182
|
visible geometry, and `animateTo()` after hot writes starts from the native
|
|
178
|
-
registry's latest value rather than the stale SharedValue.
|
|
179
|
-
|
|
183
|
+
registry's latest value rather than the stale SharedValue. To start from a
|
|
184
|
+
value fresher than the last hot write (a gesture's release sample), pass it
|
|
185
|
+
as `animation.from` — `animateTo` then performs the hot write and the
|
|
186
|
+
handoff in one call. Do not interleave `setScalars` with
|
|
187
|
+
`presentation.value` writes on the same driver.
|
|
180
188
|
- Spring `initialVelocity` is one normalized scalar along the current-to-target
|
|
181
189
|
trajectory, in units of the remaining distance per second (`1` covers the
|
|
182
190
|
remaining distance in one second). Every geometry channel continues with the
|
|
183
191
|
same normalized rate, so grab/release preserves the felt direction and
|
|
184
192
|
speed. `'inherit'` (the default) estimates the scalar from the last two
|
|
185
193
|
interactive samples on iOS and Android; samples older than 100 ms — and the
|
|
186
|
-
web fallback — fall back to zero.
|
|
194
|
+
web fallback — fall back to zero. Two writes landing inside the same frame
|
|
195
|
+
(< 4 ms apart, e.g. a release-sample `from` seed right after the last drag
|
|
196
|
+
write) coalesce into one sample, and an identical re-write is ignored, so a
|
|
197
|
+
fused handoff can neither zero nor inflate the inherited velocity.
|
|
198
|
+
`beginInteraction()` records the frozen presentation as a plain sample on
|
|
199
|
+
both platforms, so a grab-and-instant-refling inherits bounded recent
|
|
200
|
+
motion (the staleness guard still zeroes a stale pair) instead of
|
|
201
|
+
launching dead.
|
|
187
202
|
- `driver.react` exposes `beginInteraction`, `set`, `animateTo`, and `cancel`
|
|
188
203
|
as Promises (`setScalars` is UI-worklet-only). React code never blocks
|
|
189
204
|
waiting for main/UI-thread work. An immediate animation request resolves
|
|
190
205
|
before its completion callback is delivered.
|
|
191
206
|
- `animateTo()` transfers ownership to native animation. Timing uses
|
|
192
|
-
cubic Bézier control points
|
|
207
|
+
cubic Bézier control points — `ClipEasings` exports exact-form presets
|
|
208
|
+
(`easeOutCubic` = `Easing.out(Easing.cubic)` etc.) so a parallel Reanimated
|
|
209
|
+
animation can run the identical curve without hand-deriving it; springs
|
|
210
|
+
accept mass, stiffness, damping, and an
|
|
193
211
|
explicit normalized velocity or `'inherit'` (the default). Keyframes accept
|
|
194
|
-
validated, monotonically increasing offsets from zero through one
|
|
212
|
+
validated, monotonically increasing offsets from zero through one; there is
|
|
213
|
+
deliberately no keyframe easing field — playback is linear between offsets
|
|
214
|
+
and the frames encode the curve, which also expresses per-channel-nonlinear
|
|
215
|
+
paths that no single time-warp could reproduce. Every
|
|
216
|
+
kind accepts an optional `from` presentation — a fused take-ownership hot
|
|
217
|
+
write issued immediately before the handoff, so the animation starts from
|
|
218
|
+
exactly that value (pass `frames[0].presentation` for keyframes, which
|
|
219
|
+
interpolate absolutely). A non-finite `from` rejects the whole call; against
|
|
220
|
+
a held pending-animation latch the seed is dropped — the latch is newer
|
|
221
|
+
intent, and nothing is displayable yet anyway. `from` behaves the same on
|
|
222
|
+
both platforms (it is driver-layer, not native): on iOS the seed stops any
|
|
223
|
+
running Core Animation and writes the model layer first; keyframes then
|
|
224
|
+
start exactly at `from`, while timing/spring sample their from-value off
|
|
225
|
+
the presentation layer — the last committed frame, at most one frame
|
|
226
|
+
behind `from` — identical to the explicit two-call pattern.
|
|
195
227
|
- `cancel()` freezes visible presentation by default. Pass `'target'` as its
|
|
196
228
|
behavior to jump to the requested endpoint.
|
|
197
229
|
- `options.onAnimationComplete` fires exactly once per animation with its ID
|
|
198
230
|
and `finished` state, including cancellation, replacement, and native-side
|
|
199
|
-
rejection (`animateTo` then returns
|
|
200
|
-
|
|
231
|
+
rejection (`animateTo` then returns a fresh non-zero id whose single
|
|
232
|
+
`finished: false` completion follows — key completion handling by the
|
|
233
|
+
returned id, never by `0`). `animateTo` returns `0` — and delivers no
|
|
234
|
+
completion — only when the driver entry no longer exists (destroyed) or the
|
|
235
|
+
call ran off the main thread. Unmounting the last host mid-flight does not complete the
|
|
201
236
|
animation: the remainder is re-latched and the next displayable host resumes
|
|
202
237
|
it, so the completion arrives when it finishes there — or unfinished on
|
|
203
238
|
replacement, cancellation, `beginInteraction()`, or driver destruction.
|
|
239
|
+
With multiple hosts on one driver, unregistering any participant mid-flight
|
|
240
|
+
makes the eventual completion `finished: false` — `finished: true` means
|
|
241
|
+
every participant ran the animation to its end.
|
|
204
242
|
- An `animateTo` issued before any host view can produce a visible frame (for
|
|
205
243
|
example from an effect in the same commit that mounts the host, or inside a
|
|
206
244
|
modal route whose subtree attaches to its window late) is held pending and
|
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
|
}
|
|
@@ -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,13 @@ 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
|
+
// Fixed slack for the post-stall run-ahead guard in advance(). The lead it
|
|
232
|
+
// bounds is a latency DIFFERENTIAL — the anchor frame's dispatch latency
|
|
233
|
+
// minus the current frame's — so the constant is refresh-rate independent
|
|
234
|
+
// (it is not one vsync period; at 120 Hz it spans two). 17 ms keeps the
|
|
235
|
+
// guard silent through normal callback jitter while capping post-stall
|
|
236
|
+
// run-ahead at one 60 Hz frame of curve time.
|
|
237
|
+
constexpr double kMaxFrameLeadS = 0.017;
|
|
224
238
|
|
|
225
239
|
// Semi-implicit Euler per channel, substepped: one frame-sized step is only
|
|
226
240
|
// stable for omega*dt < 2, and accepted stiffness/mass ratios exceed that at
|
|
@@ -367,46 +381,6 @@ void applyToViews(DriverState &state, const Presentation &presentation) {
|
|
|
367
381
|
}
|
|
368
382
|
}
|
|
369
383
|
|
|
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
384
|
void applyPresentation(DriverState &state, const Presentation &presentation) {
|
|
411
385
|
state.latest = presentation;
|
|
412
386
|
state.hasLatest = true;
|
|
@@ -480,6 +454,11 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
|
|
|
480
454
|
animation.started = true;
|
|
481
455
|
animation.startedAtS = nowSeconds();
|
|
482
456
|
animation.lastFrameS = animation.startedAtS;
|
|
457
|
+
animation.wallStartedAtS = animation.startedAtS;
|
|
458
|
+
// Load-bearing for re-latch resumes: unregisterViewAndroid rewrites this
|
|
459
|
+
// ActiveAnimation in place, so a stale anchor would replay the fraction-0
|
|
460
|
+
// first frame the anchor exists to remove.
|
|
461
|
+
animation.frameClockAnchored = false;
|
|
483
462
|
auto &active = animatingDrivers();
|
|
484
463
|
if (std::find(active.begin(), active.end(), driverId) == active.end()) {
|
|
485
464
|
active.push_back(driverId);
|
|
@@ -494,6 +473,8 @@ int32_t startAnimation(
|
|
|
494
473
|
ActiveAnimation animation) {
|
|
495
474
|
animation.startedAtS = nowSeconds();
|
|
496
475
|
animation.lastFrameS = animation.startedAtS;
|
|
476
|
+
animation.wallStartedAtS = animation.startedAtS;
|
|
477
|
+
animation.frameClockAnchored = false;
|
|
497
478
|
// current = start even while latched is load-bearing: cancelAnimation,
|
|
498
479
|
// beginInteraction, prepareAnimation's visibleBefore and registerView's
|
|
499
480
|
// visible all read animation->current, giving a never-rendered latch
|
|
@@ -521,6 +502,38 @@ int32_t startAnimation(
|
|
|
521
502
|
void advance(uint64_t driverId, DriverState &state, double now) {
|
|
522
503
|
ActiveAnimation &animation = *state.animation;
|
|
523
504
|
|
|
505
|
+
if (!animation.frameClockAnchored) {
|
|
506
|
+
// startedAtS/lastFrameS hold nowSeconds() sampled mid-frame at the
|
|
507
|
+
// animateTo call / latch attach, but `now` is the frame's vsync
|
|
508
|
+
// timestamp — the same CLOCK_MONOTONIC timebase at an earlier sampling
|
|
509
|
+
// point. Un-anchored, the first fraction clamps to 0 and re-renders the
|
|
510
|
+
// start (1-2 duplicated frames at every interactive→animateTo handoff).
|
|
511
|
+
// Re-anchor onto the frame-time axis keeping the real elapsed time, so
|
|
512
|
+
// the first rendered frame advances honestly and later frames pace on
|
|
513
|
+
// vsync. On the 32-bit path `now` is itself nowSeconds() → ~identity.
|
|
514
|
+
const double wallElapsedS =
|
|
515
|
+
std::max(0.0, nowSeconds() - animation.startedAtS);
|
|
516
|
+
animation.startedAtS = now - wallElapsedS;
|
|
517
|
+
animation.lastFrameS = animation.startedAtS;
|
|
518
|
+
animation.frameClockAnchored = true;
|
|
519
|
+
// wallStartedAtS deliberately keeps the wall axis through this rebase.
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Run-ahead guard: the anchor's one-time shift bakes the first callback's
|
|
523
|
+
// dispatch latency into the frame axis. If a stalled handoff anchored
|
|
524
|
+
// against a stale frame stamp and later stamps catch up to real time, the
|
|
525
|
+
// frame axis leads wall time by up to the stall length and the animation
|
|
526
|
+
// completes early. Re-anchor forward when the lead exceeds one vsync of
|
|
527
|
+
// slack; on calm frames the condition (first callback's latency exceeding
|
|
528
|
+
// this frame's by > 17 ms) cannot hold, so the hot path is unchanged, and
|
|
529
|
+
// the 32-bit path (frame axis == wall axis) never fires it. lastFrameS is
|
|
530
|
+
// deliberately untouched: springs pace on dt, already clamped to 64 ms.
|
|
531
|
+
const double wallElapsedNowS =
|
|
532
|
+
std::max(0.0, nowSeconds() - animation.wallStartedAtS);
|
|
533
|
+
if (now - animation.startedAtS > wallElapsedNowS + kMaxFrameLeadS) {
|
|
534
|
+
animation.startedAtS = now - wallElapsedNowS;
|
|
535
|
+
}
|
|
536
|
+
|
|
524
537
|
bool done = false;
|
|
525
538
|
if (animation.kind == AnimationKind::Spring) {
|
|
526
539
|
done = advanceSpring(animation, now);
|
|
@@ -648,7 +661,7 @@ void setPresentation(
|
|
|
648
661
|
state.latest = presentation;
|
|
649
662
|
state.hasLatest = true;
|
|
650
663
|
state.ownership = Ownership::Interactive;
|
|
651
|
-
|
|
664
|
+
recordVelocitySample(state.samples, toChannels(presentation), nowSeconds());
|
|
652
665
|
applyToViews(state, presentation);
|
|
653
666
|
}
|
|
654
667
|
|
|
@@ -668,10 +681,12 @@ Presentation beginInteraction(uint64_t driverId) {
|
|
|
668
681
|
state.latest = current;
|
|
669
682
|
state.hasLatest = true;
|
|
670
683
|
state.ownership = Ownership::Interactive;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
684
|
+
// Plain record, not a reset — iOS parity (smoothClipFreezePresentation
|
|
685
|
+
// records the frozen value). Pairing the frozen mid-flight presentation
|
|
686
|
+
// with the last real sample lets a grab-and-instant-refling inherit
|
|
687
|
+
// bounded recent motion instead of launching dead; an unchanged value
|
|
688
|
+
// dedupes to a no-op and ages out through the staleness guard.
|
|
689
|
+
recordVelocitySample(state.samples, toChannels(current), nowSeconds());
|
|
675
690
|
applyToViews(state, current);
|
|
676
691
|
emitCompletion(driverId, animationId, false);
|
|
677
692
|
return current;
|
|
@@ -720,7 +735,7 @@ int32_t animateSpring(
|
|
|
720
735
|
// seeded with velocity·displacement to match the iOS CASpringAnimation
|
|
721
736
|
// per-keypath behavior.
|
|
722
737
|
const double velocity = animation.inheritVelocity
|
|
723
|
-
? inheritedVelocity(state, presentation)
|
|
738
|
+
? inheritedVelocity(state.samples, toChannels(presentation), nowSeconds())
|
|
724
739
|
: animation.initialVelocity;
|
|
725
740
|
const Presentation resolvedStart =
|
|
726
741
|
prepareAnimation(driverId, state, start, presentation);
|
|
@@ -1001,7 +1016,11 @@ void unregisterViewAndroid(
|
|
|
1001
1016
|
// it). Completing it here would betray the pending intent.
|
|
1002
1017
|
} else if (state.views.empty()) {
|
|
1003
1018
|
auto &animation = *state.animation;
|
|
1004
|
-
|
|
1019
|
+
// wallStartedAtS, not startedAtS: post-anchor the latter lives on the
|
|
1020
|
+
// shifted frame axis and would overstate elapsed by the first
|
|
1021
|
+
// callback's dispatch latency, shortening the resumed remainder and
|
|
1022
|
+
// advancing the keyframe rebase past the rendered position.
|
|
1023
|
+
const double elapsedS = nowSeconds() - animation.wallStartedAtS;
|
|
1005
1024
|
const double remainingS = std::max(0.0, animation.durationS - elapsedS);
|
|
1006
1025
|
const bool canResume = !state.destroyed &&
|
|
1007
1026
|
(animation.kind == AnimationKind::Spring || remainingS > 0);
|
|
@@ -0,0 +1,101 @@
|
|
|
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 on both platforms records the frozen presentation as a
|
|
64
|
+
// plain sample: a grab mid-animation pairs the frozen value with the last
|
|
65
|
+
// real sample, so an instant refling inherits bounded recent motion instead
|
|
66
|
+
// of launching dead, and a grab at an unchanged value dedupes to a no-op
|
|
67
|
+
// whose staleness keeps aging. There is deliberately no reset primitive —
|
|
68
|
+
// the 100 ms staleness guard is the forgetting mechanism.
|
|
69
|
+
|
|
70
|
+
inline void clearVelocitySamples(VelocitySampleHistory &history) {
|
|
71
|
+
history = VelocitySampleHistory{};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Normalized velocity of the recorded motion projected onto the
|
|
75
|
+
// latest→target trajectory, in units of remaining-distance per second
|
|
76
|
+
// (1 covers the remaining distance in one second) — the CASpringAnimation
|
|
77
|
+
// initialVelocity convention. iOS passes the scalar unchanged per keypath;
|
|
78
|
+
// Android seeds each integrator channel with scalar·displacement.
|
|
79
|
+
inline double inheritedVelocity(
|
|
80
|
+
const VelocitySampleHistory &history,
|
|
81
|
+
const std::array<double, 7> &target,
|
|
82
|
+
double nowS) {
|
|
83
|
+
if (!history.hasPrevious || !history.hasLatest) return 0;
|
|
84
|
+
const double elapsed = history.latestTimeS - history.previousTimeS;
|
|
85
|
+
if (elapsed <= 0 || nowS - history.latestTimeS > kVelocityStalenessS) {
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
double numerator = 0;
|
|
89
|
+
double denominator = 0;
|
|
90
|
+
for (int index = 0; index < 7; index += 1) {
|
|
91
|
+
const double sampleDelta = history.latest[index] - history.previous[index];
|
|
92
|
+
const double destinationDelta = target[index] - history.latest[index];
|
|
93
|
+
numerator += sampleDelta * destinationDelta;
|
|
94
|
+
denominator += destinationDelta * destinationDelta;
|
|
95
|
+
}
|
|
96
|
+
if (denominator <= 1e-12) return 0;
|
|
97
|
+
const double result = numerator / elapsed / denominator;
|
|
98
|
+
return std::isfinite(result) ? result : 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
} // namespace smoothclip
|
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)
|
|
@@ -305,71 +311,24 @@ using namespace facebook::react;
|
|
|
305
311
|
- (void)recordInteractiveRect:(CGRect)rect
|
|
306
312
|
radius:(CGFloat)radius
|
|
307
313
|
contentTranslation:(CGPoint)contentTranslation {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
_hasPreviousInteractiveSample = YES;
|
|
317
|
-
_previousInteractiveRect = _interactiveRect;
|
|
318
|
-
_previousInteractiveRadius = _interactiveRadius;
|
|
319
|
-
_previousInteractiveContentTranslation =
|
|
320
|
-
_interactiveContentTranslation;
|
|
321
|
-
_previousInteractiveTime = _interactiveTime;
|
|
322
|
-
}
|
|
323
|
-
_hasInteractiveSample = YES;
|
|
324
|
-
_interactiveRect = rect;
|
|
325
|
-
_interactiveRadius = radius;
|
|
326
|
-
_interactiveContentTranslation = contentTranslation;
|
|
327
|
-
_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());
|
|
328
322
|
}
|
|
329
323
|
|
|
330
324
|
- (CGFloat)inheritedVelocityToRect:(CGRect)target
|
|
331
325
|
radius:(CGFloat)targetRadius
|
|
332
326
|
contentTranslation:(CGPoint)targetContentTranslation {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const CGFloat current[] = {
|
|
339
|
-
CGRectGetMinX(_interactiveRect),
|
|
340
|
-
CGRectGetMinY(_interactiveRect),
|
|
341
|
-
CGRectGetWidth(_interactiveRect),
|
|
342
|
-
CGRectGetHeight(_interactiveRect),
|
|
343
|
-
_interactiveRadius,
|
|
344
|
-
_interactiveContentTranslation.x,
|
|
345
|
-
_interactiveContentTranslation.y};
|
|
346
|
-
const CGFloat previous[] = {
|
|
347
|
-
CGRectGetMinX(_previousInteractiveRect),
|
|
348
|
-
CGRectGetMinY(_previousInteractiveRect),
|
|
349
|
-
CGRectGetWidth(_previousInteractiveRect),
|
|
350
|
-
CGRectGetHeight(_previousInteractiveRect),
|
|
351
|
-
_previousInteractiveRadius,
|
|
352
|
-
_previousInteractiveContentTranslation.x,
|
|
353
|
-
_previousInteractiveContentTranslation.y};
|
|
354
|
-
const CGFloat destination[] = {
|
|
355
|
-
CGRectGetMinX(target),
|
|
356
|
-
CGRectGetMinY(target),
|
|
357
|
-
CGRectGetWidth(target),
|
|
358
|
-
CGRectGetHeight(target),
|
|
359
|
-
targetRadius,
|
|
360
|
-
targetContentTranslation.x,
|
|
361
|
-
targetContentTranslation.y};
|
|
362
|
-
double numerator = 0;
|
|
363
|
-
double denominator = 0;
|
|
364
|
-
for (NSUInteger index = 0; index < 7; index += 1) {
|
|
365
|
-
const double velocity = (current[index] - previous[index]) / elapsed;
|
|
366
|
-
const double displacement = destination[index] - current[index];
|
|
367
|
-
numerator += velocity * displacement;
|
|
368
|
-
denominator += displacement * displacement;
|
|
369
|
-
}
|
|
370
|
-
if (denominator <= DBL_EPSILON) return 0;
|
|
371
|
-
const double result = numerator / denominator;
|
|
372
|
-
return isfinite(result) ? result : 0;
|
|
327
|
+
return smoothclip::inheritedVelocity(
|
|
328
|
+
_velocitySamples,
|
|
329
|
+
SmoothClipVelocityChannels(
|
|
330
|
+
target, targetRadius, targetContentTranslation),
|
|
331
|
+
CACurrentMediaTime());
|
|
373
332
|
}
|
|
374
333
|
|
|
375
334
|
- (void)smoothClipApplyPresentation:(smoothclip::Presentation)presentation {
|
|
@@ -1113,8 +1072,7 @@ using namespace facebook::react;
|
|
|
1113
1072
|
[self stopLayerAnimationWithoutCallback];
|
|
1114
1073
|
_pendingAnimationInstall = NO;
|
|
1115
1074
|
_animationPaused = NO;
|
|
1116
|
-
|
|
1117
|
-
_hasInteractiveSample = NO;
|
|
1075
|
+
smoothclip::clearVelocitySamples(_velocitySamples);
|
|
1118
1076
|
[super prepareForRecycle];
|
|
1119
1077
|
_requestedClip = CGRectZero;
|
|
1120
1078
|
_normalizedClip = CGRectZero;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
// Platform re-export only — the implementation is shared with iOS.
|
|
4
|
+
// Edit drivers.native.ts, not this file.
|
|
5
|
+
export { useSmoothClipDriver } from "./drivers.native.js";
|
|
4
6
|
//# sourceMappingURL=drivers.android.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["useSmoothClipDriver"],"sourceRoot":"../../src","sources":["drivers.android.ts"],"mappings":";;AAAA,SAASA,mBAAmB,QAAQ,
|
|
1
|
+
{"version":3,"names":["useSmoothClipDriver"],"sourceRoot":"../../src","sources":["drivers.android.ts"],"mappings":";;AAAA;AACA;AACA,SAASA,mBAAmB,QAAQ,qBAAkB","ignoreList":[]}
|