@rafaelaugusto/jolt 0.1.0-beta.1 → 0.1.0-beta.3

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 +115 -8
  2. package/dist/animate.d.ts +3 -1
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +83 -6
  5. package/dist/animate.js.map +1 -1
  6. package/dist/decay.d.ts +11 -0
  7. package/dist/decay.d.ts.map +1 -0
  8. package/dist/decay.js +22 -0
  9. package/dist/decay.js.map +1 -0
  10. package/dist/driver/android.d.ts +1 -1
  11. package/dist/driver/android.d.ts.map +1 -1
  12. package/dist/driver/android.js +95 -5
  13. package/dist/driver/android.js.map +1 -1
  14. package/dist/driver/index.d.ts +3 -1
  15. package/dist/driver/index.d.ts.map +1 -1
  16. package/dist/driver/index.js.map +1 -1
  17. package/dist/driver/ios.d.ts.map +1 -1
  18. package/dist/driver/ios.js +7 -0
  19. package/dist/driver/ios.js.map +1 -1
  20. package/dist/engine.d.ts +6 -3
  21. package/dist/engine.d.ts.map +1 -1
  22. package/dist/engine.js +21 -5
  23. package/dist/engine.js.map +1 -1
  24. package/dist/haptics.d.ts +18 -0
  25. package/dist/haptics.d.ts.map +1 -0
  26. package/dist/haptics.js +96 -0
  27. package/dist/haptics.js.map +1 -0
  28. package/dist/index.d.ts +14 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/loader.d.ts +19 -0
  33. package/dist/loader.d.ts.map +1 -0
  34. package/dist/loader.js +172 -0
  35. package/dist/loader.js.map +1 -0
  36. package/dist/particles.d.ts +39 -0
  37. package/dist/particles.d.ts.map +1 -0
  38. package/dist/particles.js +264 -0
  39. package/dist/particles.js.map +1 -0
  40. package/dist/properties.d.ts +1 -1
  41. package/dist/properties.d.ts.map +1 -1
  42. package/dist/properties.js +1 -1
  43. package/dist/properties.js.map +1 -1
  44. package/dist/reveal.d.ts +25 -0
  45. package/dist/reveal.d.ts.map +1 -0
  46. package/dist/reveal.js +293 -0
  47. package/dist/reveal.js.map +1 -0
  48. package/dist/shimmer.d.ts +12 -0
  49. package/dist/shimmer.d.ts.map +1 -0
  50. package/dist/shimmer.js +160 -0
  51. package/dist/shimmer.js.map +1 -0
  52. package/dist/symbol-effect.d.ts +23 -0
  53. package/dist/symbol-effect.d.ts.map +1 -0
  54. package/dist/symbol-effect.js +186 -0
  55. package/dist/symbol-effect.js.map +1 -0
  56. package/dist/text-layout.d.ts +22 -0
  57. package/dist/text-layout.d.ts.map +1 -0
  58. package/dist/text-layout.js +179 -0
  59. package/dist/text-layout.js.map +1 -0
  60. package/dist/text.d.ts +17 -0
  61. package/dist/text.d.ts.map +1 -0
  62. package/dist/text.js +264 -0
  63. package/dist/text.js.map +1 -0
  64. package/dist/track.d.ts.map +1 -1
  65. package/dist/track.js +25 -3
  66. package/dist/track.js.map +1 -1
  67. package/dist/transition.d.ts +5 -0
  68. package/dist/transition.d.ts.map +1 -1
  69. package/dist/transition.js.map +1 -1
  70. package/dist/types.d.ts +17 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/package.json +2 -1
  73. package/src/animate.ts +96 -4
  74. package/src/decay.ts +45 -0
  75. package/src/driver/android.ts +112 -6
  76. package/src/driver/index.ts +3 -1
  77. package/src/driver/ios.ts +11 -0
  78. package/src/engine.ts +22 -6
  79. package/src/haptics.ts +125 -0
  80. package/src/index.ts +18 -1
  81. package/src/loader.ts +201 -0
  82. package/src/particles.ts +323 -0
  83. package/src/properties.ts +1 -1
  84. package/src/reveal.ts +374 -0
  85. package/src/shimmer.ts +179 -0
  86. package/src/symbol-effect.ts +225 -0
  87. package/src/text-layout.ts +216 -0
  88. package/src/text.ts +296 -0
  89. package/src/track.ts +42 -3
  90. package/src/transition.ts +6 -0
  91. package/src/types.ts +16 -0
