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

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 (151) hide show
  1. package/README.md +9 -6
  2. package/dist/animate.d.ts +6 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +3 -1
  5. package/dist/animate.js.map +1 -1
  6. package/dist/drag.js +3 -3
  7. package/dist/drag.js.map +1 -1
  8. package/dist/driver/android-gradient.d.ts.map +1 -1
  9. package/dist/driver/android-gradient.js +11 -1
  10. package/dist/driver/android-gradient.js.map +1 -1
  11. package/dist/driver/android-particles.d.ts +6 -0
  12. package/dist/driver/android-particles.d.ts.map +1 -0
  13. package/dist/driver/android-particles.js +93 -0
  14. package/dist/driver/android-particles.js.map +1 -0
  15. package/dist/driver/android.d.ts.map +1 -1
  16. package/dist/driver/android.js +2 -0
  17. package/dist/driver/android.js.map +1 -1
  18. package/dist/driver/frame.d.ts +3 -0
  19. package/dist/driver/frame.d.ts.map +1 -0
  20. package/dist/driver/frame.js +7 -0
  21. package/dist/driver/frame.js.map +1 -0
  22. package/dist/driver/index.d.ts +2 -0
  23. package/dist/driver/index.d.ts.map +1 -1
  24. package/dist/driver/index.js.map +1 -1
  25. package/dist/driver/ios-gradient.d.ts.map +1 -1
  26. package/dist/driver/ios-gradient.js +24 -2
  27. package/dist/driver/ios-gradient.js.map +1 -1
  28. package/dist/driver/ios.d.ts.map +1 -1
  29. package/dist/driver/ios.js +2 -0
  30. package/dist/driver/ios.js.map +1 -1
  31. package/dist/engine.d.ts +2 -1
  32. package/dist/engine.d.ts.map +1 -1
  33. package/dist/engine.js +32 -5
  34. package/dist/engine.js.map +1 -1
  35. package/dist/gradient-binding.d.ts +38 -0
  36. package/dist/gradient-binding.d.ts.map +1 -0
  37. package/dist/gradient-binding.js +128 -0
  38. package/dist/gradient-binding.js.map +1 -0
  39. package/dist/gradient.d.ts +19 -4
  40. package/dist/gradient.d.ts.map +1 -1
  41. package/dist/gradient.js +331 -70
  42. package/dist/gradient.js.map +1 -1
  43. package/dist/haptics.d.ts.map +1 -1
  44. package/dist/haptics.js +101 -28
  45. package/dist/haptics.js.map +1 -1
  46. package/dist/image-effect.d.ts +1 -0
  47. package/dist/image-effect.d.ts.map +1 -1
  48. package/dist/image-effect.js +10 -2
  49. package/dist/image-effect.js.map +1 -1
  50. package/dist/index.d.ts +6 -2
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/motion-scope.d.ts +3 -2
  55. package/dist/motion-scope.d.ts.map +1 -1
  56. package/dist/motion-scope.js +12 -2
  57. package/dist/motion-scope.js.map +1 -1
  58. package/dist/motion-value.d.ts +62 -8
  59. package/dist/motion-value.d.ts.map +1 -1
  60. package/dist/motion-value.js +327 -91
  61. package/dist/motion-value.js.map +1 -1
  62. package/dist/native/index.d.ts +7 -0
  63. package/dist/native/index.d.ts.map +1 -0
  64. package/dist/native/index.js +4 -0
  65. package/dist/native/index.js.map +1 -0
  66. package/dist/native-properties.d.ts +28 -0
  67. package/dist/native-properties.d.ts.map +1 -0
  68. package/dist/native-properties.js +154 -0
  69. package/dist/native-properties.js.map +1 -0
  70. package/dist/native-property-driver.d.ts +19 -0
  71. package/dist/native-property-driver.d.ts.map +1 -0
  72. package/dist/native-property-driver.js +69 -0
  73. package/dist/native-property-driver.js.map +1 -0
  74. package/dist/native-property.d.ts +35 -0
  75. package/dist/native-property.d.ts.map +1 -0
  76. package/dist/native-property.js +277 -0
  77. package/dist/native-property.js.map +1 -0
  78. package/dist/particles.d.ts +5 -2
  79. package/dist/particles.d.ts.map +1 -1
  80. package/dist/particles.js +16 -10
  81. package/dist/particles.js.map +1 -1
  82. package/dist/playback-group.d.ts.map +1 -1
  83. package/dist/playback-group.js +17 -1
  84. package/dist/playback-group.js.map +1 -1
  85. package/dist/playback.d.ts +15 -0
  86. package/dist/playback.d.ts.map +1 -1
  87. package/dist/playback.js +23 -0
  88. package/dist/playback.js.map +1 -1
  89. package/dist/scene-appearance.d.ts +14 -0
  90. package/dist/scene-appearance.d.ts.map +1 -0
  91. package/dist/scene-appearance.js +68 -0
  92. package/dist/scene-appearance.js.map +1 -0
  93. package/dist/sequence.d.ts +24 -0
  94. package/dist/sequence.d.ts.map +1 -1
  95. package/dist/sequence.js +181 -19
  96. package/dist/sequence.js.map +1 -1
  97. package/dist/timed-animation.d.ts +38 -0
  98. package/dist/timed-animation.d.ts.map +1 -0
  99. package/dist/timed-animation.js +177 -0
  100. package/dist/timed-animation.js.map +1 -0
  101. package/dist/types.d.ts +1 -1
  102. package/dist/types.d.ts.map +1 -1
  103. package/dist/value-observers.d.ts +19 -0
  104. package/dist/value-observers.d.ts.map +1 -0
  105. package/dist/value-observers.js +111 -0
  106. package/dist/value-observers.js.map +1 -0
  107. package/dist/value-track.d.ts +13 -0
  108. package/dist/value-track.d.ts.map +1 -0
  109. package/dist/value-track.js +59 -0
  110. package/dist/value-track.js.map +1 -0
  111. package/dist/vue/index.d.ts.map +1 -1
  112. package/dist/vue/index.js +28 -5
  113. package/dist/vue/index.js.map +1 -1
  114. package/package.json +6 -2
  115. package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +30 -1
  116. package/platforms/android/java/org/rafaelaugusto/jolt/ParticleEmitter.java +192 -0
  117. package/platforms/android/java/org/rafaelaugusto/jolt/ParticleSimulation.java +114 -0
  118. package/platforms/android/proguard-rules.pro +2 -0
  119. package/src/android-plugin.d.ts +93 -0
  120. package/src/animate.ts +13 -7
  121. package/src/drag.ts +3 -3
  122. package/src/driver/android-gradient.ts +8 -1
  123. package/src/driver/android-particles.ts +118 -0
  124. package/src/driver/android.ts +2 -0
  125. package/src/driver/frame.ts +10 -0
  126. package/src/driver/index.ts +2 -0
  127. package/src/driver/ios-gradient.ts +24 -2
  128. package/src/driver/ios.ts +2 -0
  129. package/src/engine.ts +33 -6
  130. package/src/gradient-binding.ts +158 -0
  131. package/src/gradient.ts +376 -91
  132. package/src/haptics.ts +106 -28
  133. package/src/image-effect.ts +10 -2
  134. package/src/index.ts +6 -0
  135. package/src/ios-plugin.d.ts +40 -0
  136. package/src/motion-scope.ts +14 -3
  137. package/src/motion-value.ts +374 -99
  138. package/src/native/index.ts +23 -0
  139. package/src/native-properties.ts +188 -0
  140. package/src/native-property-driver.ts +123 -0
  141. package/src/native-property.ts +353 -0
  142. package/src/particles.ts +18 -17
  143. package/src/playback-group.ts +17 -1
  144. package/src/playback.ts +41 -0
  145. package/src/scene-appearance.ts +92 -0
  146. package/src/sequence.ts +209 -20
  147. package/src/timed-animation.ts +192 -0
  148. package/src/types.ts +1 -1
  149. package/src/value-observers.ts +133 -0
  150. package/src/value-track.ts +71 -0
  151. package/src/vue/index.ts +29 -5
