@rafaelaugusto/jolt 0.1.0-beta.0 → 0.1.0-beta.2

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 (58) hide show
  1. package/README.md +134 -14
  2. package/dist/animate.d.ts +3 -3
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +40 -19
  5. package/dist/animate.js.map +1 -1
  6. package/dist/driver/ios.d.ts.map +1 -1
  7. package/dist/driver/ios.js +19 -3
  8. package/dist/driver/ios.js.map +1 -1
  9. package/dist/engine.d.ts +7 -3
  10. package/dist/engine.d.ts.map +1 -1
  11. package/dist/engine.js +28 -5
  12. package/dist/engine.js.map +1 -1
  13. package/dist/haptics.d.ts +18 -0
  14. package/dist/haptics.d.ts.map +1 -0
  15. package/dist/haptics.js +96 -0
  16. package/dist/haptics.js.map +1 -0
  17. package/dist/index.d.ts +9 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/particles.d.ts +39 -0
  22. package/dist/particles.d.ts.map +1 -0
  23. package/dist/particles.js +264 -0
  24. package/dist/particles.js.map +1 -0
  25. package/dist/presets.d.ts +16 -1
  26. package/dist/presets.d.ts.map +1 -1
  27. package/dist/presets.js +24 -6
  28. package/dist/presets.js.map +1 -1
  29. package/dist/press.d.ts.map +1 -1
  30. package/dist/press.js +3 -1
  31. package/dist/press.js.map +1 -1
  32. package/dist/sequence.d.ts +3 -2
  33. package/dist/sequence.d.ts.map +1 -1
  34. package/dist/sequence.js +6 -5
  35. package/dist/sequence.js.map +1 -1
  36. package/dist/shimmer.d.ts +12 -0
  37. package/dist/shimmer.d.ts.map +1 -0
  38. package/dist/shimmer.js +160 -0
  39. package/dist/shimmer.js.map +1 -0
  40. package/dist/symbol-effect.d.ts +23 -0
  41. package/dist/symbol-effect.d.ts.map +1 -0
  42. package/dist/symbol-effect.js +186 -0
  43. package/dist/symbol-effect.js.map +1 -0
  44. package/dist/types.d.ts +5 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +6 -1
  47. package/src/animate.ts +64 -19
  48. package/src/driver/ios.ts +23 -3
  49. package/src/engine.ts +26 -6
  50. package/src/haptics.ts +125 -0
  51. package/src/index.ts +13 -2
  52. package/src/particles.ts +323 -0
  53. package/src/presets.ts +31 -7
  54. package/src/press.ts +7 -1
  55. package/src/sequence.ts +8 -5
  56. package/src/shimmer.ts +179 -0
  57. package/src/symbol-effect.ts +225 -0
  58. package/src/types.ts +5 -0
