@rafaelaugusto/jolt 0.1.0-beta.14 → 0.1.0-beta.16

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.
Files changed (91) hide show
  1. package/README.md +11 -4
  2. package/dist/drag.js +1 -1
  3. package/dist/drag.js.map +1 -1
  4. package/dist/driver/android.d.ts.map +1 -1
  5. package/dist/driver/android.js +2 -0
  6. package/dist/driver/android.js.map +1 -1
  7. package/dist/driver/ios-scroll-delegate.d.ts +4 -0
  8. package/dist/driver/ios-scroll-delegate.d.ts.map +1 -0
  9. package/dist/driver/ios-scroll-delegate.js +26 -0
  10. package/dist/driver/ios-scroll-delegate.js.map +1 -0
  11. package/dist/driver/ios.d.ts.map +1 -1
  12. package/dist/driver/ios.js +2 -0
  13. package/dist/driver/ios.js.map +1 -1
  14. package/dist/gesture-native.d.ts +27 -0
  15. package/dist/gesture-native.d.ts.map +1 -0
  16. package/dist/gesture-native.js +55 -0
  17. package/dist/gesture-native.js.map +1 -0
  18. package/dist/gestures/index.d.ts +65 -0
  19. package/dist/gestures/index.d.ts.map +1 -0
  20. package/dist/gestures/index.js +251 -0
  21. package/dist/gestures/index.js.map +1 -0
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/live-spring-driver.d.ts +52 -0
  27. package/dist/live-spring-driver.d.ts.map +1 -0
  28. package/dist/live-spring-driver.js +104 -0
  29. package/dist/live-spring-driver.js.map +1 -0
  30. package/dist/mixer.d.ts +6 -0
  31. package/dist/mixer.d.ts.map +1 -1
  32. package/dist/mixer.js +41 -1
  33. package/dist/mixer.js.map +1 -1
  34. package/dist/motion-scope.d.ts +2 -0
  35. package/dist/motion-scope.d.ts.map +1 -1
  36. package/dist/motion-scope.js +6 -0
  37. package/dist/motion-scope.js.map +1 -1
  38. package/dist/motion-value.d.ts +34 -2
  39. package/dist/motion-value.d.ts.map +1 -1
  40. package/dist/motion-value.js +277 -10
  41. package/dist/motion-value.js.map +1 -1
  42. package/dist/native/index.d.ts +1 -0
  43. package/dist/native/index.d.ts.map +1 -1
  44. package/dist/native/index.js +1 -0
  45. package/dist/native/index.js.map +1 -1
  46. package/dist/scene-camera.d.ts +5 -0
  47. package/dist/scene-camera.d.ts.map +1 -0
  48. package/dist/scene-camera.js +23 -0
  49. package/dist/scene-camera.js.map +1 -0
  50. package/dist/size-layout.d.ts +19 -0
  51. package/dist/size-layout.d.ts.map +1 -0
  52. package/dist/size-layout.js +216 -0
  53. package/dist/size-layout.js.map +1 -0
  54. package/dist/size.d.ts +19 -0
  55. package/dist/size.d.ts.map +1 -0
  56. package/dist/size.js +251 -0
  57. package/dist/size.js.map +1 -0
  58. package/dist/value-observers.d.ts +3 -1
  59. package/dist/value-observers.d.ts.map +1 -1
  60. package/dist/value-observers.js +10 -2
  61. package/dist/value-observers.js.map +1 -1
  62. package/package.json +5 -1
  63. package/platforms/android/include.gradle +9 -0
  64. package/platforms/android/java/org/rafaelaugusto/jolt/GestureSession.java +193 -0
  65. package/platforms/android/java/org/rafaelaugusto/jolt/GestureSurface.java +53 -0
  66. package/platforms/android/java/org/rafaelaugusto/jolt/LiveSpring.java +270 -0
  67. package/platforms/android/java/org/rafaelaugusto/jolt/SpringState.java +39 -0
  68. package/platforms/ios/src/JoltGestureSession.h +17 -0
  69. package/platforms/ios/src/JoltGestureSession.m +217 -0
  70. package/platforms/ios/src/JoltLiveSpring.h +28 -0
  71. package/platforms/ios/src/JoltLiveSpring.m +236 -0
  72. package/platforms/ios/src/JoltSpringState.h +35 -0
  73. package/platforms/ios/src/module.modulemap +2 -0
  74. package/src/android-plugin.d.ts +59 -0
  75. package/src/drag.ts +1 -1
  76. package/src/driver/android.ts +2 -0
  77. package/src/driver/ios-scroll-delegate.ts +23 -0
  78. package/src/driver/ios.ts +2 -0
  79. package/src/gesture-native.ts +124 -0
  80. package/src/gestures/index.ts +350 -0
  81. package/src/index.ts +5 -1
  82. package/src/ios-plugin.d.ts +61 -0
  83. package/src/live-spring-driver.ts +181 -0
  84. package/src/mixer.ts +43 -1
  85. package/src/motion-scope.ts +14 -0
  86. package/src/motion-value.ts +318 -11
  87. package/src/native/index.ts +1 -0
  88. package/src/scene-camera.ts +30 -0
  89. package/src/size-layout.ts +237 -0
  90. package/src/size.ts +302 -0
  91. package/src/value-observers.ts +12 -2
