@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
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'
@@ -103,3 +104,5 @@ export type {
103
104
 
104
105
  export { animateLayout } from './layout.js'
105
106
  export type { LayoutOptions } from './layout.js'
107
+ export { animateSize } from './size.js'
108
+ export type { SizeAnimation, SizeDimensions, SizeOptions } from './size.js'
@@ -108,7 +108,17 @@ declare class JoltPropertyPlayer extends NSObject {
108
108
  cancel(): void
109
109
  }
110
110
  declare class JoltReferenceProperties extends NSObject {
111
+ static sceneFieldOfView(camera: NSObject): JoltPropertySink
112
+ static sceneCameraSinkCount(): number
113
+ static sceneCameraWriteCount(): number
111
114
  static mapCoordinate(target: NSObject): JoltPropertySink
112
115
  static mapCamera(target: NSObject): JoltPropertySink
113
116
  static scenePose(target: NSObject): JoltPropertySink
117
+ static sceneLightIntensity(target: NSObject): JoltPropertySink
118
+ static sceneLightColor(target: NSObject): JoltPropertySink
119
+ static sceneMaterialTransparency(target: NSObject): JoltPropertySink
120
+ static sceneMaterialDiffuse(target: NSObject): JoltPropertySink
121
+ static sceneMaterialEmission(target: NSObject): JoltPropertySink
122
+ static sceneAppearanceSinkCount(): number
123
+ static sceneAppearanceWriteCount(): number
114
124
  }
@@ -3,6 +3,7 @@ import { press } from './press.js'
3
3
  import { drag } from './drag.js'
4
4
  import { gradient } from './gradient.js'
5
5
  import { animateLayout, type LayoutOptions } from './layout.js'
6
+ import { animateSize, type SizeDimensions, type SizeOptions } from './size.js'
6
7
  import type { SpringName } from './transition.js'
7
8
  import { sequence, timeline } from './sequence.js'
8
9
  import {
@@ -35,13 +36,14 @@ export interface MotionScope {
35
36
  /** Owned handles/subscriptions, including suspended subscription factories. */
36
37
  readonly size: number
37
38
  animateLayout: typeof animateLayout
39
+ animateSize: typeof animateSize
38
40
  animate: typeof animate
39
41
  decay: typeof decay
40
42
  sequence: typeof sequence
41
43
  timeline: typeof timeline
42
44
  press: typeof press
43
45
  drag: typeof drag
44
- /** Removed with the scope; suspending it stops a gradient animation where it is. */
46
+ /** Removed with the scope; suspension holds bindings until resume and stops concrete playback. */
45
47
  gradient: typeof gradient
46
48
  states<
47
49
  T extends Record<string, Target | null>,
@@ -195,6 +197,18 @@ export function motionScope(): MotionScope {
195
197
  },
196
198
  animate: ((...args: Parameters<typeof animate>) =>
197
199
  startOwned(() => animate(...args))) as typeof animate,
200
+ animateSize(
201
+ target: Target,
202
+ dimensions: SizeDimensions,
203
+ changeOrOptions?: (() => void) | SizeOptions | SpringName,
204
+ options?: SizeOptions | SpringName,
205
+ ) {
206
+ return startOwned(() =>
207
+ typeof changeOrOptions === 'function'
208
+ ? animateSize(target, dimensions, changeOrOptions, options)
209
+ : animateSize(target, dimensions, changeOrOptions),
210
+ )
211
+ },
198
212
  decay(...args) {
199
213
  return startOwned(() => decay(...args))
200
214
  },
@@ -213,7 +227,14 @@ export function motionScope(): MotionScope {
213
227
  gradient(target, options) {
214
228
  assertActive()
215
229
  const made = gradient(target, options)
216
- scope.add({ pause: () => made.stop(), play() {}, dispose: () => made.remove() })
230
+ scope.add({
231
+ get paused() {
232
+ return made.suspended
233
+ },
234
+ pause: () => made.suspend(),
235
+ play: () => made.resume(),
236
+ dispose: () => made.remove(),
237
+ })
217
238
  return made
218
239
  },
219
240
  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)
@@ -1,6 +1,7 @@
1
1
  export { registerNativeProperty, checkNativeProperty } from '../native-property.js'
2
2
  export type { NativeProperty, NativePropertyDefinition } from '../native-property.js'
3
3
  export type { NativeSink } from '../native-property-driver.js'
4
+ export { sceneKitFieldOfView } from '../scene-camera.js'
4
5
  export {
5
6
  mapKitCoordinate,
6
7
  mapKitCamera,
@@ -14,3 +15,10 @@ export type {
14
15
  MapLibreCamera,
15
16
  ScenePose,
16
17
  } from '../native-properties.js'
18
+ export {
19
+ sceneKitLightIntensity,
20
+ sceneKitLightColor,
21
+ sceneKitMaterialTransparency,
22
+ sceneKitMaterialDiffuse,
23
+ sceneKitMaterialEmission,
24
+ } 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
+ )
@@ -0,0 +1,30 @@
1
+ /// <reference types="@nativescript/types-ios/lib/ios/objc-x86_64/objc!SceneKit" preserve="true" />
2
+ import { registerNativeProperty, type NativeProperty } from './native-property.js'
3
+
4
+ /** Perspective SCNCamera field of view in degrees (1..179), on its projectionDirection axis. */
5
+ export const sceneKitFieldOfView: (camera: SCNCamera) => NativeProperty<number> =
6
+ registerNativeProperty<number>({
7
+ name: 'jolt.scenekit/field-of-view',
8
+ units: ['degrees on the camera projectionDirection axis'],
9
+ platforms: ['ios'],
10
+ spatial: true,
11
+ encode: (value) => [value],
12
+ decode: (values) => values[0]!,
13
+ validate(value) {
14
+ if (!Number.isFinite(value) || value < 1 || value > 179)
15
+ throw new RangeError(
16
+ 'SceneKit field of view needs a finite angle in 1..179 degrees.',
17
+ )
18
+ },
19
+ interpolate: (a, b, t) => Math.max(1, Math.min(179, a + (b - a) * t)),
20
+ create(target) {
21
+ if (
22
+ typeof JoltReferenceProperties === 'undefined' ||
23
+ typeof JoltReferenceProperties.sceneFieldOfView !== 'function'
24
+ )
25
+ throw new Error(
26
+ 'SceneKit field of view requires an updated @rafaelaugusto/jolt-native plugin. Update both packages and rebuild the native app.',
27
+ )
28
+ return JoltReferenceProperties.sceneFieldOfView(target as NSObject)
29
+ },
30
+ })