@@ -0,0 +1,114 @@
1
+ package org.rafaelaugusto.jolt;
2
+
3
+ /** Bounded, allocation-free particle state. Units are DIPs, seconds and degrees.
4
+ * Android supplies elapsed wall time; the same engine runs in deterministic JVM tests. */
5
+ public final class ParticleSimulation {
6
+ public static final int MAX_PARTICLES = 10000;
7
+ public final int shape, source;
8
+ public final float size;
9
+ private final int[] palette;
10
+ private final double rate, lifetime, duration;
11
+ private final float velocity, velocityRange, angle, spread, gravity, spin, spinRange;
12
+ private final boolean fade;
13
+ private double[] births;
14
+ private float[] xs, ys, vxs, vys, rotations, spins;
15
+ private int[] colors;
16
+ private int live, peak;
17
+ private long scheduled, emitted, skipped;
18
+ private long random;
19
+ private double time;
20
+
21
+ public ParticleSimulation(int[] palette, int shape, int source, double rate,
22
+ double lifetimeMs, float size, float velocity, float velocityRange,
23
+ float angle, float spread, float gravity, float spin, float spinRange,
24
+ boolean fade, double durationMs, long seed) {
25
+ if (palette == null || palette.length == 0 || palette.length > 32 || shape < 0 || shape > 2 || source < 0 || source > 2)
26
+ throw new IllegalArgumentException("Invalid particle colors, shape or source");
27
+ if (!finite(rate) || rate <= 0 || rate > 50000 || !finite(lifetimeMs) || lifetimeMs < 1 || lifetimeMs > 60000
28
+ || !finite(durationMs) || (durationMs != -1 && (durationMs < 1 || durationMs > Integer.MAX_VALUE - lifetimeMs - 50))
29
+ || !finite(size) || size < 1 || size > 128 || !finite(velocity) || velocity < 0 || velocity > 10000
30
+ || !finite(velocityRange) || velocityRange < 0 || velocityRange > 10000
31
+ || !finite(angle) || Math.abs(angle) > 360 || !finite(spread) || spread < 0 || spread > 360
32
+ || !finite(gravity) || Math.abs(gravity) > 10000 || !finite(spin) || Math.abs(spin) > 3600
33
+ || !finite(spinRange) || spinRange < 0 || spinRange > 3600)
34
+ throw new IllegalArgumentException("Invalid particle settings");
35
+ this.palette = palette.clone();
36
+ this.shape = shape; this.source = source; this.rate = rate;
37
+ lifetime = lifetimeMs / 1000; duration = durationMs < 0 ? Double.POSITIVE_INFINITY : durationMs / 1000;
38
+ this.size = size; this.velocity = velocity; this.velocityRange = velocityRange;
39
+ this.angle = angle; this.spread = spread; this.gravity = gravity;
40
+ this.spin = spin; this.spinRange = spinRange; this.fade = fade;
41
+ random = seed == 0 ? 1 : seed;
42
+ double population = rate * Math.min(lifetime, duration);
43
+ if (population > MAX_PARTICLES + 0.000001) throw new IllegalArgumentException("Particle population exceeds 10000");
44
+ int capacity = Math.max(1, Math.min(MAX_PARTICLES, (int)Math.ceil(population)));
45
+ births = new double[capacity]; xs = new float[capacity]; ys = new float[capacity];
46
+ vxs = new float[capacity]; vys = new float[capacity]; rotations = new float[capacity];
47
+ spins = new float[capacity]; colors = new int[capacity];
48
+ }
49
+
50
+ private static boolean finite(double value) { return !Double.isNaN(value) && !Double.isInfinite(value); }
51
+ private float unit() {
52
+ // xorshift64*: deterministic internal seed, never part of the public JS contract.
53
+ random ^= random >>> 12; random ^= random << 25; random ^= random >>> 27;
54
+ return ((random * 2685821657736338717L) >>> 40) / 16777216f;
55
+ }
56
+ private float variation(float range) { return (unit() * 2 - 1) * range; }
57
+
58
+ /** Advance directly to the current time, skipping already-dead births after a stall.
59
+ * No backlog of expired particles and no integration steps proportional to missed frames. */
60
+ public void advance(double elapsedSeconds, float width, float height) {
61
+ if (births == null || !finite(elapsedSeconds)) return;
62
+ time = Math.max(time, Math.max(0, elapsedSeconds));
63
+ for (int i = live - 1; i >= 0; i--) {
64
+ if (time - births[i] >= lifetime - 0.000000001) {
65
+ int last = --live;
66
+ births[i] = births[last]; xs[i] = xs[last]; ys[i] = ys[last];
67
+ vxs[i] = vxs[last]; vys[i] = vys[last]; rotations[i] = rotations[last];
68
+ spins[i] = spins[last]; colors[i] = colors[last];
69
+ }
70
+ }
71
+ long until = (long)Math.floor(Math.min(time, duration) * rate + 0.0000001);
72
+ long dead = Math.min(until, (long)Math.floor(Math.max(0, time - lifetime) * rate + 0.0000001));
73
+ if (dead > scheduled) { skipped += dead - scheduled; scheduled = dead; }
74
+ // scheduled is monotonic; at most capacity particles can be alive in this interval.
75
+ while (scheduled < until) {
76
+ long index = ++scheduled;
77
+ if (live >= births.length) { skipped++; continue; }
78
+ int i = live++;
79
+ births[i] = index / rate;
80
+ xs[i] = source == 0 ? width * .5f : unit() * width;
81
+ ys[i] = source == 1 ? 0 : source == 2 ? height : height * .5f;
82
+ double direction = Math.toRadians(angle + variation(spread * .5f));
83
+ float speed = velocity + variation(velocityRange);
84
+ vxs[i] = (float)Math.cos(direction) * speed;
85
+ vys[i] = (float)Math.sin(direction) * speed;
86
+ rotations[i] = shape == 1 ? unit() * 360 : 0;
87
+ spins[i] = spin + variation(spinRange);
88
+ colors[i] = palette[(int)((index - 1) % palette.length)];
89
+ emitted++;
90
+ }
91
+ peak = Math.max(peak, live);
92
+ }
93
+
94
+ public float x(int i) { return xs[i] + vxs[i] * (float)(time - births[i]); }
95
+ public float y(int i) { float age = (float)(time - births[i]); return ys[i] + vys[i] * age + gravity * age * age * .5f; }
96
+ public float rotation(int i) { return rotations[i] + spins[i] * (float)(time - births[i]); }
97
+ public int color(int i) {
98
+ int color = colors[i];
99
+ if (!fade) return color;
100
+ int alpha = Math.round((color >>> 24) * (float)Math.max(0, 1 - (time - births[i]) / lifetime));
101
+ return (color & 0x00ffffff) | (alpha << 24);
102
+ }
103
+ public boolean finished() { return time >= duration + lifetime || (time >= duration && live == 0); }
104
+ public int live() { return live; }
105
+ public int peak() { return peak; }
106
+ public int capacity() { return births == null ? 0 : births.length; }
107
+ public long emitted() { return emitted; }
108
+ public long skipped() { return skipped; }
109
+ public double time() { return time; }
110
+ public void clear() {
111
+ live = 0;
112
+ births = null; xs = ys = vxs = vys = rotations = spins = null; colors = null;
113
+ }
114
+ }
@@ -19,3 +19,5 @@
19
19
  -keep class org.rafaelaugusto.jolt.PhotoZoom$* { *; }
