@rafaelaugusto/jolt 0.1.0-beta.13 → 0.1.0-beta.15

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 +14 -9
  2. package/dist/driver/android-gradient.d.ts.map +1 -1
  3. package/dist/driver/android-gradient.js +11 -1
  4. package/dist/driver/android-gradient.js.map +1 -1
  5. package/dist/driver/android-particles.d.ts +6 -0
  6. package/dist/driver/android-particles.d.ts.map +1 -0
  7. package/dist/driver/android-particles.js +93 -0
  8. package/dist/driver/android-particles.js.map +1 -0
  9. package/dist/driver/ios-gradient.d.ts.map +1 -1
  10. package/dist/driver/ios-gradient.js +24 -2
  11. package/dist/driver/ios-gradient.js.map +1 -1
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +9 -3
  14. package/dist/engine.js.map +1 -1
  15. package/dist/gradient-binding.d.ts +38 -0
  16. package/dist/gradient-binding.d.ts.map +1 -0
  17. package/dist/gradient-binding.js +128 -0
  18. package/dist/gradient-binding.js.map +1 -0
  19. package/dist/gradient.d.ts +19 -4
  20. package/dist/gradient.d.ts.map +1 -1
  21. package/dist/gradient.js +331 -70
  22. package/dist/gradient.js.map +1 -1
  23. package/dist/haptics.d.ts.map +1 -1
  24. package/dist/haptics.js +101 -28
  25. package/dist/haptics.js.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/motion-scope.d.ts +3 -1
  31. package/dist/motion-scope.d.ts.map +1 -1
  32. package/dist/motion-scope.js +14 -1
  33. package/dist/motion-scope.js.map +1 -1
  34. package/dist/motion-value.d.ts +17 -0
  35. package/dist/motion-value.d.ts.map +1 -1
  36. package/dist/motion-value.js +38 -0
  37. package/dist/motion-value.js.map +1 -1
  38. package/dist/native/index.d.ts +2 -0
  39. package/dist/native/index.d.ts.map +1 -1
  40. package/dist/native/index.js +2 -0
  41. package/dist/native/index.js.map +1 -1
  42. package/dist/particles.d.ts +5 -2
  43. package/dist/particles.d.ts.map +1 -1
  44. package/dist/particles.js +16 -10
  45. package/dist/particles.js.map +1 -1
  46. package/dist/scene-appearance.d.ts +14 -0
  47. package/dist/scene-appearance.d.ts.map +1 -0
  48. package/dist/scene-appearance.js +68 -0
  49. package/dist/scene-appearance.js.map +1 -0
  50. package/dist/scene-camera.d.ts +5 -0
  51. package/dist/scene-camera.d.ts.map +1 -0
  52. package/dist/scene-camera.js +23 -0
  53. package/dist/scene-camera.js.map +1 -0
  54. package/dist/size-layout.d.ts +19 -0
  55. package/dist/size-layout.d.ts.map +1 -0
  56. package/dist/size-layout.js +216 -0
  57. package/dist/size-layout.js.map +1 -0
  58. package/dist/size.d.ts +19 -0
  59. package/dist/size.d.ts.map +1 -0
  60. package/dist/size.js +251 -0
  61. package/dist/size.js.map +1 -0
  62. package/dist/types.d.ts +1 -1
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/vue/index.d.ts.map +1 -1
  65. package/dist/vue/index.js +28 -5
  66. package/dist/vue/index.js.map +1 -1
  67. package/package.json +1 -1
  68. package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +30 -1
  69. package/platforms/android/java/org/rafaelaugusto/jolt/ParticleEmitter.java +192 -0
  70. package/platforms/android/java/org/rafaelaugusto/jolt/ParticleSimulation.java +114 -0
  71. package/platforms/android/proguard-rules.pro +2 -0
  72. package/src/android-plugin.d.ts +56 -0
  73. package/src/driver/android-gradient.ts +8 -1
  74. package/src/driver/android-particles.ts +118 -0
  75. package/src/driver/ios-gradient.ts +24 -2
  76. package/src/engine.ts +10 -4
  77. package/src/gradient-binding.ts +158 -0
  78. package/src/gradient.ts +376 -91
  79. package/src/haptics.ts +106 -28
  80. package/src/index.ts +3 -0
  81. package/src/ios-plugin.d.ts +10 -0
  82. package/src/motion-scope.ts +23 -2
  83. package/src/motion-value.ts +43 -0
  84. package/src/native/index.ts +8 -0
  85. package/src/particles.ts +18 -17
  86. package/src/scene-appearance.ts +92 -0
  87. package/src/scene-camera.ts +30 -0
  88. package/src/size-layout.ts +237 -0
  89. package/src/size.ts +302 -0
  90. package/src/types.ts +1 -1
  91. 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 {
@@ -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
+ }
@@ -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/engine.ts CHANGED
@@ -457,11 +457,17 @@ export class ViewAnimation implements PlaybackAnimation {
457
457
  createClock(() => driver.now())
458
458
  if (this._paused) this.clock.pause()
459
459
  this.view.on('unloaded', this.unloaded)
460
- this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now, () =>
461
- this.time(),
462
- )
463
- if (this._paused) this.haptics?.pause()
464
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
+ }
465
471
  if (this.pendingProgress !== undefined) {
466
472
  const progress = this.pendingProgress
467
473
  this.pendingProgress = undefined
@@ -0,0 +1,158 @@
1
+ import { parseColor, type ColorValue } from './color.js'
2
+ import { reducedMotion } from './config.js'
3
+ import {
4
+ isDerived,
5
+ valueSource,
6
+ type DerivedValue,
7
+ type DerivedColor,
8
+ } from './motion-value.js'
9
+ import type { GradientFrame } from './gradient.js'
10
+
11
+ export interface GradientBindings {
12
+ colors?: readonly (ColorValue | DerivedColor)[]
13
+ stops?: readonly (number | DerivedValue)[]
14
+ angle?: number | DerivedValue
15
+ center?: { readonly x?: number | DerivedValue; readonly y?: number | DerivedValue }
16
+ radius?: number | DerivedValue
17
+ }
18
+
19
+ export type GradientComponent =
20
+ 'angle' | 'x' | 'y' | 'radius' | `color:${number}` | `stop:${number}`
21
+ export type GradientSource = ReturnType<typeof valueSource>
22
+ export type GradientComponentValue = number | GradientSource
23
+ export type GradientPatch = Map<GradientComponent, GradientComponentValue>
24
+
25
+ export function cloneFrame(frame: GradientFrame): GradientFrame {
26
+ return { ...frame, colors: [...frame.colors], stops: [...frame.stops] }
27
+ }
28
+
29
+ export function put(frame: GradientFrame, key: GradientComponent, value: number) {
30
+ if (key.startsWith('color:')) frame.colors[Number(key.slice(6))] = value
31
+ else if (key.startsWith('stop:')) frame.stops[Number(key.slice(5))] = value
32
+ else frame[key as 'angle' | 'x' | 'y' | 'radius'] = value
33
+ }
34
+
35
+ export function take(frame: GradientFrame, key: GradientComponent) {
36
+ if (key.startsWith('color:')) return frame.colors[Number(key.slice(6))]!
37
+ if (key.startsWith('stop:')) return frame.stops[Number(key.slice(5))]!
38
+ return frame[key as 'angle' | 'x' | 'y' | 'radius']
39
+ }
40
+
41
+ /** Validate structural/static inputs before touching any existing owner. */
42
+ export function gradientPatch(values: GradientBindings, count: number): GradientPatch {
43
+ if (!values || typeof values !== 'object' || Array.isArray(values))
44
+ throw new TypeError('Gradient values must be an object.')
45
+ const patch: GradientPatch = new Map()
46
+ const add = (key: GradientComponent, value: unknown, color = false) => {
47
+ if (value === undefined) return
48
+ if (isDerived(value)) patch.set(key, valueSource(value, color))
49
+ else if (color) patch.set(key, parseColor(value as ColorValue))
50
+ else {
51
+ if (typeof value !== 'number' || !Number.isFinite(value))
52
+ throw new RangeError(`Invalid gradient ${key}: ${String(value)}.`)
53
+ if (key === 'radius' && value < 0)
54
+ throw new RangeError('Gradient radius must not be negative.')
55
+ if (key.startsWith('stop:') && (value < 0 || value > 1))
56
+ throw new RangeError('Gradient stops must be from 0 to 1.')
57
+ patch.set(key, value)
58
+ }
59
+ }
60
+ if (values.colors !== undefined) {
61
+ if (
62
+ !Array.isArray(values.colors) ||
63
+ values.colors.length < 2 ||
64
+ values.colors.length > 8
65
+ )
66
+ throw new RangeError('A gradient needs between two and eight colors.')
67
+ count = values.colors.length
68
+ Array.from(values.colors).forEach((value, i) => {
69
+ if (value === undefined) throw new TypeError('Gradient colors cannot be empty.')
70
+ add(`color:${i}`, value, true)
71
+ })
72
+ }
73
+ if (values.stops !== undefined) {
74
+ if (!Array.isArray(values.stops) || values.stops.length !== count)
75
+ throw new RangeError('Gradient stops need one value per color.')
76
+ Array.from(values.stops).forEach((value, i) => {
77
+ if (value === undefined) throw new TypeError('Gradient stops cannot be empty.')
78
+ add(`stop:${i}`, value)
79
+ })
80
+ if (
81
+ values.stops.every((value) => typeof value === 'number') &&
82
+ values.stops.some((value, i) => i > 0 && value < (values.stops![i - 1] as number))
83
+ )
84
+ throw new RangeError('Gradient stops must be ordered without going back.')
85
+ } else if (values.colors !== undefined) {
86
+ // Preserve the concrete API's rule: replacing colors resets unspecified stops.
87
+ values.colors.forEach((_, i) => patch.set(`stop:${i}`, i / (count - 1)))
88
+ }
89
+ add('angle', values.angle)
90
+ if (
91
+ values.center !== undefined &&
92
+ (!values.center || typeof values.center !== 'object')
93
+ )
94
+ throw new TypeError('Gradient center needs x/y components.')
95
+ add('x', values.center?.x)
96
+ add('y', values.center?.y)
97
+ add('radius', values.radius)
98
+ return patch
99
+ }
100
+
101
+ export interface GradientBase {
102
+ at(delta: number): GradientFrame
103
+ remaining: number
104
+ elapsed(): number
105
+ }
106
+
107
+ /** Native windows share a source clock. Only setup/commands/window boundaries enter JS. */
108
+ export function gradientProgram(base: GradientBase, bindings: GradientPatch) {
109
+ const roots = new Map<object, ReturnType<GradientSource['snapshot']>>()
110
+ for (const value of bindings.values())
111
+ if (typeof value !== 'number' && !roots.has(value.identity))
112
+ roots.set(value.identity, value.snapshot())
113
+ const moving = [...roots.values()].filter((state) => state.running)
114
+ const remaining = Math.max(
115
+ base.remaining,
116
+ ...moving.map((state) => state.track.end - state.elapsed),
117
+ 0,
118
+ )
119
+ const duration = Math.min(4000, remaining)
120
+ const anchor = moving[0]
121
+ const elapsed = anchor
122
+ ? () => Math.max(0, anchor.time() - anchor.elapsed)
123
+ : base.elapsed
124
+ const reduced = reducedMotion()
125
+ const at = (delta: number) => {
126
+ const frame = cloneFrame(base.at(delta))
127
+ for (const [key, source] of bindings) {
128
+ if (typeof source === 'number') {
129
+ put(frame, key, source)
130
+ continue
131
+ }
132
+ const state = roots.get(source.identity)!
133
+ const geometry = key === 'angle' || key === 'x' || key === 'y' || key === 'radius'
134
+ const input =
135
+ reduced && geometry && state.running
136
+ ? state.track.to
137
+ : state.running
138
+ ? state.track.valueAt(state.elapsed + delta)
139
+ : state.value
140
+ const value = source.read(input)
141
+ if (!Number.isFinite(value))
142
+ throw new RangeError(`Nonfinite gradient binding: ${key}.`)
143
+ put(frame, key, value)
144
+ }
145
+ // Project the complete vector without sorting colors. Spring overshoot may
146
+ // merge adjacent stops; their identity and order are retained.
147
+ frame.radius = Math.max(0, frame.radius)
148
+ frame.stops = frame.stops.map((stop) => Math.min(1, Math.max(0, stop)))
149
+ for (let i = 1; i < frame.stops.length; i++)
150
+ frame.stops[i] = Math.max(frame.stops[i - 1]!, frame.stops[i]!)
151
+ return frame
152
+ }
153
+ const current = at(0)
154
+ const steps = Math.max(1, Math.ceil((duration * 60) / 1000))
155
+ const times = Array.from({ length: steps + 1 }, (_, i) => i / steps)
156
+ const frames = times.map((time) => at(time * duration))
157
+ return { current, frames, times, duration, remaining, elapsed, at }
158
+ }