package/src/loader.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { Application, Color, isIOS, type View } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import type { Target } from './types.js'
4
+
5
+ export type LoaderStyle = 'dots' | 'bars' | 'ring'
6
+
7
+ export interface LoaderOptions {
8
+ /** Defaults to 'dots'. */
9
+ style?: LoaderStyle
10
+ /** A NativeScript color string. Defaults to '#71717a'. */
11
+ color?: string
12
+ /** Maximum width and height in DIPs, fitted inside the target. Defaults to 48. */
13
+ size?: number
14
+ /** Milliseconds per cycle. Defaults to 1200. */
15
+ duration?: number
16
+ }
17
+
18
+ const running = new WeakMap<View, () => void>()
19
+ const key = 'jolt:loader'
20
+
21
+ /**
22
+ * Adds a centered native iOS loading indicator. Returns an idempotent cleanup.
23
+ * Unloading releases native resources; loading resumes until explicitly stopped.
24
+ * Reduce Motion keeps the indicator static. Android is a no-op.
25
+ */
26
+ export function loader(target: Target, options: LoaderOptions = {}): () => void {
27
+ const settings: Required<LoaderOptions> = {
28
+ style: options.style ?? 'dots',
29
+ color: options.color ?? '#71717a',
30
+ size: options.size ?? 48,
31
+ duration: options.duration ?? 1200,
32
+ }
33
+ if (!['dots', 'bars', 'ring'].includes(settings.style))
34
+ throw new TypeError('Unknown loader style.')
35
+ if (typeof settings.color !== 'string' || !Color.isValid(settings.color))
36
+ throw new TypeError('Loader color must be a valid color string.')
37
+ for (const name of ['size', 'duration'] as const)
38
+ if (!Number.isFinite(settings[name]) || settings[name] <= 0)
39
+ throw new RangeError(`Loader ${name} must be a positive finite number.`)
40
+ const view = toView(target)
41
+ running.get(view)?.()
42
+ if (!isIOS) return () => {}
43
+ return new Loader(view, settings).stop
44
+ }
45
+
46
+ class Loader {
47
+ private stopped = false
48
+ private queued = false
49
+ private waitingForLoad: boolean
50
+ private native: UIView | undefined
51
+ private layer: CAReplicatorLayer | undefined
52
+ private item: CALayer | undefined
53
+ private animating = false
54
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
55
+
56
+ constructor(
57
+ private readonly view: View,
58
+ private readonly settings: Required<LoaderOptions>,
59
+ ) {
60
+ this.waitingForLoad = !view.isLoaded
61
+ running.set(view, this.stop)
62
+ view.on('loaded', this.loaded)
63
+ view.on('layoutChanged', this.schedule)
64
+ view.on('unloaded', this.unloaded)
65
+ this.schedule()
66
+ }
67
+
68
+ stop = () => {
69
+ if (this.stopped) return
70
+ this.stopped = true
71
+ this.view.off('loaded', this.loaded)
72
+ this.view.off('layoutChanged', this.schedule)
73
+ this.view.off('unloaded', this.unloaded)
74
+ if (running.get(this.view) === this.stop) running.delete(this.view)
75
+ this.unloaded()
76
+ }
77
+
78
+ private loaded = () => {
79
+ this.waitingForLoad = false
80
+ this.schedule()
81
+ }
82
+
83
+ private unloaded = () => {
84
+ this.waitingForLoad = true
85
+ if (this.observer) {
86
+ Application.ios.removeNotificationObserver(
87
+ this.observer,
88
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
89
+ )
90
+ this.observer = undefined
91
+ }
92
+ this.remove()
93
+ }
94
+
95
+ private schedule = () => {
96
+ if (this.stopped || this.queued) return
97
+ this.queued = true
98
+ void Promise.resolve().then(() => {
99
+ this.queued = false
100
+ if (this.stopped) return
101
+ try {
102
+ this.update()
103
+ } catch (error) {
104
+ this.stop()
105
+ throw error
106
+ }
107
+ })
108
+ }
109
+
110
+ private update() {
111
+ if (this.waitingForLoad || !this.view.isLoaded) return
112
+ const native = this.view.nativeViewProtected as UIView | undefined
113
+ if (!native?.window) return this.remove()
114
+ const bounds = native.layer.bounds
115
+ const size = Math.min(this.settings.size, bounds.size.width, bounds.size.height)
116
+ if (size <= 0) return this.remove()
117
+ if (this.native && this.native !== native) this.remove()
118
+ this.observer ??= Application.ios.addNotificationObserver(
119
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
120
+ this.schedule,
121
+ )
122
+
123
+ CATransaction.begin()
124
+ CATransaction.setDisableActions(true)
125
+ try {
126
+ if (!this.layer) {
127
+ this.native = native
128
+ this.layer = CAReplicatorLayer.new()
129
+ this.layer.name = key
130
+ this.item = CALayer.new()
131
+ this.item.backgroundColor = new Color(this.settings.color).ios.CGColor
132
+ this.layer.addSublayer(this.item)
133
+ native.layer.addSublayer(this.layer)
134
+ }
135
+ const layer = this.layer
136
+ const item = this.item!
137
+ const { style, duration } = this.settings
138
+ layer.bounds = CGRectMake(0, 0, size, size)
139
+ layer.position = CGPointMake(
140
+ bounds.origin.x + bounds.size.width / 2,
141
+ bounds.origin.y + bounds.size.height / 2,
142
+ )
143
+ layer.instanceCount = style === 'dots' ? 3 : style === 'bars' ? 5 : 12
144
+ layer.instanceDelay = duration / 1000 / (style === 'ring' ? 12 : 5)
145
+ if (style === 'ring') {
146
+ const diameter = size * 0.16
147
+ item.bounds = CGRectMake(0, 0, diameter, diameter)
148
+ item.position = CGPointMake(size / 2, diameter / 2)
149
+ item.cornerRadius = diameter / 2
150
+ layer.instanceTransform = CATransform3DMakeRotation((2 * Math.PI) / 12, 0, 0, 1)
151
+ } else {
152
+ const width = size * (style === 'dots' ? 0.2 : 0.12)
153
+ const height = style === 'dots' ? width : size * 0.75
154
+ item.bounds = CGRectMake(0, 0, width, height)
155
+ item.position = CGPointMake(width / 2, size / 2)
156
+ item.cornerRadius = width / 2
157
+ layer.instanceTransform = CATransform3DMakeTranslation(
158
+ (size - width) / (layer.instanceCount - 1),
159
+ 0,
160
+ 0,
161
+ )
162
+ }
163
+ const reduced = UIAccessibilityIsReduceMotionEnabled()
164
+ item.opacity = style === 'ring' ? 0.35 : 1
165
+ if (reduced && this.animating) {
166
+ item.removeAnimationForKey(key)
167
+ this.animating = false
168
+ } else if (!reduced && !this.animating) {
169
+ const animation = CAKeyframeAnimation.animationWithKeyPath(
170
+ style === 'ring'
171
+ ? 'opacity'
172
+ : style === 'bars'
173
+ ? 'transform.scale.y'
174
+ : 'transform.scale',
175
+ )
176
+ animation.values = NSArray.arrayWithArray(
177
+ style === 'ring' ? [1, 0.2] : [0.35, 1, 0.35, 0.35],
178
+ )
179
+ animation.keyTimes = NSArray.arrayWithArray(
180
+ style === 'ring' ? [0, 1] : [0, 0.3, 0.6, 1],
181
+ )
182
+ animation.duration = duration / 1000
183
+ animation.repeatCount = Infinity
184
+ // Start one cycle in so every delayed copy is already participating.
185
+ animation.beginTime =
186
+ item.convertTimeFromLayer(CACurrentMediaTime(), null) - animation.duration
187
+ item.addAnimationForKey(animation, key)
188
+ this.animating = true
189
+ }
190
+ } finally {
191
+ CATransaction.commit()
192
+ }
193
+ }
194
+
195
+ private remove() {
196
+ this.item?.removeAnimationForKey(key)
197
+ this.layer?.removeFromSuperlayer()
198
+ this.item = this.layer = this.native = undefined
199
+ this.animating = false
200
+ }
201
+ }
@@ -0,0 +1,323 @@
1
+ import { Application, Color, isIOS, type View } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import type { Target } from './types.js'
4
+
5
+ export interface ConfettiOptions {
6
+ /** Approximate number of particles in the burst. Defaults to 80. */
7
+ count?: number
8
+ colors?: readonly string[]
9
+ }
10
+
11
+ export interface ParticleOptions {
12
+ colors?: readonly string[]
13
+ shape?: 'circle' | 'rectangle' | 'ring'
14
+ /** Center point, or a line across the top/bottom of the target. */
15
+ source?: 'center' | 'top' | 'bottom'
16
+ /** Total particles per second, shared across all colors. Defaults to 20. */
17
+ rate?: number
18
+ /** Particle lifetime in milliseconds. Defaults to 3000. */
19
+ lifetime?: number
20
+ /** Longest side in DIPs. Defaults to 6. */
21
+ size?: number
22
+ /** DIPs per second. Defaults to 60. */
23
+ velocity?: number
24
+ velocityRange?: number
25
+ /** Degrees clockwise from right: -90 is up, 90 is down. Defaults to -90. */
26
+ angle?: number
27
+ /** Emission cone in degrees, from 0 to 360. Defaults to 30. */
28
+ spread?: number
29
+ /** Downward acceleration in DIPs per second squared. Defaults to 0. */
30
+ gravity?: number
31
+ /** Degrees per second. Defaults to 0. */
32
+ spin?: number
33
+ spinRange?: number
34
+ /** Fade each particle over its lifetime. Defaults to true. */
35
+ fadeOut?: boolean
36
+ /** Emit for this many milliseconds, then let particles expire. Omit to keep emitting. */
37
+ duration?: number
38
+ }
39
+
40
+ type Settings = Required<Omit<ParticleOptions, 'duration'>> & { duration?: number }
41
+ const bursts = new WeakMap<View, () => void>()
42
+ const emitters = new WeakMap<View, () => void>()
43
+ const palette = ['#facc15', '#ef4444', '#3b82f6', '#22c55e', '#a855f7']
44
+ const textures = new Map<Settings['shape'], UIImage>()
45
+ const radians = (degrees: number) => (degrees * Math.PI) / 180
46
+ const BURST_DURATION = 200
47
+
48
+ /** Bursts from the target's center into its window. iOS only; returns immediate cleanup. */
49
+ export function confetti(target: Target, options: ConfettiOptions = {}): () => void {
50
+ const count = number('count', options.count ?? 80, 0, 10000)
51
+ if (!Number.isInteger(count)) throw new RangeError('Particle count must be an integer.')
52
+ const settings = resolve(
53
+ {
54
+ colors: options.colors ?? palette,
55
+ shape: 'rectangle',
56
+ rate: count / (BURST_DURATION / 1000),
57
+ duration: BURST_DURATION,
58
+ lifetime: 2200,
59
+ size: 8,
60
+ velocity: 260,
61
+ velocityRange: 100,
62
+ angle: -90,
63
+ spread: 100,
64
+ gravity: 420,
65
+ spinRange: 360,
66
+ },
67
+ true,
68
+ )
69
+ return start(target, settings, true)
70
+ }
71
+
72
+ /** Adds a native particle emitter to the target. iOS only; returns immediate cleanup. */
73
+ export function particles(target: Target, options: ParticleOptions = {}): () => void {
74
+ return start(target, resolve(options), false)
75
+ }
76
+
77
+ function start(target: Target, settings: Settings, overlay: boolean): () => void {
78
+ const view = toView(target)
79
+ const running = overlay ? bursts : emitters
80
+ running.get(view)?.()
81
+ if (!isIOS || settings.rate === 0 || UIAccessibilityIsReduceMotionEnabled())
82
+ return () => {}
83
+ return new Emitter(view, settings, overlay, running).stop
84
+ }
85
+
86
+ function resolve(options: ParticleOptions, burst = false): Settings {
87
+ const colors = options.colors ?? ['#ffffff']
88
+ if (
89
+ !Array.isArray(colors) ||
90
+ colors.length === 0 ||
91
+ colors.length > 32 ||
92
+ Array.from(colors).some((color) => typeof color !== 'string' || !Color.isValid(color))
93
+ )
94
+ throw new TypeError('Particles need 1 to 32 valid color strings.')
95
+ const shape = options.shape ?? 'circle'
96
+ const source = options.source ?? 'center'
97
+ if (!['circle', 'rectangle', 'ring'].includes(shape))
98
+ throw new TypeError('Unknown particle shape.')
99
+ if (!['center', 'top', 'bottom'].includes(source))
100
+ throw new TypeError('Unknown particle source.')
101
+ const lifetime = number('lifetime', options.lifetime ?? 3000, 1, 60000)
102
+ const rate = number('rate', options.rate ?? 20, 0, burst ? 50000 : 10000)
103
+ if (!burst && (rate * lifetime) / 1000 > 10000)
104
+ throw new RangeError('Particle rate × lifetime must not exceed 10000 live particles.')
105
+ const duration =
106
+ options.duration === undefined
107
+ ? undefined
108
+ : number('duration', options.duration, 1, 2147483647 - lifetime - 50)
109
+ const fadeOut = options.fadeOut ?? true
110
+ if (typeof fadeOut !== 'boolean')
111
+ throw new TypeError('Particle fadeOut must be a boolean.')
112
+ return {
113
+ colors: [...colors],
114
+ shape,
115
+ source,
116
+ lifetime,
117
+ rate,
118
+ duration,
119
+ fadeOut,
120
+ size: number('size', options.size ?? 6, 1, 128),
121
+ velocity: number('velocity', options.velocity ?? 60, 0, 10000),
122
+ velocityRange: number('velocityRange', options.velocityRange ?? 0, 0, 10000),
123
+ angle: number('angle', options.angle ?? -90, -360, 360),
124
+ spread: number('spread', options.spread ?? 30, 0, 360),
125
+ gravity: number('gravity', options.gravity ?? 0, -10000, 10000),
126
+ spin: number('spin', options.spin ?? 0, -3600, 3600),
127
+ spinRange: number('spinRange', options.spinRange ?? 0, 0, 3600),
128
+ }
129
+ }
130
+
131
+ function number(name: string, value: number, min: number, max: number): number {
132
+ if (!Number.isFinite(value) || value < min || value > max)
133
+ throw new RangeError(`Particle ${name} must be between ${min} and ${max}.`)
134
+ return value
135
+ }
136
+
137
+ class Emitter {
138
+ private stopped = false
139
+ private queued = false
140
+ private layer: CAEmitterLayer | undefined
141
+ private native: UIView | undefined
142
+ private window: UIWindow | undefined
143
+ private timer: ReturnType<typeof setTimeout> | undefined
144
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
145
+
146
+ constructor(
147
+ private readonly view: View,
148
+ private readonly settings: Settings,
149
+ private readonly overlay: boolean,
150
+ private readonly running: WeakMap<View, () => void>,
151
+ ) {
152
+ running.set(view, this.stop)
153
+ view.on('loaded', this.schedule)
154
+ view.on('layoutChanged', this.schedule)
155
+ view.on('unloaded', this.stop)
156
+ this.schedule()
157
+ }
158
+
159
+ stop = () => {
160
+ if (this.stopped) return
161
+ this.stopped = true
162
+ this.view.off('loaded', this.schedule)
163
+ this.view.off('layoutChanged', this.schedule)
164
+ this.view.off('unloaded', this.stop)
165
+ if (this.running.get(this.view) === this.stop) this.running.delete(this.view)
166
+ if (this.timer !== undefined) clearTimeout(this.timer)
167
+ if (this.observer)
168
+ Application.ios.removeNotificationObserver(
169
+ this.observer,
170
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
171
+ )
172
+ this.layer?.removeAllAnimations()
173
+ this.layer?.removeFromSuperlayer()
174
+ this.layer = this.native = this.window = undefined
175
+ this.timer = this.observer = undefined
176
+ }
177
+
178
+ private schedule = () => {
179
+ if (this.stopped || this.queued) return
180
+ this.queued = true
181
+ void Promise.resolve().then(() => {
182
+ this.queued = false
183
+ if (this.stopped) return
184
+ try {
185
+ this.update()
186
+ } catch (error) {
187
+ this.stop()
188
+ throw error
189
+ }
190
+ })
191
+ }
192
+
193
+ private motionChanged = () => {
194
+ if (UIAccessibilityIsReduceMotionEnabled()) this.stop()
195
+ }
196
+
197
+ private update() {
198
+ if (!this.view.isLoaded) return
199
+ if (UIAccessibilityIsReduceMotionEnabled()) return this.stop()
200
+ const native = this.view.nativeViewProtected as UIView | undefined
201
+ const window = native?.window
202
+ if (this.layer && (!native || !window)) return this.stop()
203
+ if (!native || !window) return
204
+ if (this.native && (this.native !== native || this.window !== window))
205
+ return this.stop()
206
+ const bounds = native.layer.bounds
207
+ if (bounds.size.width <= 0 || bounds.size.height <= 0) {
208
+ if (this.layer) this.stop()
209
+ return
210
+ }
211
+ CATransaction.begin()
212
+ CATransaction.setDisableActions(true)
213
+ try {
214
+ if (!this.layer) {
215
+ this.native = native
216
+ this.window = window
217
+ const host = this.overlay ? window.layer : native.layer
218
+ const layer = (this.layer = CAEmitterLayer.new())
219
+ layer.name = this.overlay ? 'jolt:confetti' : 'jolt:particles'
220
+ layer.frame = host.bounds
221
+ layer.emitterShape =
222
+ this.settings.source === 'center' ? kCAEmitterLayerPoint : kCAEmitterLayerLine
223
+ layer.emitterCells = NSArray.arrayWithArray(this.cells())
224
+ host.addSublayer(layer)
225
+ // Start from an empty emitter, even if its parent has been alive for hours.
226
+ layer.beginTime = host.convertTimeFromLayer(CACurrentMediaTime(), null)
227
+ if (this.overlay)
228
+ layer.emitterPosition = native.convertPointToView(
229
+ CGPointMake(
230
+ bounds.origin.x + bounds.size.width / 2,
231
+ bounds.origin.y + bounds.size.height / 2,
232
+ ),
233
+ window,
234
+ )
235
+ this.observer = Application.ios.addNotificationObserver(
236
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
237
+ this.motionChanged,
238
+ )
239
+ if (this.settings.duration !== undefined) {
240
+ // The render server stops emission even if JS is blocked. The timer
241
+ // only removes the empty layer after the last particles have expired.
242
+ layer.birthRate = 0
243
+ const emission = CABasicAnimation.animationWithKeyPath('birthRate')
244
+ emission.fromValue = 1
245
+ emission.toValue = 1
246
+ emission.duration = this.settings.duration / 1000
247
+ layer.addAnimationForKey(emission, 'jolt:emission')
248
+ this.timer = setTimeout(
249
+ this.stop,
250
+ this.settings.duration + this.settings.lifetime + 50,
251
+ )
252
+ }
253
+ }
254
+ if (!this.overlay) {
255
+ const layer = this.layer
256
+ layer.frame = bounds
257
+ layer.emitterSize = CGSizeMake(bounds.size.width, 0)
258
+ layer.emitterPosition = CGPointMake(
259
+ bounds.size.width / 2,
260
+ this.settings.source === 'top'
261
+ ? 0
262
+ : this.settings.source === 'bottom'
263
+ ? bounds.size.height
264
+ : bounds.size.height / 2,
265
+ )
266
+ }
267
+ } finally {
268
+ CATransaction.commit()
269
+ }
270
+ }
271
+
272
+ private cells(): CAEmitterCell[] {
273
+ const options = this.settings
274
+ const image = texture(options.shape)
275
+ return options.colors.map((color) => {
276
+ const cell = CAEmitterCell.emitterCell()
277
+ cell.contents = image.CGImage
278
+ cell.color = new Color(color).ios.CGColor
279
+ cell.birthRate = options.rate / options.colors.length
280
+ cell.lifetime = options.lifetime / 1000
281
+ cell.scale = options.size / 32
282
+ cell.velocity = options.velocity
283
+ cell.velocityRange = options.velocityRange
284
+ // Horizontal line emitters point upward at longitude zero; point
285
+ // emitters point right. Keep the public angle in view coordinates.
286
+ cell.emissionLongitude = radians(
287
+ options.angle + (options.source === 'center' ? 0 : 90),
288
+ )
289
+ cell.emissionRange = radians(options.spread)
290
+ cell.yAcceleration = options.gravity
291
+ cell.spin = radians(options.spin)
292
+ cell.spinRange = radians(options.spinRange)
293
+ cell.alphaSpeed = options.fadeOut ? -1 / cell.lifetime : 0
294
+ return cell
295
+ })
296
+ }
297
+ }
298
+
299
+ function texture(shape: Settings['shape']): UIImage {
300
+ const cached = textures.get(shape)
301
+ if (cached) return cached
302
+ const format = UIGraphicsImageRendererFormat.defaultFormat()
303
+ format.scale = 1
304
+ format.opaque = false
305
+ const height = shape === 'rectangle' ? 18 : 32
306
+ const renderer = UIGraphicsImageRenderer.alloc().initWithSizeFormat(
307
+ CGSizeMake(32, height),
308
+ format,
309
+ )
310
+ const image = renderer.imageWithActions((context) => {
311
+ const cg = context.CGContext
312
+ CGContextSetFillColorWithColor(cg, UIColor.whiteColor.CGColor)
313
+ if (shape === 'rectangle') CGContextFillRect(cg, CGRectMake(0, 0, 32, height))
314
+ else if (shape === 'circle') CGContextFillEllipseInRect(cg, CGRectMake(0, 0, 32, 32))
315
+ else {
316
+ CGContextSetStrokeColorWithColor(cg, UIColor.whiteColor.CGColor)
317
+ CGContextSetLineWidth(cg, 3)
318
+ CGContextStrokeEllipseInRect(cg, CGRectMake(2, 2, 28, 28))
319
+ }
320
+ })
321
+ textures.set(shape, image)
322
+ return image
323
+ }
package/src/properties.ts CHANGED
@@ -25,7 +25,7 @@ const defaults: Record<Property, number> = {
25
25
  rotateY: 0,
26
26
  }
27
27
 
28
- /** How close to its target a spring has to be to count as settled. */
28
+ /** How close to its target a spring or decay has to be to count as settled. */
29
29
  export const precision: Record<Property, number> = {
30
30
  opacity: 0.002,
31
31
  x: 0.25,