@rafaelaugusto/jolt 0.1.0-beta.13 → 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 (75) hide show
  1. package/README.md +6 -6
  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 +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/motion-scope.d.ts +1 -1
  30. package/dist/motion-scope.d.ts.map +1 -1
  31. package/dist/motion-scope.js +8 -1
  32. package/dist/motion-scope.js.map +1 -1
  33. package/dist/motion-value.d.ts +17 -0
  34. package/dist/motion-value.d.ts.map +1 -1
  35. package/dist/motion-value.js +38 -0
  36. package/dist/motion-value.js.map +1 -1
  37. package/dist/native/index.d.ts +1 -0
  38. package/dist/native/index.d.ts.map +1 -1
  39. package/dist/native/index.js +1 -0
  40. package/dist/native/index.js.map +1 -1
  41. package/dist/particles.d.ts +5 -2
  42. package/dist/particles.d.ts.map +1 -1
  43. package/dist/particles.js +16 -10
  44. package/dist/particles.js.map +1 -1
  45. package/dist/scene-appearance.d.ts +14 -0
  46. package/dist/scene-appearance.d.ts.map +1 -0
  47. package/dist/scene-appearance.js +68 -0
  48. package/dist/scene-appearance.js.map +1 -0
  49. package/dist/types.d.ts +1 -1
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/vue/index.d.ts.map +1 -1
  52. package/dist/vue/index.js +28 -5
  53. package/dist/vue/index.js.map +1 -1
  54. package/package.json +1 -1
  55. package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +30 -1
  56. package/platforms/android/java/org/rafaelaugusto/jolt/ParticleEmitter.java +192 -0
  57. package/platforms/android/java/org/rafaelaugusto/jolt/ParticleSimulation.java +114 -0
  58. package/platforms/android/proguard-rules.pro +2 -0
  59. package/src/android-plugin.d.ts +56 -0
  60. package/src/driver/android-gradient.ts +8 -1
  61. package/src/driver/android-particles.ts +118 -0
  62. package/src/driver/ios-gradient.ts +24 -2
  63. package/src/engine.ts +10 -4
  64. package/src/gradient-binding.ts +158 -0
  65. package/src/gradient.ts +376 -91
  66. package/src/haptics.ts +106 -28
  67. package/src/index.ts +1 -0
  68. package/src/ios-plugin.d.ts +7 -0
  69. package/src/motion-scope.ts +9 -2
  70. package/src/motion-value.ts +43 -0
  71. package/src/native/index.ts +7 -0
  72. package/src/particles.ts +18 -17
  73. package/src/scene-appearance.ts +92 -0
  74. package/src/types.ts +1 -1
  75. package/src/vue/index.ts +29 -5
package/src/haptics.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isIOS, type View } from '@nativescript/core'
1
+ import { isAndroid, isIOS, type View } from '@nativescript/core'
2
2
  import type { Driver } from './driver/index.js'
3
3
  import type { Track } from './track.js'
4
4
 
@@ -39,7 +39,69 @@ export function resolveHaptic(haptic: Haptic | undefined): HapticOptions | undef
39
39
 
40
40
  const PREPARE_AHEAD = 100
41
41
  const MAX_LATENESS = 100