20
20
  -keep class org.rafaelaugusto.jolt.GradientLayer { *; }
21
21
  -keep class org.rafaelaugusto.jolt.GradientLayer$* { *; }
22
+ -keep class org.rafaelaugusto.jolt.ParticleEmitter { *; }
23
+ -keep class org.rafaelaugusto.jolt.ParticleEmitter$* { *; }
@@ -1,4 +1,52 @@
1
1
  declare namespace org.rafaelaugusto.jolt {
2
+ class ParticleEmitter {
3
+ constructor(
4
+ target: android.view.View,
5
+ overlay: boolean,
6
+ colors: native.Array<number>,
7
+ shape: number,
8
+ source: number,
9
+ rate: number,
10
+ lifetimeMs: number,
11
+ size: number,
12
+ velocity: number,
13
+ velocityRange: number,
14
+ angle: number,
15
+ spread: number,
16
+ gravity: number,
17
+ spin: number,
18
+ spinRange: number,
19
+ fade: boolean,
20
+ durationMs: number,
21
+ listener: ParticleEmitter.Listener,
22
+ )
23
+ start(): void
24
+ refresh(cornerPixels: number): void
25
+ stop(): void
26
+ static getActiveCount(): number
27
+ isStopped(): boolean
28
+ getLiveCount(): number
29
+ getPeakCount(): number
30
+ getCapacity(): number
31
+ getEmittedCount(): number
32
+ getSkippedCount(): number
33
+ getFrameCount(): number
34
+ getDrawCount(): number
35
+ getWorkNanos(): number
36
+ getDrawNanos(): number
37
+ getDensity(): number
38
+ getTimeSeconds(): number
39
+ isObserving(): boolean
40
+ getOriginInWindow(): native.Array<number>
41
+ getParticle(index: number): native.Array<number>
42
+ getFrameIntervals(): native.Array<number>
43
+ getBounds(): android.graphics.Rect
44
+ }
45
+ namespace ParticleEmitter {
46
+ class Listener {
47
+ constructor(implementation: { onEnd(): void })
48
+ }
49
+ }
2
50
  class PhotoZoom {
3
51
  constructor(
4
52
  image: android.widget.ImageView,
@@ -116,6 +164,14 @@ declare namespace org.rafaelaugusto.jolt {
116
164
  reverse: boolean,
117
165
  listener: GradientLayer.Listener,
118
166
  ): void
167
+ control(elapsed: number, paused: boolean): void
168
+ isPlaying(): boolean
169
+ getGeometry(): native.Array<number>
170
+ getCorner(): number
171
+ getStops(): native.Array<number>
172
+ getColors(): native.Array<number>
173
+ getSampleCount(): number
174
+ dispose(): void
119
175
  cancel(): void
120
176
  }
121
177
  namespace GradientLayer {
@@ -209,3 +265,40 @@ declare namespace org.rafaelaugusto.jolt {
209
265
  }
210
266
  }
211
267
  }
268
+
269
+ declare namespace org.rafaelaugusto.jolt {
270
+ class PropertySink extends java.lang.Object {
271
+ static scalar(target: any, getter: string, setter: string): PropertySink
272
+ getDimensions(): number
273
+ isAlive(): boolean
274
+ isCompiled(): boolean
275
+ read(): native.Array<number>
276
+ write(values: native.Array<number>): void
277
+ dispose(): void
278
+ }
279
+ class MapLibrePropertySink extends PropertySink {
280
+ constructor(target: any, camera: boolean)
281
+ }
282
+ class PropertyPlayer extends java.lang.Object {
283
+ constructor(
284
+ sink: PropertySink,
285
+ times: native.Array<number>,
286
+ values: native.Array<number>,
287
+ finalValue: native.Array<number>,
288
+ duration: number,
289
+ delay: number,
290
+ repeat: number,
291
+ reverse: boolean,
292
+ listener: PropertyPlayer.Listener,
293
+ )
294
+ static activeCount(): number
295
+ getFrames(): number
296
+ control(time: number, paused: boolean): void
297
+ cancel(): void
298
+ }
299
+ namespace PropertyPlayer {
300
+ class Listener extends java.lang.Object {
301
+ constructor(implementation: { onEnd(completed: boolean): void })
302
+ }
303
+ }
304
+ }
package/src/animate.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  detachBindings,
30
30
  isMotionValue,
31
31
  type MotionValue,
32
+ type ValueAnimationOptions,
32
33
  } from './motion-value.js'