package/src/shimmer.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { Application, isIOS, type View } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import type { Target } from './types.js'
4
+
5
+ export interface ShimmerOptions {
6
+ /** Milliseconds per sweep. Defaults to 1500. */
7
+ duration?: number
8
+ }
9
+
10
+ const running = new WeakMap<View, () => void>()
11
+ const key = 'jolt:shimmer'
12
+
13
+ // NativeScript saves a mask here while applying clip-path or non-uniform borders.
14
+ type MaskedView = UIView & { originalMask?: CALayer | null }
15
+
16
+ /**
17
+ * Sweeps a native gradient mask across an iOS placeholder until stopped.
18
+ * Waits for layout, respects Reduce Motion and pauses while the view is unloaded.
19
+ * Android is a no-op. Returns an idempotent cleanup function.
20
+ */
21
+ export function shimmer(target: Target, options: ShimmerOptions = {}): () => void {
22
+ const view = toView(target)
23
+ const duration = options.duration ?? 1500
24
+ if (!Number.isFinite(duration) || duration <= 0)
25
+ throw new RangeError('Shimmer duration must be a positive finite number.')
26
+ running.get(view)?.()
27
+ if (!isIOS) return () => {}
28
+ return new Shimmer(view, duration).stop
29
+ }
30
+
31
+ class Shimmer {
32
+ private stopped = false
33
+ private queued = false
34
+ private waitingForLoad: boolean
35
+ private native: MaskedView | undefined
36
+ private owner: CALayer | undefined
37
+ private gradient: CAGradientLayer | undefined
38
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
39
+
40
+ constructor(
41
+ private readonly view: View,
42
+ private readonly duration: number,
43
+ ) {
44
+ this.waitingForLoad = !view.isLoaded
45
+ running.set(view, this.stop)
46
+ view.on('loaded', this.loaded)
47
+ view.on('layoutChanged', this.schedule)
48
+ view.on('unloaded', this.unloaded)
49
+ view.style.on('backgroundInternalChange', this.schedule)
50
+ this.update()
51
+ }
52
+
53
+ stop = () => {
54
+ if (this.stopped) return
55
+ this.stopped = true
56
+ const { view } = this
57
+ view.off('loaded', this.loaded)
58
+ view.off('layoutChanged', this.schedule)
59
+ view.off('unloaded', this.unloaded)
60
+ view.style.off('backgroundInternalChange', this.schedule)
61
+ if (running.get(view) === this.stop) running.delete(view)
62
+ this.unloaded()
63
+ }
64
+
65
+ private loaded = () => {
66
+ this.waitingForLoad = false
67
+ this.schedule()
68
+ }
69
+
70
+ private unloaded = () => {
71
+ this.waitingForLoad = true
72
+ if (this.observer) {
73
+ Application.ios.removeNotificationObserver(
74
+ this.observer,
75
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
76
+ )
77
+ this.observer = undefined
78
+ }
79
+ this.remove()
80
+ }
81
+
82
+ private schedule = () => {
83
+ if (this.stopped || this.queued) return
84
+ this.queued = true
85
+ // Core raises layoutChanged before redrawing the background and its mask.
86
+ // Attach after that pass, coalescing events from the same layout.
87
+ void Promise.resolve().then(() => {
88
+ this.queued = false
89
+ this.update()
90
+ })
91
+ }
92
+
93
+ private update = () => {
94
+ if (this.stopped || this.waitingForLoad || !this.view.isLoaded) return
95
+ const native = this.view.nativeViewProtected as MaskedView | undefined
96
+ if (!native) return
97
+ this.observer ??= Application.ios.addNotificationObserver(
98
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
99
+ this.update,
100
+ )
101
+ if (UIAccessibilityIsReduceMotionEnabled()) return this.remove()
102
+ const bounds = native.layer.bounds
103
+ if (bounds.size.width <= 0 || bounds.size.height <= 0) return this.remove()
104
+ if (this.native && this.native !== native) this.remove()
105
+
106
+ CATransaction.begin()
107
+ CATransaction.setDisableActions(true)
108
+ try {
109
+ this.native = native
110
+ const gradient = (this.gradient ??= this.createGradient())
111
+ // Leave Core's mask at the root so it can keep updating its path on resize.
112
+ let owner = native.layer
113
+ while (owner.mask && owner.mask !== gradient) owner = owner.mask
114
+ if (this.owner && this.owner !== owner && this.owner.mask === gradient)
115
+ this.owner.mask = null
116
+ this.owner = owner
117
+ owner.mask = gradient
118
+ // Don't let a later background redraw resurrect our mask after cleanup.
119
+ if (native.originalMask === gradient) native.originalMask = null
120
+
121
+ // Core's CAShapeLayer masks draw a path with zero bounds. In that case the
122
+ // path uses the view's coordinates; other masks have their own bounds.
123
+ const area =
124
+ owner.bounds.size.width > 0 && owner.bounds.size.height > 0
125
+ ? owner.bounds
126
+ : bounds
127
+ gradient.frame = CGRectMake(
128
+ area.origin.x - area.size.width,
129
+ area.origin.y,
130
+ area.size.width * 3,
131
+ area.size.height,
132
+ )
133
+ } finally {
134
+ CATransaction.commit()
135
+ }
136
+ }
137
+
138
+ private createGradient(): CAGradientLayer {
139
+ const gradient = CAGradientLayer.new()
140
+ const dim = UIColor.colorWithWhiteAlpha(1, 0.35).CGColor
141
+ gradient.colors = NSArray.arrayWithArray([dim, UIColor.whiteColor.CGColor, dim])
142
+ gradient.startPoint = CGPointMake(0, 0.5)
143
+ gradient.endPoint = CGPointMake(1, 0.5)
144
+
145
+ // A three-width mask lets the band enter and leave while every stop stays
146
+ // within the documented 0...1 range for CAGradientLayer.locations.
147
+ const from = NSArray.arrayWithArray([0, 0.125, 0.25])
148
+ const to = NSArray.arrayWithArray([0.75, 0.875, 1])
149
+ gradient.locations = from
150
+ const animation = CABasicAnimation.animationWithKeyPath('locations')
151
+ animation.fromValue = from
152
+ animation.toValue = to
153
+ animation.duration = this.duration / 1000
154
+ animation.repeatCount = Infinity
155
+ animation.removedOnCompletion = false
156
+ animation.timingFunction = CAMediaTimingFunction.functionWithName(
157
+ kCAMediaTimingFunctionLinear,
158
+ )
159
+ gradient.addAnimationForKey(animation, key)
160
+ return gradient
161
+ }
162
+
163
+ private remove() {
164
+ const { gradient, owner, native } = this
165
+ if (!gradient) return
166
+ CATransaction.begin()
167
+ CATransaction.setDisableActions(true)
168
+ try {
169
+ gradient.removeAnimationForKey(key)
170
+ if (owner?.mask === gradient) owner.mask = null
171
+ if (native?.originalMask === gradient) native.originalMask = null
172
+ } finally {
173
+ CATransaction.commit()
174
+ }
175
+ this.owner = undefined
176
+ this.gradient = undefined
177
+ this.native = undefined
178
+ }
179
+ }
@@ -0,0 +1,225 @@
1
+ import { Image, ImageSource, isIOS } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import type { Animation } from './engine.js'
4
+ import type { Target } from './types.js'
5
+
6
+ export type SymbolEffectName = 'bounce' | 'pulse' | 'scale' | 'variableColor'
7
+
8
+ export type SymbolEffect = SymbolEffectName | { replace: `sys://${string}` }
9
+
10
+ export interface SymbolEffectOptions {
11
+ /** Native playback speed multiplier. Defaults to 1. */
12
+ speed?: number
13
+ /** Repeat bounce, pulse or variableColor until stopped. Defaults to false. */
14
+ repeat?: boolean
15
+ }
16
+
17
+ const running = new WeakMap<Image, SymbolAnimation>()
18
+ const names: readonly string[] = ['bounce', 'pulse', 'scale', 'variableColor']
19
+
20
+ /**
21
+ * Plays an iOS 17+ SF Symbol effect on a NativeScript Image.
22
+ * Scale holds until stopped; replacements also update the Image's src.
23
+ *
24
+ * ```ts
25
+ * symbolEffect(icon, 'bounce')
26
+ * symbolEffect(icon, { replace: 'sys://heart.fill' })
27
+ * ```
28
+ */
29
+ export function symbolEffect(
30
+ target: Target,
31
+ effect: SymbolEffect,
32
+ options: SymbolEffectOptions = {},
33
+ ): Animation {
34
+ const view = toView(target)
35
+ if (!(view instanceof Image)) throw new TypeError('symbolEffect expects an Image.')
36
+ if (
37
+ typeof effect === 'string'
38
+ ? !names.includes(effect)
39
+ : !effect ||
40
+ typeof effect.replace !== 'string' ||
41
+ !/^sys:\/\/[^\s/]+$/.test(effect.replace)
42
+ ) {
43
+ throw new TypeError('Expected a symbol effect name or { replace: "sys://name" }.')
44
+ }
45
+ const speed = options.speed ?? 1
46
+ if (!Number.isFinite(speed) || speed <= 0)
47
+ throw new RangeError('Symbol effect speed must be a positive finite number.')
48
+ if (options.repeat !== undefined && typeof options.repeat !== 'boolean')
49
+ throw new TypeError('Symbol effect repeat must be a boolean.')
50
+ if (options.repeat && (typeof effect !== 'string' || effect === 'scale'))
51
+ throw new TypeError('Only bounce, pulse and variableColor can repeat.')
52
+
53
+ return new SymbolAnimation(view, effect, speed, options.repeat ?? false)
54
+ }
55
+
56
+ class SymbolAnimation implements Animation {
57
+ readonly finished: Promise<boolean>
58
+ private resolve!: (finished: boolean) => void
59
+ private settled = false
60
+ private started = false
61
+ private changingSource = false
62
+ private waitingForLoad: boolean
63
+ private native: UIImageView | undefined
64
+ private nativeEffect: NSSymbolEffect | undefined
65
+ private replacement: UIImage | undefined
66
+
67
+ constructor(
68
+ private readonly view: Image,
69
+ private readonly effect: SymbolEffect,
70
+ private readonly speed: number,
71
+ private readonly repeat: boolean,
72
+ ) {
73
+ this.finished = new Promise((resolve) => (this.resolve = resolve))
74
+ this.waitingForLoad = !view.isLoaded
75
+ running.get(view)?.stop()
76
+ running.set(view, this)
77
+ view.on('unloaded', this.stop)
78
+ view.on('srcChange', this.sourceChanged)
79
+ view.on('imageSourceChange', this.imageChanged)
80
+ view.on('loaded', this.loaded)
81
+ view.on('isLoadingChange', this.start)
82
+ this.start()
83
+ }
84
+
85
+ then<A = boolean, B = never>(
86
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
87
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
88
+ ): Promise<A | B> {
89
+ return this.finished.then(onfulfilled, onrejected)
90
+ }
91
+
92
+ stop = () => this.end(false, true)
93
+
94
+ private sourceChanged = () => {
95
+ if (!this.changingSource) this.stop()
96
+ }
97
+
98
+ private imageChanged = () => {
99
+ if (this.changingSource) return
100
+ if (this.started) this.stop()
101
+ else this.start()
102
+ }
103
+
104
+ private loaded = () => {
105
+ this.waitingForLoad = false
106
+ this.start()
107
+ }
108
+
109
+ private start = () => {
110
+ if (this.settled || this.started) return
111
+ if (!isIOS) return this.end(false)
112
+ const { view, effect } = this
113
+ // isLoaded becomes true before NativeScript resumes native property updates.
114
+ // Wait for the loaded event so intermediate image resets don't start playback.
115
+ if (
116
+ this.waitingForLoad ||
117
+ !view.isLoaded ||
118
+ !view.nativeViewProtected ||
119
+ view.isLoading
120
+ )
121
+ return
122
+ this.started = true
123
+ const native = (this.native = view.nativeViewProtected as UIImageView)
124
+ const reducedMotion = UIAccessibilityIsReduceMotionEnabled()
125
+
126
+ if (typeof effect !== 'string') {
127
+ // Resolve first so a symbol absent on this OS doesn't clear the current image.
128
+ if (!ImageSource.fromSystemImageSync(effect.replace.slice(6))?.ios)
129
+ return this.end(false)
130
+ const previous = native.image
131
+ const loadMode = view.loadMode
132
+ this.changingSource = true
133
+ try {
134
+ // Let NativeScript update its source, tint, configuration and pending loads.
135
+ // Both assignments happen in this turn, before UIKit draws the next frame.
136
+ view.loadMode = 'sync'
137
+ view.src = effect.replace
138
+ } finally {
139
+ view.loadMode = loadMode
140
+ this.changingSource = false
141
+ }
142
+ // A source-change listener may have started another effect or changed the src.
143
+ if (this.settled) return
144
+ if (view.src !== effect.replace) return this.end(false)
145
+ const image = native.image
146
+ if (!image) return this.end(false)
147
+ if (
148
+ reducedMotion ||
149
+ !previous?.symbolImage ||
150
+ !native.respondsToSelector(
151
+ 'setSymbolImage:withContentTransition:options:completion:',
152
+ )
153
+ )
154
+ return this.end(true)
155
+
156
+ this.replacement = image
157
+ native.image = previous
158
+ native.setSymbolImageWithContentTransitionOptionsCompletion(
159
+ image,
160
+ NSSymbolReplaceContentTransition.transition(),
161
+ this.options(),
162
+ this.completed,
163
+ )
164
+ return
165
+ }
166
+
167
+ if (
168
+ reducedMotion ||
169
+ !native.image?.symbolImage ||
170
+ !native.respondsToSelector('addSymbolEffect:options:animated:completion:')
171
+ )
172
+ return this.end(false)
173
+
174
+ const factories = {
175
+ bounce: NSSymbolBounceEffect,
176
+ pulse: NSSymbolPulseEffect,
177
+ scale: NSSymbolScaleEffect,
178
+ variableColor: NSSymbolVariableColorEffect,
179
+ }
180
+ this.nativeEffect = factories[effect].effect()
181
+ native.addSymbolEffectOptionsAnimatedCompletion(
182
+ this.nativeEffect,
183
+ this.options(),
184
+ true,
185
+ this.completed,
186
+ )
187
+ }
188
+
189
+ private options(): NSSymbolEffectOptions {
190
+ const options = this.repeat
191
+ ? NSSymbolEffectOptions.optionsWithRepeating()
192
+ : NSSymbolEffectOptions.optionsWithNonRepeating()
193
+ return options.optionsWithSpeed(this.speed)
194
+ }
195
+
196
+ private completed = (context: UISymbolEffectCompletionContext) => {
197
+ // Scale's entrance can complete while the effect is still holding its state.
198
+ if (context.finished && (this.effect === 'scale' || this.repeat)) return
199
+ this.end(context.finished)
200
+ }
201
+
202
+ private end(finished: boolean, remove = false) {
203
+ if (this.settled) return
204
+ this.settled = true
205
+ const { view, native, nativeEffect, replacement } = this
206
+ view.off('loaded', this.loaded)
207
+ view.off('isLoadingChange', this.start)
208
+ view.off('unloaded', this.stop)
209
+ view.off('srcChange', this.sourceChanged)
210
+ view.off('imageSourceChange', this.imageChanged)
211
+ if (running.get(view) === this) running.delete(view)
212
+ if (remove && native) {
213
+ if (nativeEffect) native.removeSymbolEffectOfType(nativeEffect)
214
+ // Content transitions have no removal API. Keep the destination icon.
215
+ if (replacement && native.image === replacement) {
216
+ native.image = null
217
+ native.image = replacement
218
+ }
219
+ }
220
+ this.native = undefined
221
+ this.nativeEffect = undefined
222
+ this.replacement = undefined
223
+ this.resolve(finished)
224
+ }
225
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { View } from '@nativescript/core'
2
+ import type { Haptic } from './haptics.js'
2
3
  import type { SpringName, Transition } from './transition.js'
3
4
 
4
5
  /** A value to animate to, or keyframes. `null` stands for the value the view has when the animation starts. */
@@ -34,6 +35,10 @@ export interface Stagger {
34
35
  export type AnimateOptions = Transition & {
35
36
  [K in keyof Keyframes]?: Transition | SpringName
36
37
  } & {
38
+ /** Offset translations/rotations and multiply scale/opacity by their values at the start. */
39
+ relative?: boolean
37
40
  /** With several targets, delays each one by this much more than the previous. */
38
41
  stagger?: number | Stagger
42
+ /** iOS impact feedback at arrival, or at explicit points in a finite animation. */
43
+ haptic?: Haptic
39
44
  }