42
- const COMPLETION_TOLERANCE = 20
42
+
43
+ interface Feedback {
44
+ active(): boolean
45
+ enabled(): boolean
46
+ prepare?(): void
47
+ fire(): void
48
+ }
49
+
50
+ function feedbackFor(
51
+ view: View,
52
+ style: HapticStyle,
53
+ driver: Driver,
54
+ ): Feedback | undefined {
55
+ if (isAndroid) {
56
+ if (typeof android === 'undefined' || driver.reducedMotion?.()) return
57
+ const native = view.nativeViewProtected as android.view.View | undefined
58
+ if (!native) return
59
+ const sdk = android.os.Build.VERSION.SDK_INT
60
+ const constants = android.view.HapticFeedbackConstants
61
+ const effect =
62
+ style === 'heavy'
63
+ ? constants.LONG_PRESS
64
+ : style === 'medium' && sdk >= 23
65
+ ? constants.CONTEXT_CLICK
66
+ : style === 'light' && sdk >= 21
67
+ ? constants.CLOCK_TICK
68
+ : constants.KEYBOARD_TAP
69
+ return {
70
+ // Animator scale zero must also silence opacity/color feedback when
71
+ // configure({ reducedMotion: 'never' }) keeps a logical track alive.
72
+ enabled: () => !driver.reducedMotion?.(),
73
+ active: () =>
74
+ view.isLoaded &&
75
+ view.nativeViewProtected === native &&
76
+ !!native.getWindowToken() &&
77
+ native.getWindowVisibility() === 0 &&
78
+ native.isShown() &&
79
+ native.hasWindowFocus(),
80
+ // No ignore-setting flags, forced vibration or permission. A false result
81
+ // means the system declined this request; do not retry with a stronger effect.
82
+ fire: () => {
83
+ native.performHapticFeedback(effect)
84
+ },
85
+ }
86
+ }
87
+ if (!isIOS || typeof UIImpactFeedbackGenerator === 'undefined') return
88
+ const styles = { light: 0, medium: 1, heavy: 2 } satisfies Record<
89
+ HapticStyle,
90
+ UIImpactFeedbackStyle
91
+ >
92
+ const generator = UIImpactFeedbackGenerator.alloc().initWithStyle(styles[style])
93
+ const native = view.nativeViewProtected as UIView
94
+ return {
95
+ enabled: () => true,
96
+ active: () =>
97
+ view.isLoaded &&
98
+ view.nativeViewProtected === native &&
99
+ !!native.window &&
100
+ UIApplication.sharedApplication.applicationState === 0,
101
+ prepare: () => generator.prepare(),
102
+ fire: () => generator.impactOccurred(),
103
+ }
104
+ }
43
105
 
44
106
  /** Discrete feedback only: animation playback remains entirely native. */