33
34
  import { resolveHaptic, type Haptic } from './haptics.js'
34
35
  import { layerable } from './mixer.js'
@@ -77,8 +78,8 @@ interface Options {
77
78
  export function animate(
78
79
  value: MotionValue,
79
80
  to: number | readonly (number | null)[],
80
- transition?: Transition | SpringName,
81
- ): Animation
81
+ transition?: ValueAnimationOptions | SpringName,
82
+ ): PlaybackAnimation
82
83
  export function animate(
83
84
  target: Target | readonly Target[],
84
85
  animation: Keyframes | PresetName,
@@ -92,7 +93,7 @@ export function animate(
92
93
  if (isMotionValue(target))
93
94
  return asRoot(target).animate(
94
95
  animation as number | readonly (number | null)[],
95
- options as Transition | SpringName | undefined,
96
+ options as ValueAnimationOptions | SpringName | undefined,
96
97
  )
97
98
  return prepareAnimation(
98
99
  target as Target | readonly Target[],
@@ -237,7 +238,7 @@ export function decay(
237
238
  * Stops all or selected properties at their current values. `scale` stops both axes.
238
239
  * A motion value stops where it is, and so do the views bound to it.
239
240
  */
240
- export function stop(value: MotionValue): void
241
+ export function stop(value: MotionValue, options?: { layer?: string }): void
241
242
  export function stop(
242
243
  target: Target | readonly Target[],
243
244
  properties?: readonly KeyframeProperty[],
@@ -245,11 +246,16 @@ export function stop(
245
246
  ): void
246
247
  export function stop(
247
248
  target: Target | readonly Target[] | MotionValue,
248
- properties?: readonly KeyframeProperty[],
249
+ properties?: readonly KeyframeProperty[] | { layer?: string },
249
250
  options?: { layer?: string },
250
251
  ) {
251
- if (isMotionValue(target)) return asRoot(target).stop()
252
- const expanded = properties?.flatMap<Property>((property) =>
252
+ if (isMotionValue(target))
253
+ return asRoot(target).stop((properties as { layer?: string } | undefined)?.layer)
254
+ if (properties && !Array.isArray(properties))
255
+ throw new TypeError('View properties must be an array.')
256
+ const expanded = (
257
+ properties as readonly KeyframeProperty[] | undefined
258
+ )?.flatMap<Property>((property) =>
253
259
  property === 'scale' ? ['scaleX', 'scaleY'] : [property],
254
260
  )
255
261
  for (const view of toViews(target as Target | readonly Target[]))
package/src/drag.ts CHANGED
@@ -247,7 +247,7 @@ class DragController implements Drag {
247
247
  }
248
248
  get position(): DragPoint {
249
249
  const value = this.config.value
250
- if (value) return { x: 0, y: 0, [this.config.axes[0]!]: value.raw() }
250
+ if (value) return { x: 0, y: 0, [this.config.axes[0]!]: value.base() }
251
251
  return { x: currentValue(this.view, 'x'), y: currentValue(this.view, 'y') }
252
252
  }
253
253
  cancel = () => {
@@ -338,7 +338,7 @@ class DragController implements Drag {
338
338
  this.blocked = false
339
339
  const value = this.config.value
340
340
  if (value) {
341
- value.stop()
341
+ value.stopBase()
342
342
  this.origin = this.position
343
343
  } else {
344
344
  // Claim just the enabled axes, preserving the native presentation on takeover.
@@ -385,7 +385,7 @@ class DragController implements Drag {
385
385
  : clampDrag(raw, curve.bounds)
386
386
  if (axis === 'x') vx = input.velocityX * resistance
387
387
  else vy = input.velocityY * resistance
388
- if (this.config.value) this.config.value.set(position[axis])
388
+ if (this.config.value) this.config.value.setBase(position[axis])
389
389
  else {
390
390
  stopBase(this.view, [axis])
391
391
  setBase(this.view, axis, position[axis])
@@ -28,8 +28,10 @@ export function androidGradient(
28
28
  }
29
29
  let stack: android.graphics.drawable.LayerDrawable | undefined
30
30
  let queued = false
31
+ let removed = false
31
32
 
32
33
  const wrap = () => {
34
+ if (removed) return
33
35
  const native = view.nativeViewProtected as android.view.View | undefined
34
36
  if (!native) return
35
37
  const corner = typeof view.borderRadius === 'number' ? view.borderRadius : 0
@@ -87,6 +89,9 @@ export function androidGradient(
87
89
  }),
88
90
  )
89
91
  return {
92
+ control(elapsed, paused) {
93
+ if (active) layer.control(Math.round(elapsed), paused)
94
+ },
90
95
  cancel() {
91
96
  if (!active) return
92
97
  active = false
@@ -96,10 +101,12 @@ export function androidGradient(
96
101
  }
97
102
  },
98
103
  remove() {
104
+ if (removed) return
105
+ removed = true
99
106
  view.off('loaded', wrap)
100
107
  view.style.off('backgroundInternalChange', rewrap)
101
108
  view.style.off('borderTopLeftRadiusChange', rewrap)
102
- layer.cancel()
109
+ layer.dispose()
103
110
  const native = view.nativeViewProtected as android.view.View | undefined
104
111
  if (native && stack && native.getBackground() === stack)
105
112
  native.setBackground(stack.getDrawable(0))
@@ -0,0 +1,118 @@
1
+ import { Application, Color, Utils, type View } from '@nativescript/core'
2
+ import { onReducedMotion, reducedMotion } from '../config.js'
3
+ import type { ParticleSettings } from '../particles.js'
4
+
5
+ type NativeEmitter = org.rafaelaugusto.jolt.ParticleEmitter
6
+ const live = new WeakMap<View, { emitter?: NativeEmitter; burst?: NativeEmitter }>()
7
+
8
+ /** Internal diagnostic seam for native conformance, not a public animation API. */
9
+ export function currentAndroidParticles(view: View, overlay = false) {
10
+ return live.get(view)?.[overlay ? 'burst' : 'emitter']
11
+ }
12
+
13
+ export function androidParticles(
14
+ view: View,
15
+ settings: ParticleSettings,
16
+ overlay: boolean,
17
+ owners: WeakMap<View, () => void>,
18
+ ): () => void {
19
+ let stopped = false,
20
+ queued = false
21
+ let emitter: NativeEmitter | undefined,
22
+ target: android.view.View | undefined,
23
+ unwatch: (() => void) | undefined
24
+ const key = overlay ? 'burst' : 'emitter'
25
+ const stop = () => {
26
+ if (stopped) return
27
+ stopped = true
28
+ view.off('loaded', schedule)
29
+ view.off('layoutChanged', schedule)
30
+ view.off('unloaded', stop)
31
+ view.style.off('borderTopLeftRadiusChange', schedule)
32
+ Application.off(Application.suspendEvent, stop)
33
+ unwatch?.()
34
+ unwatch = undefined
35
+ if (owners.get(view) === stop) owners.delete(view)
36
+ const active = live.get(view)
37
+ if (active && active[key] === emitter) {
38
+ delete active[key]
39
+ if (!active.emitter && !active.burst) live.delete(view)
40
+ }
41
+ const native = emitter
42
+ emitter = target = undefined
43
+ native?.stop()
44
+ }
45
+ const update = () => {
46
+ if (!view.isLoaded) return
47
+ if (reducedMotion()) return stop()
48
+ const native = view.nativeViewProtected as android.view.View | undefined
49
+ if (target && target !== native) return stop()
50
+ if (emitter) {
51
+ emitter.refresh(
52
+ (typeof view.borderRadius === 'number' ? view.borderRadius : 0) *
53
+ Utils.layout.getDisplayDensity(),
54
+ )
55
+ return
56
+ }
57
+ if (!native?.getWindowToken() || native.getWidth() <= 0 || native.getHeight() <= 0)
58
+ return
59
+ const colors = Array.create('int', settings.colors.length)
60
+ settings.colors.forEach((color, i) => {
61
+ colors[i] = new Color(color).argb | 0
62
+ })
63
+ target = native
64
+ emitter = new org.rafaelaugusto.jolt.ParticleEmitter(
65
+ native,
66
+ overlay,
67
+ colors,
68
+ settings.shape === 'circle' ? 0 : settings.shape === 'rectangle' ? 1 : 2,
69
+ settings.source === 'center' ? 0 : settings.source === 'top' ? 1 : 2,
70
+ settings.rate,
71
+ settings.lifetime,
72
+ settings.size,
73
+ settings.velocity,
74
+ settings.velocityRange,
75
+ settings.angle,
76
+ settings.spread,
77
+ settings.gravity,
78
+ settings.spin,
79
+ settings.spinRange,
80
+ settings.fadeOut,
81
+ settings.duration ?? -1,
82
+ new org.rafaelaugusto.jolt.ParticleEmitter.Listener({ onEnd: stop }),
83
+ )
84
+ const active = live.get(view) ?? {}
85
+ active[key] = emitter
86
+ live.set(view, active)
87
+ emitter.refresh(
88
+ (typeof view.borderRadius === 'number' ? view.borderRadius : 0) *
89
+ Utils.layout.getDisplayDensity(),
90
+ )
91
+ emitter.start()
92
+ }
93
+ function schedule() {
94
+ if (stopped || queued) return
95
+ queued = true
96
+ void Promise.resolve().then(() => {
97
+ queued = false
98
+ if (stopped) return
99
+ try {
100
+ update()
101
+ } catch (error) {
102
+ stop()
103
+ throw error
104
+ }
105
+ })
106
+ }
107
+ owners.set(view, stop)
108
+ view.on('loaded', schedule)
109
+ view.on('layoutChanged', schedule)
110
+ view.on('unloaded', stop)
111
+ view.style.on('borderTopLeftRadiusChange', schedule)
112
+ Application.on(Application.suspendEvent, stop)
113
+ unwatch = onReducedMotion((reduced) => {
114
+ if (reduced) stop()
115
+ })
116
+ schedule()
117
+ return stop
118
+ }
@@ -12,6 +12,7 @@ import {
12
12
  import { Application, Utils, type View } from '@nativescript/core'
13
13
  import type { NumericProperty } from '../properties.js'
14
14
  import type { Driver } from './index.js'
15
+ import { frame } from './frame.js'
15
16
  import { isColorProperty } from '../color.js'
16
17
  import {
17
18
  playAndroidColor,
@@ -109,6 +110,7 @@ export function createAndroidDriver(): Driver {
109
110
 
110
111
  return {
111
112
  now: () => android.os.SystemClock.uptimeMillis(),
113
+ frame,
112
114
  clock,
113
115
  control(_view, handle, elapsed, paused, command) {
114
116
  ;(handle as Handle).control(elapsed, paused, command)
@@ -0,0 +1,10 @@
1
+ import {
2
+ cancelAnimationFrame,
3
+ requestAnimationFrame,
4
+ } from '@nativescript/core/animation-frame'
5
+
6
+ /** NativeScript schedules subsequent frames with CADisplayLink / Choreographer. */
7
+ export function frame(callback: () => void): () => void {
8
+ const id = requestAnimationFrame(callback)
9
+ return () => cancelAnimationFrame(id)
10
+ }
@@ -15,6 +15,8 @@ export interface LayoutFrame {
15
15
  export interface Driver {
16
16
  /** Milliseconds, on the clock the platform's animations run on. */
17
17
  now(): number
18
+ /** One native animation frame; returns an idempotent cancellation function. */
19
+ frame(callback: () => void): () => void
18
20
  /** Starts the track at `startedAt + track.delay` and calls `onEnd` once, however it ends. */
19
21
  play(
20
22
  view: View,
@@ -47,7 +47,8 @@ export function iosGradient(view: View, type: GradientType): GradientPainter {
47
47
  CATransaction.setDisableActions(true)
48
48
  if (layer.superlayer !== host.layer) host.layer.insertSublayerAtIndex(layer, 0)
49
49
  layer.frame = host.layer.bounds
50
- layer.cornerRadius = host.layer.cornerRadius
50
+ layer.cornerRadius =
51
+ typeof view.borderRadius === 'number' ? view.borderRadius : host.layer.cornerRadius
51
52
  if (current) {
52
53
  const model = values(current)
53
54
  layer.colors = model.colors
@@ -59,9 +60,13 @@ export function iosGradient(view: View, type: GradientType): GradientPainter {
59
60
  }
60
61
  view.on('loaded', attach)
61
62
  view.on('layoutChanged', attach)
63
+ view.style.on('borderTopLeftRadiusChange', attach)
62
64
 
63
65
  return {
64
66
  paint(frame) {
67
+ layer.speed = 1
68
+ layer.timeOffset = 0
69
+ layer.beginTime = 0
65
70
  current = frame
66
71
  generation++
67
72
  for (const key of keys) layer.removeAnimationForKey(`jolt:gradient:${key}`)
@@ -73,7 +78,14 @@ export function iosGradient(view: View, type: GradientType): GradientPainter {
73
78
  attach()
74
79
  const sampled = frames.map(values)
75
80
  const now = CACurrentMediaTime()
81
+ const parentTime = () =>
82
+ layer.superlayer?.convertTimeFromLayer(CACurrentMediaTime(), null) ??
83
+ CACurrentMediaTime()
76
84
  CATransaction.begin()
85
+ CATransaction.setDisableActions(true)
86
+ layer.speed = 1
87
+ layer.timeOffset = 0
88
+ layer.beginTime = layer.superlayer?.convertTimeFromLayer(now, null) ?? now
77
89
  if (timing.repeat !== Infinity)
78
90
  CATransaction.setCompletionBlock(() => {
79
91
  if (instance === generation) onEnd(true)
@@ -83,7 +95,7 @@ export function iosGradient(view: View, type: GradientType): GradientPainter {
83
95
  animation.values = NSArray.arrayWithArray(sampled.map((value) => value[key]))
84
96
  animation.keyTimes = NSArray.arrayWithArray(times as number[])
85
97
  animation.duration = Math.max(timing.duration, 1) / 1000
86
- animation.beginTime = layer.convertTimeFromLayer(now + timing.delay / 1000, null)
98
+ animation.beginTime = Math.max(0.000001, timing.delay / 1000)
87
99
  animation.fillMode = kCAFillModeBackwards
88
100
  if (timing.repeat > 0) {
89
101
  animation.autoreverses = timing.reverse
@@ -95,6 +107,15 @@ export function iosGradient(view: View, type: GradientType): GradientPainter {
95
107
  }
96
108
  CATransaction.commit()
97
109
  return {
110
+ control(elapsed, paused) {
111
+ if (instance !== generation) return
112
+ CATransaction.begin()
113
+ CATransaction.setDisableActions(true)
114
+ layer.speed = paused ? 0 : 1
115
+ layer.timeOffset = paused ? elapsed / 1000 : 0
116
+ layer.beginTime = paused ? 0 : parentTime() - elapsed / 1000
117
+ CATransaction.commit()
118
+ },
98
119
  cancel() {
99
120
  if (instance !== generation) return
100
121
  generation++
@@ -107,6 +128,7 @@ export function iosGradient(view: View, type: GradientType): GradientPainter {
107
128
  generation++
108
129
  view.off('loaded', attach)
109
130
  view.off('layoutChanged', attach)
131
+ view.style.off('borderTopLeftRadiusChange', attach)
110
132
  layer.removeAllAnimations()
111
133
  layer.removeFromSuperlayer()
112
134
  },
package/src/driver/ios.ts CHANGED
@@ -13,6 +13,7 @@ import { playIOSColor, presentedIOSColor } from './ios-color.js'
13
13
  import { after, type Driver } from './index.js'
14
14
  import { hasInteractionSurface } from '../interaction-surface.js'
15
15
  import { retainInteractiveLayout } from './ios-layout.js'
16
+ import { frame } from './frame.js'
16
17
 
17
18
  interface Handle {
18
19
  stop(): void
@@ -59,6 +60,7 @@ function ensurePerspective(view: View, layers: CALayer[]) {
59
60
  export function createIOSDriver(): Driver {
60
61
  return {
61
62
  now: () => CACurrentMediaTime() * 1000,
63
+ frame,
62
64
 
63
65
  play(view, track, startedAt, onEnd): Handle {
64
66
  if (isDecorationProperty(track.property))
package/src/engine.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import {
2
2
  createClock,
3
+ registerPlayback,
3
4
  validateProgress,
4
5
  type PlaybackAnimation,
5
6
  type PlaybackClock,
6
7
  type PlaybackCommand,
7
8
  } from './playback.js'
8
- import type { View } from '@nativescript/core'
9
+ import { Application, type View } from '@nativescript/core'
9
10
  import { getDriver } from './driver/index.js'
10
11
  import { playHaptics, type HapticOptions } from './haptics.js'
11
12
  import { readValue, writeValue, type Property } from './properties.js'
@@ -109,7 +110,9 @@ export class ViewAnimation implements PlaybackAnimation {
109
110
  private readyListener: (() => void) | undefined
110
111
  private readonly endless: boolean
111
112
  private readonly timingListeners = new Set<() => void>()
113
+ private readonly playbackChanged: () => void
112
114
  private timingChanged() {
115
+ this.playbackChanged()
113
116
  for (const callback of [...this.timingListeners]) callback()
114
117
  }
115
118
 
@@ -142,6 +145,7 @@ export class ViewAnimation implements PlaybackAnimation {
142
145
  if (this.settled) return
143
146
  if (!this.started) {
144
147
  this.pendingProgress = value
148
+ this.timingChanged()
145
149
  return
146
150
  }
147
151
  this.seekTime(this.duration * value)
@@ -280,7 +284,12 @@ export class ViewAnimation implements PlaybackAnimation {
280
284
  getDriver().stop(this.view, handle)
281
285
  this.playEntry(entry, elapsed)
282
286
  }
283
- private unloaded = () => this.stop()
287
+ private unloaded = () => {
288
+ // NativeScript unloads the root view when iOS enters the background.
289
+ // An explicitly paused owner can resume after that temporary unload.
290
+ if (this._paused && Application.suspended) return
291
+ this.stop()
292
+ }
284
293
 
285
294
  constructor(
286
295
  readonly view: View,
@@ -295,6 +304,18 @@ export class ViewAnimation implements PlaybackAnimation {
295
304
  !Number.isFinite(plan.transition.curve.duration)),
296
305
  )
297
306
  this.finished = new Promise((resolve) => (this.resolve = resolve))
307
+ this.playbackChanged = registerPlayback(this, () => ({
308
+ position: this.pendingProgress,
309
+ time:
310
+ this.pendingProgress === undefined
311
+ ? this.time()
312
+ : this.pendingProgress * this.duration,
313
+ duration: this.duration,
314
+ paused: this._paused,
315
+ ready: this.ready,
316
+ settled: this.settled,
317
+ completed: this.settled && this.complete,
318
+ }))
298
319
  setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
299
320
  setArrivalTiming(this, {
300
321
  read: () => ({
@@ -436,11 +457,17 @@ export class ViewAnimation implements PlaybackAnimation {
436
457
  createClock(() => driver.now())
437
458
  if (this._paused) this.clock.pause()
438
459
  this.view.on('unloaded', this.unloaded)
439
- this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now, () =>
440
- this.time(),
441
- )
442
- if (this._paused) this.haptics?.pause()
443
460
  for (const entry of this.entries) this.playEntry(entry)
461
+ // Register discrete feedback after the visual animators. Android dispatches
462
+ // these callbacks in registration order within a frame: feedback should see
463
+ // that frame's updated pose, not the preceding frame's position.
464
+ // Scale-zero native playback may already have completed synchronously.
465
+ if (!this.settled) {
466
+ this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now, () =>
467
+ this.time(),
468
+ )
469
+ if (this._paused) this.haptics?.pause()
470
+ }
444
471
  if (this.pendingProgress !== undefined) {
445
472
  const progress = this.pendingProgress
446
473
  this.pendingProgress = undefined