@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.
- package/README.md +14 -9
- package/dist/driver/android-gradient.d.ts.map +1 -1
- package/dist/driver/android-gradient.js +11 -1
- package/dist/driver/android-gradient.js.map +1 -1
- package/dist/driver/android-particles.d.ts +6 -0
- package/dist/driver/android-particles.d.ts.map +1 -0
- package/dist/driver/android-particles.js +93 -0
- package/dist/driver/android-particles.js.map +1 -0
- package/dist/driver/ios-gradient.d.ts.map +1 -1
- package/dist/driver/ios-gradient.js +24 -2
- package/dist/driver/ios-gradient.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +9 -3
- package/dist/engine.js.map +1 -1
- package/dist/gradient-binding.d.ts +38 -0
- package/dist/gradient-binding.d.ts.map +1 -0
- package/dist/gradient-binding.js +128 -0
- package/dist/gradient-binding.js.map +1 -0
- package/dist/gradient.d.ts +19 -4
- package/dist/gradient.d.ts.map +1 -1
- package/dist/gradient.js +331 -70
- package/dist/gradient.js.map +1 -1
- package/dist/haptics.d.ts.map +1 -1
- package/dist/haptics.js +101 -28
- package/dist/haptics.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/motion-scope.d.ts +3 -1
- package/dist/motion-scope.d.ts.map +1 -1
- package/dist/motion-scope.js +14 -1
- package/dist/motion-scope.js.map +1 -1
- package/dist/motion-value.d.ts +17 -0
- package/dist/motion-value.d.ts.map +1 -1
- package/dist/motion-value.js +38 -0
- package/dist/motion-value.js.map +1 -1
- package/dist/native/index.d.ts +2 -0
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +2 -0
- package/dist/native/index.js.map +1 -1
- package/dist/particles.d.ts +5 -2
- package/dist/particles.d.ts.map +1 -1
- package/dist/particles.js +16 -10
- package/dist/particles.js.map +1 -1
- package/dist/scene-appearance.d.ts +14 -0
- package/dist/scene-appearance.d.ts.map +1 -0
- package/dist/scene-appearance.js +68 -0
- package/dist/scene-appearance.js.map +1 -0
- package/dist/scene-camera.d.ts +5 -0
- package/dist/scene-camera.d.ts.map +1 -0
- package/dist/scene-camera.js +23 -0
- package/dist/scene-camera.js.map +1 -0
- package/dist/size-layout.d.ts +19 -0
- package/dist/size-layout.d.ts.map +1 -0
- package/dist/size-layout.js +216 -0
- package/dist/size-layout.js.map +1 -0
- package/dist/size.d.ts +19 -0
- package/dist/size.d.ts.map +1 -0
- package/dist/size.js +251 -0
- package/dist/size.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts.map +1 -1
- package/dist/vue/index.js +28 -5
- package/dist/vue/index.js.map +1 -1
- package/package.json +1 -1
- package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +30 -1
- package/platforms/android/java/org/rafaelaugusto/jolt/ParticleEmitter.java +192 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ParticleSimulation.java +114 -0
- package/platforms/android/proguard-rules.pro +2 -0
- package/src/android-plugin.d.ts +56 -0
- package/src/driver/android-gradient.ts +8 -1
- package/src/driver/android-particles.ts +118 -0
- package/src/driver/ios-gradient.ts +24 -2
- package/src/engine.ts +10 -4
- package/src/gradient-binding.ts +158 -0
- package/src/gradient.ts +376 -91
- package/src/haptics.ts +106 -28
- package/src/index.ts +3 -0
- package/src/ios-plugin.d.ts +10 -0
- package/src/motion-scope.ts +23 -2
- package/src/motion-value.ts +43 -0
- package/src/native/index.ts +8 -0
- package/src/particles.ts +18 -17
- package/src/scene-appearance.ts +92 -0
- package/src/scene-camera.ts +30 -0
- package/src/size-layout.ts +237 -0
- package/src/size.ts +302 -0
- package/src/types.ts +1 -1
- 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$* { *; }
|
package/src/android-plugin.d.ts
CHANGED
|
@@ -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.
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|