@@ -0,0 +1,270 @@
1
+ package org.rafaelaugusto.jolt;
2
+
3
+ import android.os.SystemClock;
4
+ import android.view.Choreographer;
5
+ import android.view.View;
6
+ import java.lang.ref.WeakReference;
7
+ import java.util.ArrayList;
8
+ import org.json.JSONArray;
9
+ import org.json.JSONException;
10
+ import org.json.JSONObject;
11
+
12
+ /** Persistent native follower graph. The frame callback never enters JavaScript. */
13
+ public final class LiveSpring {
14
+ private static final ArrayList<LiveSpring> nodes = new ArrayList<>();
15
+ private static boolean scheduled;
16
+ private static long previousFrame;
17
+ private static final Choreographer.FrameCallback clock = new Choreographer.FrameCallback() {
18
+ @Override public void doFrame(long nanos) {
19
+ scheduled = false;
20
+ double now = nanos / 1e6;
21
+ double remaining = previousFrame == 0 ? 1.0 / 120 : Math.min(.064, Math.max(0, (nanos - previousFrame) / 1e9));
22
+ previousFrame = nanos;
23
+ // Older nodes precede their followers. Substeps bound chain phase error at 60/120Hz.
24
+ while (remaining > 1e-9) {
25
+ double dt = Math.min(remaining, 1.0 / 240);
26
+ remaining -= dt;
27
+ for (int i = 0; i < nodes.size(); i++) nodes.get(i).step(now - remaining * 1000, dt);
28
+ }
29
+ boolean active = false;
30
+ for (int i = 0; i < nodes.size(); i++) {
31
+ LiveSpring node = nodes.get(i);
32
+ node.apply();
33
+ active |= node.moving || node.layersMoving(now);
34
+ }
35
+ if (active) schedule(); else previousFrame = 0;
36
+ }
37
+ };
38
+ private static void schedule() {
39
+ if (scheduled || nodes.isEmpty()) return;
40
+ scheduled = true;
41
+ Choreographer.getInstance().postFrameCallback(clock);
42
+ }
43
+
44
+ private final SpringState state;
45
+ private final double stiffness, damping, mass, restDelta, restSpeed;
46
+ private final ArrayList<Binding> bindings = new ArrayList<>();
47
+ private LiveSpring source;
48
+ private Mapping[] sourceMaps = new Mapping[0];
49
+ private SampledInput input;
50
+ private boolean direct = true;
51
+ private double targetValue;
52
+ private boolean moving, reduced, disposed, paused, inputHeld;
53
+
54
+ public LiveSpring(double initial, double stiffness, double damping, double mass,
55
+ double restDelta, double restSpeed, boolean reduced) {
56
+ state = new SpringState(initial, 0);
57
+ this.stiffness = stiffness; this.damping = damping; this.mass = mass;
58
+ this.restDelta = restDelta; this.restSpeed = restSpeed; this.reduced = reduced;
59
+ targetValue = initial;
60
+ nodes.add(this);
61
+ }
62
+ public double getPosition() { return state.position; }
63
+ public double getVelocity() { return state.velocity; }
64
+ public boolean isMoving() { return moving; }
65
+ public static int activeCount() { return scheduled ? 1 : 0; }
66
+ public static int nodeCount() { return nodes.size(); }
67
+
68
+ public void follow(LiveSpring parent, String mappings) {
69
+ if (disposed || parent.disposed || nodes.indexOf(parent) >= nodes.indexOf(this))
70
+ throw new IllegalArgumentException("A spring can only follow an existing live node");
71
+ sourceMaps = maps(mappings);
72
+ source = parent;
73
+ wake();
74
+ }
75
+ public void updateInput(String snapshot) {
76
+ if (disposed) return;
77
+ try { input = new SampledInput(new JSONObject(snapshot)); }
78
+ catch (JSONException error) { throw new IllegalArgumentException(error); }
79
+ direct = false;
80
+ wake();
81
+ }
82
+ public void setTarget(double value) {
83
+ if (disposed) return;
84
+ direct = true; targetValue = value; input = null;
85
+ wake();
86
+ }
87
+ public void holdInput(double value, double velocity) {
88
+ if (disposed) return;
89
+ inputHeld = true; direct = true; targetValue = value; input = null;
90
+ state.position = value; state.velocity = velocity; moving = true;
91
+ apply(); schedule();
92
+ }
93
+ public void releaseInput(double target, double velocity) {
94
+ if (disposed) return;
95
+ inputHeld = false; state.velocity = velocity; setTarget(target);
96
+ }
97
+ public void setReduced(boolean value) { reduced = value; wake(); }
98
+ public void setPaused(boolean value) {
99
+ paused = value;
100
+ if (value) moving = false; else wake();
101
+ }
102
+ private void wake() {
103
+ if (disposed || paused) return;
104
+ moving = true;
105
+ if (reduced) { step(SystemClock.uptimeMillis(), 0); apply(); }
106
+ schedule();
107
+ }
108
+ private void step(double now, double dt) {
109
+ if (disposed || paused || inputHeld) return;
110
+ double target = source == null ? (direct ? targetValue : input.value(now)) : map(source.state.position, sourceMaps);
111
+ boolean inputMoving = source == null ? !direct && input.running(now) : source.moving;
112
+ if (reduced) { state.position = target; state.velocity = 0; }
113
+ else state.advance(target, stiffness, damping, mass, dt);
114
+ boolean rest = Math.abs(state.position - target) <= restDelta && Math.abs(state.velocity) <= restSpeed;
115
+ if (rest && !inputMoving) { state.position = target; state.velocity = 0; }
116
+ moving = inputMoving || !rest;
117
+ }
118
+ public Binding bind(View view, String property, String mappings) {
119
+ if (disposed) throw new IllegalStateException("Spring disposed");
120
+ Binding binding = new Binding(view, property, maps(mappings));
121
+ bindings.add(binding);
122
+ binding.apply();
123
+ return binding;
124
+ }
125
+ private void apply() {
126
+ for (int i = bindings.size() - 1; i >= 0; i--) {
127
+ Binding binding = bindings.get(i);
128
+ if (binding.view.get() == null) bindings.remove(i); else binding.apply();
129
+ }
130
+ }
131
+ private boolean layersMoving(double now) {
132
+ for (int i = 0; i < bindings.size(); i++)
133
+ for (SampledInput layer : bindings.get(i).layers)
134
+ if (layer.running(now)) return true;
135
+ return false;
136
+ }
137
+ public static void sync(View view) {
138
+ for (int i = 0; i < nodes.size(); i++) {
139
+ LiveSpring node = nodes.get(i);
140
+ for (int j = 0; j < node.bindings.size(); j++) {
141
+ Binding binding = node.bindings.get(j);
142
+ if (binding.view.get() == view) binding.apply();
143
+ }
144
+ }
145
+ }
146
+ public void dispose() {
147
+ if (disposed) return;
148
+ disposed = true; moving = false;
149
+ bindings.clear();
150
+ nodes.remove(this);
151
+ // A retained child continues from its own state toward the parent's last value.
152
+ for (int i = 0; i < nodes.size(); i++) {
153
+ LiveSpring child = nodes.get(i);
154
+ if (child.source == this) {
155
+ child.source = null;
156
+ child.setTarget(map(state.position, child.sourceMaps));
157
+ }
158
+ }
159
+ source = null;
160
+ if (nodes.isEmpty() && scheduled) {
161
+ Choreographer.getInstance().removeFrameCallback(clock);
162
+ scheduled = false; previousFrame = 0;
163
+ }
164
+ }
165
+
166
+ public final class Binding {
167
+ private final WeakReference<View> view;
168
+ private final String property;
169
+ private final Mapping[] mappings;
170
+ private final float density;
171
+ private boolean stopped;
172
+ private SampledInput[] layers = new SampledInput[0];
173
+ Binding(View view, String property, Mapping[] mappings) {
174
+ this.view = new WeakReference<>(view); this.property = property; this.mappings = mappings;
175
+ density = view.getResources().getDisplayMetrics().density;
176
+ }
177
+ public double getValue() { return map(state.position, mappings); }
178
+ public void setLayers(String inputs) {
179
+ try {
180
+ JSONArray array = new JSONArray(inputs);
181
+ SampledInput[] next = new SampledInput[array.length()];
182
+ for (int i = 0; i < next.length; i++) next[i] = new SampledInput(array.getJSONObject(i));
183
+ layers = next;
184
+ } catch (JSONException error) { throw new IllegalArgumentException(error); }
185
+ apply(); schedule();
186
+ }
187
+ public void cancel() { stopped = true; bindings.remove(this); }
188
+ private void apply() {
189
+ View target = view.get();
190
+ if (stopped || target == null || !target.isAttachedToWindow()) return;
191
+ double composed = getValue();
192
+ boolean multiply = property.equals("opacity") || property.startsWith("scale");
193
+ double now = SystemClock.uptimeMillis();
194
+ for (SampledInput layer : layers) composed = multiply ? composed * layer.value(now) : composed + layer.value(now);
195
+ float value = (float)composed;
196
+ switch (property) {
197
+ case "x": target.setTranslationX(value * density); break;
198
+ case "y": target.setTranslationY(value * density); break;
199
+ case "scaleX": target.setScaleX(value); break;
200
+ case "scaleY": target.setScaleY(value); break;
201
+ case "rotate": target.setRotation(value); break;
202
+ case "rotateX": target.setRotationX(value); break;
203
+ case "rotateY": target.setRotationY(value); break;
204
+ case "opacity": target.setAlpha(Math.max(0, Math.min(1, value))); break;
205
+ default: throw new IllegalArgumentException("Unsupported live property " + property);
206
+ }
207
+ }
208
+ }
209
+ private static final class Mapping {
210
+ final double[] input, output;
211
+ final boolean clamp;
212
+ Mapping(JSONObject json) throws JSONException {
213
+ input = array(json.getJSONArray("input")); output = array(json.getJSONArray("output"));
214
+ clamp = json.getBoolean("clamp");
215
+ }
216
+ double value(double value) {
217
+ if (clamp && value <= input[0]) return output[0];
218
+ if (clamp && value >= input[input.length - 1]) return output[output.length - 1];
219
+ int index = 0;
220
+ while (index < input.length - 2 && value > input[index + 1]) index++;
221
+ double span = input[index + 1] - input[index];
222
+ double fraction = span > 0 ? (value - input[index]) / span : value < input[index] ? 0 : 1;
223
+ return output[index] + (output[index + 1] - output[index]) * fraction;
224
+ }
225
+ }
226
+ private static Mapping[] maps(String json) {
227
+ try {
228
+ JSONArray array = new JSONArray(json);
229
+ Mapping[] result = new Mapping[array.length()];
230
+ for (int i = 0; i < result.length; i++) result[i] = new Mapping(array.getJSONObject(i));
231
+ return result;
232
+ } catch (JSONException error) { throw new IllegalArgumentException(error); }
233
+ }
234
+ private static double map(double value, Mapping[] maps) {
235
+ for (Mapping mapping : maps) value = mapping.value(value);
236
+ return value;
237
+ }
238
+ private static double[] array(JSONArray input) throws JSONException {
239
+ double[] result = new double[input.length()];
240
+ for (int i = 0; i < result.length; i++) result[i] = input.getDouble(i);
241
+ return result;
242
+ }
243
+ private static final class SampledInput {
244
+ final double value, anchor, elapsed, duration, delay, repeat, end;
245
+ final double[] values, times;
246
+ final boolean reverse, playing;
247
+ SampledInput(JSONObject json) throws JSONException {
248
+ value = json.getDouble("value");
249
+ anchor = SystemClock.uptimeMillis(); elapsed = json.getDouble("elapsed");
250
+ playing = json.getBoolean("running");
251
+ duration = json.getDouble("duration"); delay = json.getDouble("delay");
252
+ repeat = json.getDouble("repeat"); end = json.getDouble("to");
253
+ reverse = json.getBoolean("reverse");
254
+ values = array(json.getJSONArray("values")); times = array(json.getJSONArray("times"));
255
+ }
256
+ boolean running(double now) { return playing && (repeat < 0 || now - anchor + elapsed < delay + duration * (repeat + 1)); }
257
+ double value(double now) {
258
+ if (!playing || values.length < 2) return value;
259
+ if (!running(now)) return end;
260
+ double time = Math.max(0, now - anchor + elapsed - delay);
261
+ double iteration = Math.floor(time / Math.max(1, duration));
262
+ double progress = (time % Math.max(1, duration)) / Math.max(1, duration);
263
+ if (reverse && iteration % 2 >= 1) progress = 1 - progress;
264
+ int lo = 0, hi = times.length - 1;
265
+ while (lo + 1 < hi) { int mid = (lo + hi) / 2; if (times[mid] <= progress) lo = mid; else hi = mid; }
266
+ double fraction = times[hi] > times[lo] ? (progress - times[lo]) / (times[hi] - times[lo]) : 1;
267
+ return values[lo] + (values[hi] - values[lo]) * Math.max(0, Math.min(1, fraction));
268
+ }
269
+ }
270
+ }
@@ -0,0 +1,39 @@
1
+ package org.rafaelaugusto.jolt;
2
+
3
+ /** Exact constant-target solution, matching JoltSpringState.h. No per-step allocation. */
4
+ public final class SpringState {
5
+ public double position, velocity;
6
+
7
+ public SpringState(double position, double velocity) {
8
+ this.position = position;
9
+ this.velocity = velocity;
10
+ }
11
+
12
+ public void advance(double target, double stiffness, double damping, double mass, double seconds) {
13
+ if (seconds <= 0) return;
14
+ double x = position - target;
15
+ double w = Math.sqrt(stiffness / mass);
16
+ double z = damping / (2 * Math.sqrt(stiffness * mass));
17
+ double next, speed;
18
+ if (Math.abs(z - 1) < 1e-6) {
19
+ double b = velocity + w * x, e = Math.exp(-w * seconds);
20
+ next = e * (x + b * seconds);
21
+ speed = e * (b - w * (x + b * seconds));
22
+ } else if (z < 1) {
23
+ double a = z * w, d = w * Math.sqrt(1 - z * z);
24
+ double b = (velocity + a * x) / d;
25
+ double c = Math.cos(d * seconds), s = Math.sin(d * seconds), e = Math.exp(-a * seconds);
26
+ next = e * (x * c + b * s);
27
+ speed = e * ((-a * x + b * d) * c + (-a * b - x * d) * s);
28
+ } else {
29
+ double q = Math.sqrt(z * z - 1);
30
+ double r1 = -w / (z + q), r2 = -w * (z + q);
31
+ double c1 = (velocity - r2 * x) / (r1 - r2), c2 = x - c1;
32
+ double e1 = Math.exp(r1 * seconds), e2 = Math.exp(r2 * seconds);
33
+ next = c1 * e1 + c2 * e2;
34
+ speed = c1 * r1 * e1 + c2 * r2 * e2;
35
+ }
36
+ position = target + next;
37
+ velocity = speed;
38
+ }
39
+ }
@@ -0,0 +1,17 @@
1
+ #import <UIKit/UIKit.h>
2
+ #import "JoltLiveSpring.h"
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+ @interface JoltGestureSession : NSObject
6
+ @property(nonatomic, readonly) BOOL active;
7
+ + (instancetype)attach:(UIView *)surface scroll:(nullable UIScrollView *)scroll
8
+ x:(JoltLiveSpring *)x y:(JoltLiveSpring *)y scale:(JoltLiveSpring *)scale rotation:(JoltLiveSpring *)rotation
9
+ options:(NSString *)options onState:(void (^)(NSInteger, double, double))onState;
10
+ - (void)cancel;
11
+ - (void)refreshScrollDelegate;
12
+ - (void)setEnabled:(BOOL)enabled;
13
+ - (void)settleX:(double)x y:(double)y scale:(double)scale rotation:(double)rotation animated:(BOOL)animated;
14
+ - (void)updateLeft:(double)left right:(double)right top:(double)top bottom:(double)bottom;
15
+ - (void)dispose;
16
+ @end
17
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,217 @@
1
+ #import "JoltGestureSession.h"
2
+ #import <UIKit/UIGestureRecognizerSubclass.h>
3
+
4
+ static double clamp(double value, double minimum, double maximum) { return MAX(minimum, MIN(maximum, value)); }
5
+ @class JoltGestureSession;
6
+
7
+ /* Centroid tracking in window coordinates. Pointer membership changes rebase without movement. */
8
+ @interface JoltAxisPan : UIGestureRecognizer
9
+ @property(nonatomic, weak) JoltGestureSession *owner;
10
+ @property(nonatomic) NSMutableSet<UITouch *> *contacts;
11
+ @property(nonatomic) CGPoint previous, origin, delta, velocity;
12
+ @property(nonatomic) double previousTime, threshold, failOffset;
13
+ @property(nonatomic) NSInteger axis;
14
+ - (void)rebase;
15
+ @end
16
+
17
+ @interface JoltGestureSession () <UIGestureRecognizerDelegate, UIScrollViewDelegate>
18
+ @property(nonatomic, weak) UIView *surface;
19
+ @property(nonatomic, weak) UIScrollView *scroll;
20
+ @property(nonatomic) id<UIScrollViewDelegate> previousDelegate;
21
+ @property(nonatomic) JoltAxisPan *pan;
22
+ @property(nonatomic) UIPinchGestureRecognizer *pinch;
23
+ @property(nonatomic) UIRotationGestureRecognizer *rotate;
24
+ @property(nonatomic) UITapGestureRecognizer *tap, *doubleTap;
25
+ @property(nonatomic) JoltLiveSpring *x, *y, *scale, *rotation;
26
+ @property(nonatomic) double minX, maxX, minY, maxY, minScale, maxScale, velocityX, velocityY, scrollPosition;
27
+ @property(nonatomic, readwrite) BOOL active;
28
+ @property(nonatomic) BOOL enabled, disposed, contact, fromScroll, adjusting, ending;
29
+ @property(nonatomic, copy) void (^onState)(NSInteger, double, double);
30
+ - (void)touchStarted:(UITouch *)touch;
31
+ - (void)finish:(BOOL)cancelled;
32
+ - (void)applyScroll;
33
+ @end
34
+
35
+ @implementation JoltAxisPan
36
+ - (instancetype)initWithTarget:(id)target action:(SEL)action {
37
+ if ((self = [super initWithTarget:target action:action])) self.contacts = [NSMutableSet new];
38
+ return self;
39
+ }
40
+ - (CGPoint)centroid {
41
+ CGPoint point = CGPointZero;
42
+ for (UITouch *touch in self.contacts) { CGPoint p = [touch locationInView:nil]; point.x += p.x; point.y += p.y; }
43
+ if (self.contacts.count) { point.x /= self.contacts.count; point.y /= self.contacts.count; }
44
+ return point;
45
+ }
46
+ - (void)rebase { self.previous = [self centroid]; self.previousTime = CACurrentMediaTime(); self.delta = CGPointZero; self.velocity = CGPointZero; }
47
+ - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
48
+ BOOL first = !self.contacts.count;
49
+ [self.contacts unionSet:touches]; [self rebase];
50
+ if (self.state == UIGestureRecognizerStatePossible) self.origin = self.previous;
51
+ if (first) { self.origin = self.previous; [self.owner touchStarted:touches.anyObject]; }
52
+ }
53
+ - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
54
+ CGPoint point = [self centroid]; double now = CACurrentMediaTime(), dt = MAX(.001, now - self.previousTime);
55
+ self.delta = CGPointMake(point.x - self.previous.x, point.y - self.previous.y);
56
+ self.velocity = CGPointMake(self.delta.x / dt, self.delta.y / dt);
57
+ self.previous = point; self.previousTime = now;
58
+ if (self.state == UIGestureRecognizerStatePossible) {
59
+ double dx = fabs(point.x - self.origin.x), dy = fabs(point.y - self.origin.y);
60
+ if ((self.axis == 1 && dy > self.failOffset) || (self.axis == 2 && dx > self.failOffset)) { self.state = UIGestureRecognizerStateFailed; return; }
61
+ double distance = self.axis == 1 ? dx : self.axis == 2 ? dy : hypot(dx, dy);
62
+ if (self.axis && distance >= self.threshold) self.state = UIGestureRecognizerStateBegan;
63
+ } else if (self.state == UIGestureRecognizerStateBegan || self.state == UIGestureRecognizerStateChanged) self.state = UIGestureRecognizerStateChanged;
64
+ }
65
+ - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
66
+ [self.contacts minusSet:touches];
67
+ if (self.contacts.count) [self rebase];
68
+ else {
69
+ if (CACurrentMediaTime() - self.previousTime > .08) self.velocity = CGPointZero;
70
+ self.owner.velocityX = (self.axis & 1) ? self.velocity.x : 0; self.owner.velocityY = (self.axis & 2) ? self.velocity.y : 0;
71
+ self.state = self.state == UIGestureRecognizerStatePossible ? UIGestureRecognizerStateFailed : UIGestureRecognizerStateEnded;
72
+ [self.owner finish:NO];
73
+ }
74
+ }
75
+ - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { self.state = UIGestureRecognizerStateCancelled; [self.owner finish:YES]; }
76
+ - (void)reset { [super reset]; [self.contacts removeAllObjects]; self.delta = CGPointZero; self.velocity = CGPointZero; }
77
+ @end
78
+
79
+ @implementation JoltGestureSession
80
+ + (instancetype)attach:(UIView *)surface scroll:(UIScrollView *)scroll x:(JoltLiveSpring *)x y:(JoltLiveSpring *)y scale:(JoltLiveSpring *)scale rotation:(JoltLiveSpring *)rotation options:(NSString *)options onState:(void (^)(NSInteger,double,double))onState {
81
+ NSDictionary *config = [NSJSONSerialization JSONObjectWithData:[options dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
82
+ JoltGestureSession *s = [self new]; s.surface = surface; s.scroll = scroll; s.x = x; s.y = y; s.scale = scale; s.rotation = rotation;
83
+ s.minX = [config[@"minX"] doubleValue]; s.maxX = [config[@"maxX"] doubleValue]; s.minY = [config[@"minY"] doubleValue]; s.maxY = [config[@"maxY"] doubleValue];
84
+ s.minScale = [config[@"minScale"] doubleValue]; s.maxScale = [config[@"maxScale"] doubleValue]; s.onState = onState; s.enabled = YES;
85
+ s.pan = [[JoltAxisPan alloc] initWithTarget:s action:@selector(panned:)]; s.pan.owner = s;
86
+ s.pan.axis = [config[@"axis"] integerValue]; s.pan.threshold = [config[@"threshold"] doubleValue]; s.pan.failOffset = [config[@"failOffset"] doubleValue];
87
+ s.pinch = [[UIPinchGestureRecognizer alloc] initWithTarget:s action:@selector(pinched:)];
88
+ s.rotate = [[UIRotationGestureRecognizer alloc] initWithTarget:s action:@selector(rotated:)];
89
+ s.tap = [[UITapGestureRecognizer alloc] initWithTarget:s action:@selector(tapped:)];
90
+ s.doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:s action:@selector(tapped:)]; s.doubleTap.numberOfTapsRequired = 2;
91
+ [s.tap requireGestureRecognizerToFail:s.doubleTap];
92
+ for (UIGestureRecognizer *g in @[s.pan, s.pinch, s.rotate, s.tap, s.doubleTap]) { g.delegate = s; [surface addGestureRecognizer:g]; }
93
+ s.pinch.enabled = [config[@"pinch"] boolValue]; s.rotate.enabled = [config[@"rotate"] boolValue];
94
+ s.tap.enabled = [config[@"tap"] boolValue]; s.doubleTap.enabled = [config[@"doubleTap"] boolValue];
95
+ // NativeScript's scroll delegate is forwarded, never replaced with a JS per-frame bridge.
96
+ if (scroll) { s.previousDelegate = scroll.delegate; scroll.delegate = s; }
97
+ return s;
98
+ }
99
+ - (BOOL)respondsToSelector:(SEL)selector { return [super respondsToSelector:selector] || [self.previousDelegate respondsToSelector:selector]; }
100
+ - (void)refreshScrollDelegate {
101
+ if (!self.disposed && self.scroll && self.scroll.delegate != self) {
102
+ self.previousDelegate = self.scroll.delegate; self.scroll.delegate = self;
103
+ }
104
+ }
105
+ - (id)forwardingTargetForSelector:(SEL)selector { return [self.previousDelegate respondsToSelector:selector] ? self.previousDelegate : [super forwardingTargetForSelector:selector]; }
106
+ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)first shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)second {
107
+ if ([first isKindOfClass:UITapGestureRecognizer.class] || [second isKindOfClass:UITapGestureRecognizer.class]) return NO;
108
+ return YES;
109
+ }
110
+ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gesture shouldReceiveTouch:(UITouch *)touch {
111
+ return self.enabled && !self.disposed;
112
+ }
113
+ - (void)touchStarted:(UITouch *)touch {
114
+ if (self.disposed || !self.enabled) return;
115
+ self.contact = YES; self.fromScroll = self.scroll && [touch.view isDescendantOfView:self.scroll];
116
+ self.scrollPosition = MAX(-self.scroll.adjustedContentInset.top, self.scroll.contentOffset.y);
117
+ self.velocityX = self.velocityY = 0;
118
+ for (JoltLiveSpring *node in @[self.x, self.y, self.scale, self.rotation]) [node releaseInput:node.position velocity:0];
119
+ }
120
+ - (void)emit:(NSInteger)state { if (!self.disposed && self.onState) self.onState(state, self.velocityX, self.velocityY); }
121
+ - (void)begin {
122
+ if (self.active || self.disposed || !self.enabled) return;
123
+ self.active = YES;
124
+ for (JoltLiveSpring *node in @[self.x, self.y, self.scale, self.rotation]) [node holdInput:node.position velocity:0];
125
+ [self emit:1];
126
+ }
127
+ - (void)panned:(JoltAxisPan *)pan {
128
+ if (self.disposed || !self.enabled) return;
129
+ if (pan.state == UIGestureRecognizerStateBegan || pan.state == UIGestureRecognizerStateChanged) {
130
+ [self begin]; if (self.disposed || !self.enabled || !self.active) return;
131
+ self.velocityX = (pan.axis & 1) ? pan.velocity.x : 0; self.velocityY = (pan.axis & 2) ? pan.velocity.y : 0;
132
+ double dx = pan.delta.x, dy = pan.delta.y;
133
+ if (self.fromScroll) {
134
+ double oldY = self.y.position, nextY = oldY, top = -self.scroll.adjustedContentInset.top;
135
+ if (dy < 0) { nextY = MAX(self.minY, oldY + dy); dy -= nextY - oldY; }
136
+ else {
137
+ double consumed = MIN(dy, MAX(0, self.scrollPosition - top));
138
+ self.scrollPosition -= consumed; dy -= consumed;
139
+ nextY = MIN(self.maxY, oldY + dy); dy -= nextY - oldY;
140
+ }
141
+ double bottom = MAX(top, self.scroll.contentSize.height - self.scroll.bounds.size.height + self.scroll.adjustedContentInset.bottom);
142
+ self.scrollPosition = clamp(self.scrollPosition - dy, top, bottom);
143
+ [self.y holdInput:nextY velocity:self.velocityY]; [self applyScroll];
144
+ } else {
145
+ if (pan.axis & 1) [self.x holdInput:clamp(self.x.position + dx, self.minX, self.maxX) velocity:self.velocityX];
146
+ if (pan.axis & 2) [self.y holdInput:clamp(self.y.position + dy, self.minY, self.maxY) velocity:self.velocityY];
147
+ }
148
+ } else if (pan.state == UIGestureRecognizerStateEnded) [self finish:NO];
149
+ else if (pan.state == UIGestureRecognizerStateCancelled || pan.state == UIGestureRecognizerStateFailed) [self finish:YES];
150
+ }
151
+ - (void)pinched:(UIPinchGestureRecognizer *)gesture {
152
+ if (self.fromScroll || self.disposed || !self.enabled) return;
153
+ if (gesture.state == UIGestureRecognizerStateBegan || gesture.state == UIGestureRecognizerStateChanged) {
154
+ [self begin]; if (self.disposed || !self.active) return;
155
+ [self.scale holdInput:clamp(self.scale.position * gesture.scale, self.minScale, self.maxScale) velocity:0]; gesture.scale = 1;
156
+ } else if (!self.pan.contacts.count) [self finish:gesture.state == UIGestureRecognizerStateCancelled];
157
+ }
158
+ - (void)rotated:(UIRotationGestureRecognizer *)gesture {
159
+ if (self.fromScroll || self.disposed || !self.enabled) return;
160
+ if (gesture.state == UIGestureRecognizerStateBegan || gesture.state == UIGestureRecognizerStateChanged) {
161
+ [self begin]; if (self.disposed || !self.active) return;
162
+ [self.rotation holdInput:self.rotation.position + gesture.rotation * 180 / M_PI velocity:0]; gesture.rotation = 0;
163
+ } else if (!self.pan.contacts.count) [self finish:gesture.state == UIGestureRecognizerStateCancelled];
164
+ }
165
+ - (void)tapped:(UITapGestureRecognizer *)gesture { if (gesture.state == UIGestureRecognizerStateRecognized) [self emit:gesture == self.doubleTap ? 5 : 4]; }
166
+ - (void)applyScroll {
167
+ if (!self.scroll || self.adjusting) return;
168
+ self.adjusting = YES;
169
+ [self.scroll setContentOffset:CGPointMake(self.scroll.contentOffset.x, self.scrollPosition) animated:NO];
170
+ self.adjusting = NO;
171
+ }
172
+ - (void)scrollViewDidScroll:(UIScrollView *)scroll {
173
+ if (self.contact && self.fromScroll && !self.adjusting) [self applyScroll];
174
+ if (!self.adjusting && [self.previousDelegate respondsToSelector:_cmd]) [self.previousDelegate scrollViewDidScroll:scroll];
175
+ }
176
+ - (void)scrollViewWillEndDragging:(UIScrollView *)scroll withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)target {
177
+ if (self.y.position > self.minY + .01) target->y = scroll.contentOffset.y;
178
+ if ([self.previousDelegate respondsToSelector:_cmd]) [self.previousDelegate scrollViewWillEndDragging:scroll withVelocity:velocity targetContentOffset:target];
179
+ }
180
+ - (void)finish:(BOOL)cancelled {
181
+ self.contact = NO;
182
+ if (!self.active) return;
183
+ self.active = NO;
184
+ for (JoltLiveSpring *node in @[self.x, self.y, self.scale, self.rotation]) [node releaseInput:node.position velocity:0];
185
+ self.ending = !cancelled;
186
+ @try { [self emit:cancelled ? 3 : 2]; } @finally { self.ending = NO; }
187
+ }
188
+ - (void)cancel {
189
+ if (self.active) [self finish:YES];
190
+ else {
191
+ [self finish:YES];
192
+ for (JoltLiveSpring *node in @[self.x, self.y, self.scale, self.rotation]) [node releaseInput:node.position velocity:0];
193
+ }
194
+ for (UIGestureRecognizer *g in @[self.pan, self.pinch, self.rotate, self.tap, self.doubleTap]) if (g.enabled) { g.enabled = NO; g.enabled = YES; }
195
+ }
196
+ - (void)setEnabled:(BOOL)value { _enabled = value; if (!value) [self cancel]; }
197
+ - (void)settleX:(double)x y:(double)y scale:(double)scale rotation:(double)rotation animated:(BOOL)animated {
198
+ if (self.disposed) return;
199
+ double vx = self.ending ? self.velocityX : self.x.velocity, vy = self.ending ? self.velocityY : self.y.velocity;
200
+ [self cancel];
201
+ double targets[] = {clamp(x, self.minX, self.maxX), clamp(y, self.minY, self.maxY), clamp(scale, self.minScale, self.maxScale), rotation};
202
+ NSArray *nodes = @[self.x, self.y, self.scale, self.rotation];
203
+ for (NSUInteger i = 0; i < nodes.count; i++) {
204
+ JoltLiveSpring *node = nodes[i];
205
+ if (!animated) [node holdInput:targets[i] velocity:0];
206
+ [node releaseInput:targets[i] velocity:animated ? i == 0 ? vx : i == 1 ? vy : 0 : 0];
207
+ }
208
+ }
209
+ - (void)updateLeft:(double)left right:(double)right top:(double)top bottom:(double)bottom { self.minX = left; self.maxX = right; self.minY = top; self.maxY = bottom; }
210
+ - (void)dispose {
211
+ if (self.disposed) return;
212
+ [self cancel]; self.disposed = YES;
213
+ for (UIGestureRecognizer *g in @[self.pan, self.pinch, self.rotate, self.tap, self.doubleTap]) [self.surface removeGestureRecognizer:g];
214
+ if (self.scroll.delegate == self) self.scroll.delegate = self.previousDelegate;
215
+ self.previousDelegate = nil; self.onState = nil;
216
+ }
217
+ @end
@@ -0,0 +1,28 @@
1
+ #import <UIKit/UIKit.h>
2
+ NS_ASSUME_NONNULL_BEGIN
3
+ @class JoltLiveSpringBinding;
4
+ @interface JoltLiveSpring : NSObject
5
+ + (instancetype)create:(double)initial stiffness:(double)stiffness damping:(double)damping mass:(double)mass
6
+ restDelta:(double)restDelta restSpeed:(double)restSpeed reduced:(BOOL)reduced;
7
+ @property(nonatomic, readonly) double position;
8
+ @property(nonatomic, readonly) double velocity;
9
+ @property(nonatomic, readonly) BOOL moving;
10
+ + (NSUInteger)activeCount;
11
+ + (NSUInteger)nodeCount;
12
+ + (void)sync:(UIView *)view;
13
+ - (void)follow:(JoltLiveSpring *)source mappings:(NSString *)mappings;
14
+ - (void)updateInput:(NSString *)snapshot;
15
+ - (void)setTarget:(double)value;
16
+ - (void)holdInput:(double)value velocity:(double)velocity;
17
+ - (void)releaseInput:(double)target velocity:(double)velocity;
18
+ - (void)setReduced:(BOOL)value;
19
+ - (void)setPaused:(BOOL)value;
20
+ - (JoltLiveSpringBinding *)bind:(UIView *)view property:(NSString *)property mappings:(NSString *)mappings;
21
+ - (void)dispose;
22
+ @end
23
+ @interface JoltLiveSpringBinding : NSObject
24
+ @property(nonatomic, readonly) double value;
25
+ - (void)setLayers:(NSString *)inputs;
26
+ - (void)cancel;
27
+ @end
28
+ NS_ASSUME_NONNULL_END