45
107
  export function playHaptics(
@@ -50,7 +112,7 @@ export function playHaptics(
50
112
  startedAt: number,
51
113
  elapsedTime = () => driver.now() - startedAt,
52
114
  ) {
53
- if (!isIOS || !options || typeof UIImpactFeedbackGenerator === 'undefined') return
115
+ if (!options) return
54
116
  const moving = tracks.filter(
55
117
  (track) =>
56
118
  track.duration > 0 && track.values.some((value) => value !== track.values[0]),
@@ -62,25 +124,13 @@ export function playHaptics(
62
124
  const offsets = options.times
63
125
  ? options.times.map((time) => delay + (end - delay) * time)
64
126
  : [Math.max(...tracks.map((track) => track.arrival))]
65
- const styles = {
66
- light: 0,
67
- medium: 1,
68
- heavy: 2,
69
- } satisfies Record<HapticStyle, UIImpactFeedbackStyle>
70
- let generator: UIImpactFeedbackGenerator | undefined =
71
- UIImpactFeedbackGenerator.alloc().initWithStyle(styles[options.style])
72
- const native = view.nativeViewProtected as UIView
127
+ let feedback = feedbackFor(view, options.style, driver)
128
+ if (!feedback) return
73
129
  let paused = false
74
130
  let next = 0
75
131
  let cancelPrepare: (() => void) | undefined
76
132
  let cancelImpact: (() => void) | undefined
77
133
 
78
- const active = () =>
79
- view.isLoaded &&
80
- view.nativeViewProtected === native &&
81
- !!native.window &&
82
- UIApplication.sharedApplication.applicationState === 0 // UIApplicationState.Active
83
-
84
134
  const cancelTimers = () => {
85
135
  cancelPrepare?.()
86
136
  cancelImpact?.()
@@ -90,28 +140,54 @@ export function playHaptics(
90
140
  cancelPrepare?.()
91
141
  cancelImpact?.()
92
142
  cancelPrepare = cancelImpact = undefined
93
- generator = undefined
143
+ feedback = undefined
94
144
  view.off('unloaded', cancel)
95
145
  }
96
146
 
97
- const fire = (tolerance = 0) => {
98
- const elapsed = elapsedTime()
147
+ const fire = (elapsed = elapsedTime()) => {
99
148
  // A busy UI thread can delay timers. Drop stale impacts instead of replaying
100
149
  // several old taps at once when it becomes responsive again.
101
- while (next < offsets.length && offsets[next]! <= elapsed + tolerance) {
150
+ while (next < offsets.length && offsets[next]! <= elapsed) {
102
151
  const offset = offsets[next++]!
103
- if (active() && elapsed - offset <= MAX_LATENESS) generator?.impactOccurred()
152
+ if (feedback?.enabled() && feedback.active() && elapsed - offset <= MAX_LATENESS)
153
+ feedback.fire()
104
154
  }
105
155
  }
106
156
 
107
157
  const schedule = () => {
108
158
  if (paused) return
109
- if (!generator || next === offsets.length) return cancel()
159
+ if (!feedback?.enabled() || next === offsets.length) return cancel()
160
+ if (isAndroid) {
161
+ // Start pending markers together with the visual animators. Chaining a
162
+ // new ValueAnimator from each impact adds another first-frame startup
163
+ // delay, which can wrongly make subsequent points stale at fast scales.
164
+ const markers = new Set<() => void>()
165
+ cancelImpact = () => {
166
+ for (const release of markers) release()
167
+ markers.clear()
168
+ }
169
+ const arm = (index: number) => {
170
+ if (!feedback?.enabled()) return cancel()
171
+ // Native durations are integer milliseconds; zero completes synchronously.
172
+ const remaining = Math.max(1, offsets[index]! - elapsedTime())
173
+ const release = driver.after(remaining, () => {
174
+ markers.delete(release)
175
+ if (!feedback || paused) return
176
+ if (!feedback.enabled()) return cancel()
177
+ fire()
178
+ if (next === offsets.length) cancel()
179
+ else if (next <= index) arm(index)
180
+ })
181
+ markers.add(release)
182
+ }
183
+ for (let index = next; index < offsets.length; index++) arm(index)
184
+ return
185
+ }
110
186
  const remaining = offsets[next]! - elapsedTime()
111
- if (active()) generator.prepare()
112
- if (remaining > PREPARE_AHEAD) {
187
+ if (feedback.active()) feedback.prepare?.()
188
+ if (feedback.prepare && remaining > PREPARE_AHEAD) {
113
189
  cancelPrepare = driver.after(remaining - PREPARE_AHEAD, () => {
114
- if (active()) generator?.prepare()
190
+ if (feedback?.active()) feedback.prepare?.()
115
191
  })
116
192
  }
117
193
  cancelImpact = driver.after(Math.max(0, remaining), () => {
@@ -140,8 +216,10 @@ export function playHaptics(
140
216
  schedule()
141
217
  },
142
218
  finish() {
143
- // Native completion may beat the timer at the final keyframe by a frame.
144
- fire(COMPLETION_TOLERANCE)
219
+ // Native completion proves that the final frame was reached, even when
220
+ // a scaled/resumed clock sample is one frame behind. Earlier impacts are
221
+ // still judged against the end position, so stale points are not replayed.
222
+ fire(Math.max(end, elapsedTime()))
145
223
  cancel()
146
224
  },
147
225
  }
package/src/index.ts CHANGED
@@ -69,6 +69,7 @@ export type {
69
69
  GradientOptions,
70
70
  GradientType,
71
71
  GradientValues,
72
+ GradientBindings,
72
73
  } from './gradient.js'
73
74
  export { configure, onReducedMotion, reducedMotion } from './config.js'
74
75
  export type { JoltConfig } from './config.js'
@@ -111,4 +111,11 @@ declare class JoltReferenceProperties extends NSObject {
111
111
  static mapCoordinate(target: NSObject): JoltPropertySink
112
112
  static mapCamera(target: NSObject): JoltPropertySink
113
113
  static scenePose(target: NSObject): JoltPropertySink
114
+ static sceneLightIntensity(target: NSObject): JoltPropertySink
115
+ static sceneLightColor(target: NSObject): JoltPropertySink
116
+ static sceneMaterialTransparency(target: NSObject): JoltPropertySink
117
+ static sceneMaterialDiffuse(target: NSObject): JoltPropertySink
118
+ static sceneMaterialEmission(target: NSObject): JoltPropertySink
119
+ static sceneAppearanceSinkCount(): number
120
+ static sceneAppearanceWriteCount(): number
114
121
  }
@@ -41,7 +41,7 @@ export interface MotionScope {
41
41
  timeline: typeof timeline
42
42
  press: typeof press
43
43
  drag: typeof drag
44
- /** Removed with the scope; suspending it stops a gradient animation where it is. */
44
+ /** Removed with the scope; suspension holds bindings until resume and stops concrete playback. */
45
45
  gradient: typeof gradient
46
46
  states<
47
47
  T extends Record<string, Target | null>,
@@ -213,7 +213,14 @@ export function motionScope(): MotionScope {
213
213
  gradient(target, options) {
214
214
  assertActive()
215
215
  const made = gradient(target, options)
216
- scope.add({ pause: () => made.stop(), play() {}, dispose: () => made.remove() })
216
+ scope.add({
217
+ get paused() {
218
+ return made.suspended
219
+ },
220
+ pause: () => made.suspend(),
221
+ play: () => made.resume(),
222
+ dispose: () => made.remove(),
223
+ })
217
224
  return made
218
225
  },
219
226
  states(targets, definitions, options) {
@@ -409,6 +409,12 @@ class Root extends Derived {
409
409
  return { property, keyframes: [], transition, track: mapped }
410
410
  }
411
411
 
412
+ /** Capture the current native presentation clock, even if a later command replaces it. */
413
+ captureTime(): () => number {
414
+ const run = this.presentation
415
+ return run ? () => this.runTime(run) : () => 0
416
+ }
417
+
412
418
  elapsed(): number {
413
419
  return this.presentation ? this.runTime(this.presentation) : 0
414
420
  }
@@ -595,6 +601,9 @@ class PlaybackValue extends Root {
595
601
  override get paused() {
596
602
  return this.playback.read().paused
597
603
  }
604
+ override captureTime() {
605
+ return () => this.playback.read().time
606
+ }
598
607
  override elapsed() {
599
608
  return this.playback.read().time
600
609
  }
@@ -642,6 +651,40 @@ class PlaybackValue extends Root {
642
651
  }
643
652
  }
644
653
 
654
+ /** Internal compound-binding bridge. One raw snapshot per root keeps sibling mappings coherent. */
655
+ export function valueSource(source: DerivedValue | DerivedColor, color: boolean) {
656
+ if (!(source instanceof Derived) || source.color !== color)
657
+ throw new TypeError(
658
+ `Gradient component needs a ${color ? 'color' : 'numeric'} Jolt value.`,
659
+ )
660
+ const { root, read } = source
661
+ return {
662
+ identity: root,
663
+ read,
664
+ snapshot() {
665
+ return {
666
+ track: root.plan({ property: 'opacity', read: (value) => value } as Binding)
667
+ .track!(),
668
+ elapsed: root.elapsed(),
669
+ time: root.captureTime(),
670
+ running: root.running,
671
+ value: root.raw(),
672
+ }
673
+ },
674
+ subscribe(listener: () => void) {
675
+ root.listeners.add(listener)
676
+ return () => {
677
+ root.listeners.delete(listener)
678
+ }
679
+ },
680
+ }
681
+ }
682
+
683
+ /** Treat values as opaque identities in declarative option comparisons. */
684
+ export function isDerived(value: unknown): value is DerivedValue | DerivedColor {
685
+ return value instanceof Derived
686
+ }
687
+
645
688
  /** Internal native-adapter bridge. Mappings are sampled at boundaries, never per frame. */
646
689
  export function valuePlayback(source: DerivedValue) {
647
690
  if (!(source instanceof Derived) || source.color)
@@ -14,3 +14,10 @@ export type {
14
14
  MapLibreCamera,
15
15
  ScenePose,
16
16
  } from '../native-properties.js'
17
+ export {
18
+ sceneKitLightIntensity,
19
+ sceneKitLightColor,
20
+ sceneKitMaterialTransparency,
21
+ sceneKitMaterialDiffuse,
22
+ sceneKitMaterialEmission,
23
+ } from '../scene-appearance.js'
package/src/particles.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { Application, Color, isIOS, type View } from '@nativescript/core'
1
+ import { Application, Color, isAndroid, isIOS, type View } from '@nativescript/core'
2
2
  import { toView } from './animate.js'
3
3
  import type { Target } from './types.js'
4
- import { reducedMotion } from './config.js'
4
+ import { onReducedMotion, reducedMotion } from './config.js'
5
+ import { androidParticles } from './driver/android-particles.js'
5
6
 
6
7
  export interface ConfettiOptions {
7
8
  /** Approximate number of particles in the burst. Defaults to 80. */
@@ -38,7 +39,10 @@ export interface ParticleOptions {
38
39
  duration?: number
39
40
  }
40
41
 
41
- type Settings = Required<Omit<ParticleOptions, 'duration'>> & { duration?: number }
42
+ export type ParticleSettings = Required<Omit<ParticleOptions, 'duration'>> & {
43
+ duration?: number
44
+ }
45
+ type Settings = ParticleSettings
42
46
  const bursts = new WeakMap<View, () => void>()
43
47
  const emitters = new WeakMap<View, () => void>()
44
48
  const palette = ['#facc15', '#ef4444', '#3b82f6', '#22c55e', '#a855f7']
@@ -46,7 +50,7 @@ const textures = new Map<Settings['shape'], UIImage>()
46
50
  const radians = (degrees: number) => (degrees * Math.PI) / 180
47
51
  const BURST_DURATION = 200
48
52
 
49
- /** Bursts from the target's center into its window. iOS only; returns immediate cleanup. */
53
+ /** Bursts from the target's center into its window. Returns immediate cleanup. */
50
54
  export function confetti(target: Target, options: ConfettiOptions = {}): () => void {
51
55
  const count = number('count', options.count ?? 80, 0, 10000)
52
56
  if (!Number.isInteger(count)) throw new RangeError('Particle count must be an integer.')
@@ -70,7 +74,7 @@ export function confetti(target: Target, options: ConfettiOptions = {}): () => v
70
74
  return start(target, settings, true)
71
75
  }
72
76
 
73
- /** Adds a native particle emitter to the target. iOS only; returns immediate cleanup. */
77
+ /** Adds a native particle emitter to the target. Returns immediate cleanup. */
74
78
  export function particles(target: Target, options: ParticleOptions = {}): () => void {
75
79
  return start(target, resolve(options), false)
76
80
  }
@@ -79,7 +83,9 @@ function start(target: Target, settings: Settings, overlay: boolean): () => void
79
83
  const view = toView(target)
80
84
  const running = overlay ? bursts : emitters
81
85
  running.get(view)?.()
82
- if (!isIOS || settings.rate === 0 || reducedMotion()) return () => {}
86
+ if (settings.rate === 0 || reducedMotion()) return () => {}
87
+ if (isAndroid) return androidParticles(view, settings, overlay, running)
88
+ if (!isIOS) return () => {}
83
89
  return new Emitter(view, settings, overlay, running).stop
84
90
  }
85
91
 
@@ -141,7 +147,7 @@ class Emitter {
141
147
  private native: UIView | undefined
142
148
  private window: UIWindow | undefined
143
149
  private timer: ReturnType<typeof setTimeout> | undefined
144
- private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
150
+ private unwatch: (() => void) | undefined
145
151
 
146
152
  constructor(
147
153
  private readonly view: View,
@@ -153,6 +159,8 @@ class Emitter {
153
159
  view.on('loaded', this.schedule)
154
160
  view.on('layoutChanged', this.schedule)
155
161
  view.on('unloaded', this.stop)
162
+ Application.on(Application.suspendEvent, this.stop)
163
+ this.unwatch = onReducedMotion(this.motionChanged)
156
164
  this.schedule()
157
165
  }
158
166
 
@@ -162,17 +170,14 @@ class Emitter {
162
170
  this.view.off('loaded', this.schedule)
163
171
  this.view.off('layoutChanged', this.schedule)
164
172
  this.view.off('unloaded', this.stop)
173
+ Application.off(Application.suspendEvent, this.stop)
165
174
  if (this.running.get(this.view) === this.stop) this.running.delete(this.view)
166
175
  if (this.timer !== undefined) clearTimeout(this.timer)
167
- if (this.observer)
168
- Application.ios.removeNotificationObserver(
169
- this.observer,
170
- UIAccessibilityReduceMotionStatusDidChangeNotification,
171
- )
176
+ this.unwatch?.()
172
177
  this.layer?.removeAllAnimations()
173
178
  this.layer?.removeFromSuperlayer()
174
179
  this.layer = this.native = this.window = undefined
175
- this.timer = this.observer = undefined
180
+ this.timer = this.unwatch = undefined
176
181
  }
177
182
 
178
183
  private schedule = () => {
@@ -232,10 +237,6 @@ class Emitter {
232
237
  ),
233
238
  window,
234
239
  )
235
- this.observer = Application.ios.addNotificationObserver(
236
- UIAccessibilityReduceMotionStatusDidChangeNotification,
237
- this.motionChanged,
238
- )
239
240
  if (this.settings.duration !== undefined) {
240
241
  // The render server stops emission even if JS is blocked. The timer
241
242
  // only removes the empty layer after the last particles have expired.
@@ -0,0 +1,92 @@
1
+ /// <reference types="@nativescript/types-ios/lib/ios/objc-x86_64/objc!SceneKit" preserve="true" />
2
+ import { Color } from '@nativescript/core'
3
+ import { colorMixer, parseColor, type ColorValue } from './color.js'
4
+ import { registerNativeProperty, type NativeProperty } from './native-property.js'
5
+
6
+ const clamp = (n: number) => Math.max(0, Math.min(1, n))
7
+ function scalar(name: string, maximum: number, create: (target: object) => object) {
8
+ return registerNativeProperty<number>({
9
+ name: `jolt.scenekit/${name}`,
10
+ units: [
11
+ name === 'light-intensity'
12
+ ? 'lumens (PBR); 1000 = unit multiplier otherwise'
13
+ : 'opacity fraction',
14
+ ],
15
+ platforms: ['ios'],
16
+ encode: (value) => [value],
17
+ decode: (values) => values[0]!,
18
+ validate(value) {
19
+ if (!Number.isFinite(value) || value < 0 || value > maximum)
20
+ throw new RangeError(`${name} needs a finite value in 0..${maximum}.`)
21
+ },
22
+ interpolate: (a, b, t) => Math.max(0, Math.min(maximum, a + (b - a) * t)),
23
+ create,
24
+ })
25
+ }
26
+ function color(name: string, create: (target: object) => object) {
27
+ return registerNativeProperty<ColorValue>({
28
+ name: `jolt.scenekit/${name}`,
29
+ // Premultiply the sampled sRGB channels, so native linear interpolation also handles zero alpha.
30
+ units: [
31
+ 'premultiplied sRGB red',
32
+ 'premultiplied sRGB green',
33
+ 'premultiplied sRGB blue',
34
+ 'alpha',
35
+ ],
36
+ platforms: ['ios'],
37
+ encode(value) {
38
+ const argb = parseColor(value),
39
+ alpha = (argb >>> 24) / 255
40
+ return [
41
+ (((argb >>> 16) & 255) / 255) * alpha,
42
+ (((argb >>> 8) & 255) / 255) * alpha,
43
+ ((argb & 255) / 255) * alpha,
44
+ alpha,
45
+ ]
46
+ },
47
+ decode(values) {
48
+ const alpha = clamp(values[3]!)
49
+ const channel = (i: number) =>
50
+ Math.round(clamp(alpha ? values[i]! / alpha : 0) * 255)
51
+ return new Color(
52
+ ((Math.round(alpha * 255) << 24) |
53
+ (channel(0) << 16) |
54
+ (channel(1) << 8) |
55
+ channel(2)) >>>
56
+ 0,
57
+ )
58
+ },
59
+ interpolate: (a, b, t) => new Color(colorMixer(parseColor(a), parseColor(b))(t)),
60
+ create,
61
+ })
62
+ }
63
+
64
+ /** SCNLight intensity, finite and nonnegative. Non-spatial: retains timing under reduced motion. */
65
+ export const sceneKitLightIntensity: (light: SCNLight) => NativeProperty<number> = scalar(
66
+ 'light-intensity',
67
+ Infinity,
68
+ (target) => JoltReferenceProperties.sceneLightIntensity(target as NSObject),
69
+ )
70
+ /** Solid UIColor/CGColor light color. Returns a normalized sRGB Color from get(). */
71
+ export const sceneKitLightColor: (light: SCNLight) => NativeProperty<ColorValue> = color(
72
+ 'light-color',
73
+ (target) => JoltReferenceProperties.sceneLightColor(target as NSObject),
74
+ )
75
+ /** SCNMaterial transparency in 0..1; does not change transparencyMode/transparent contents. */
76
+ export const sceneKitMaterialTransparency: (
77
+ material: SCNMaterial,
78
+ ) => NativeProperty<number> = scalar('material-transparency', 1, (target) =>
79
+ JoltReferenceProperties.sceneMaterialTransparency(target as NSObject),
80
+ )
81
+ /** Solid color diffuse contents only; never converts a texture into a color. */
82
+ export const sceneKitMaterialDiffuse: (
83
+ material: SCNMaterial,
84
+ ) => NativeProperty<ColorValue> = color('material-diffuse', (target) =>
85
+ JoltReferenceProperties.sceneMaterialDiffuse(target as NSObject),
86
+ )
87
+ /** Solid color emission contents only; does not make the material illuminate nearby geometry. */
88
+ export const sceneKitMaterialEmission: (
89
+ material: SCNMaterial,
90
+ ) => NativeProperty<ColorValue> = color('material-emission', (target) =>
91
+ JoltReferenceProperties.sceneMaterialEmission(target as NSObject),
92
+ )
package/src/types.ts CHANGED
@@ -84,7 +84,7 @@ export type AnimateOptions = Transition & {
84
84
  relative?: boolean
85
85
  /** With several targets, delays each one by this much more than the previous. */
86
86
  stagger?: number | Stagger
87
- /** iOS impact feedback at arrival, or at explicit points in a finite animation. */
87
+ /** Native haptic feedback at arrival, or at explicit points in a finite animation. */
88
88
  haptic?: Haptic
89
89
  /**
90
90
  * Plays on a named layer, on top of the property's other animations instead of replacing
package/src/vue/index.ts CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  type GradientOptions,
41
41
  type Transition,
42
42
  } from '../index.js'
43
+ import { isDerived } from '../motion-value.js'
44
+ import { prepareGradient, type GradientValues } from '../gradient.js'
43
45
  import { currentKeyboard } from '../keyboard.js'
44
46
 
45
47
  type Host = NSVElement
@@ -70,7 +72,10 @@ export interface TransitionProps {
70
72
  const animations = new WeakMap<object, PlaybackAnimation>()
71
73
  const scopes = new WeakMap<object, MotionScope>()
72
74
  const removers = new WeakMap<object, () => void>()
73
- const gradients = new WeakMap<object, { gradient: Gradient; colors: number }>()
75
+ const gradients = new WeakMap<
76
+ object,
77
+ { gradient: Gradient; colors: number; release?: () => void }
78
+ >()
74
79
 
75
80
  function split<T>(
76
81
  value: T | readonly [T, Options?],
@@ -82,6 +87,7 @@ function split<T>(
82
87
 
83
88
  function same(a: unknown, b: unknown): boolean {
84
89
  if (Object.is(a, b)) return true
90
+ if (isDerived(a) || isDerived(b)) return false
85
91
  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
86
92
  return false
87
93
  if (Array.isArray(a) !== Array.isArray(b)) return false
@@ -161,17 +167,35 @@ function paint(el: Host, value: GradientValue, animated: boolean) {
161
167
  (Transition | SpringName)?,
162
168
  ]
163
169
  const current = gradients.get(el)
170
+ prepareGradient(options)
164
171
  const colors = options.colors.length
172
+ const hasBindings = [
173
+ options.angle,
174
+ options.radius,
175
+ options.center?.x,
176
+ options.center?.y,
177
+ ...options.colors,
178
+ ...(options.stops ?? []),
179
+ ].some(isDerived)
165
180
  if (current && current.gradient.type === options.type) {
166
181
  const { type, ...values } = options
167
- if (animated && current.colors === colors)
168
- current.gradient.animate(values, transition)
169
- else current.gradient.set(values)
182
+ const previous = current.release
183
+ if (hasBindings) current.release = current.gradient.bind(values)
184
+ else {
185
+ if (animated && current.colors === colors)
186
+ current.gradient.animate(values as GradientValues, transition)
187
+ else current.gradient.set(values as GradientValues)
188
+ current.release = undefined
189
+ }
190
+ previous?.()
170
191
  current.colors = colors
171
192
  return
172
193
  }
173
194
  current?.gradient.remove()
174
- gradients.set(el, { gradient: gradient(el, options), colors })
195
+ // Track directive ownership explicitly, including bindings omitted on a later update.
196
+ const made = gradient(el, options)
197
+ const release = hasBindings ? made.bind(options) : undefined
198
+ gradients.set(el, { gradient: made, colors, release })
175
199
  }
176
200
 
177
201
  /**