@rafaelaugusto/jolt 0.1.0-beta.1 → 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.
@@ -0,0 +1,186 @@
1
+ import { Image, ImageSource, isIOS } from '@nativescript/core';
2
+ import { toView } from './animate.js';
3
+ const running = new WeakMap();
4
+ const names = ['bounce', 'pulse', 'scale', 'variableColor'];
5
+ /**
6
+ * Plays an iOS 17+ SF Symbol effect on a NativeScript Image.
7
+ * Scale holds until stopped; replacements also update the Image's src.
8
+ *
9
+ * ```ts
10
+ * symbolEffect(icon, 'bounce')
11
+ * symbolEffect(icon, { replace: 'sys://heart.fill' })
12
+ * ```
13
+ */
14
+ export function symbolEffect(target, effect, options = {}) {
15
+ const view = toView(target);
16
+ if (!(view instanceof Image))
17
+ throw new TypeError('symbolEffect expects an Image.');
18
+ if (typeof effect === 'string'
19
+ ? !names.includes(effect)
20
+ : !effect ||
21
+ typeof effect.replace !== 'string' ||
22
+ !/^sys:\/\/[^\s/]+$/.test(effect.replace)) {
23
+ throw new TypeError('Expected a symbol effect name or { replace: "sys://name" }.');
24
+ }
25
+ const speed = options.speed ?? 1;
26
+ if (!Number.isFinite(speed) || speed <= 0)
27
+ throw new RangeError('Symbol effect speed must be a positive finite number.');
28
+ if (options.repeat !== undefined && typeof options.repeat !== 'boolean')
29
+ throw new TypeError('Symbol effect repeat must be a boolean.');
30
+ if (options.repeat && (typeof effect !== 'string' || effect === 'scale'))
31
+ throw new TypeError('Only bounce, pulse and variableColor can repeat.');
32
+ return new SymbolAnimation(view, effect, speed, options.repeat ?? false);
33
+ }
34
+ class SymbolAnimation {
35
+ view;
36
+ effect;
37
+ speed;
38
+ repeat;
39
+ finished;
40
+ resolve;
41
+ settled = false;
42
+ started = false;
43
+ changingSource = false;
44
+ waitingForLoad;
45
+ native;
46
+ nativeEffect;
47
+ replacement;
48
+ constructor(view, effect, speed, repeat) {
49
+ this.view = view;
50
+ this.effect = effect;
51
+ this.speed = speed;
52
+ this.repeat = repeat;
53
+ this.finished = new Promise((resolve) => (this.resolve = resolve));
54
+ this.waitingForLoad = !view.isLoaded;
55
+ running.get(view)?.stop();
56
+ running.set(view, this);
57
+ view.on('unloaded', this.stop);
58
+ view.on('srcChange', this.sourceChanged);
59
+ view.on('imageSourceChange', this.imageChanged);
60
+ view.on('loaded', this.loaded);
61
+ view.on('isLoadingChange', this.start);
62
+ this.start();
63
+ }
64
+ then(onfulfilled, onrejected) {
65
+ return this.finished.then(onfulfilled, onrejected);
66
+ }
67
+ stop = () => this.end(false, true);
68
+ sourceChanged = () => {
69
+ if (!this.changingSource)
70
+ this.stop();
71
+ };
72
+ imageChanged = () => {
73
+ if (this.changingSource)
74
+ return;
75
+ if (this.started)
76
+ this.stop();
77
+ else
78
+ this.start();
79
+ };
80
+ loaded = () => {
81
+ this.waitingForLoad = false;
82
+ this.start();
83
+ };
84
+ start = () => {
85
+ if (this.settled || this.started)
86
+ return;
87
+ if (!isIOS)
88
+ return this.end(false);
89
+ const { view, effect } = this;
90
+ // isLoaded becomes true before NativeScript resumes native property updates.
91
+ // Wait for the loaded event so intermediate image resets don't start playback.
92
+ if (this.waitingForLoad ||
93
+ !view.isLoaded ||
94
+ !view.nativeViewProtected ||
95
+ view.isLoading)
96
+ return;
97
+ this.started = true;
98
+ const native = (this.native = view.nativeViewProtected);
99
+ const reducedMotion = UIAccessibilityIsReduceMotionEnabled();
100
+ if (typeof effect !== 'string') {
101
+ // Resolve first so a symbol absent on this OS doesn't clear the current image.
102
+ if (!ImageSource.fromSystemImageSync(effect.replace.slice(6))?.ios)
103
+ return this.end(false);
104
+ const previous = native.image;
105
+ const loadMode = view.loadMode;
106
+ this.changingSource = true;
107
+ try {
108
+ // Let NativeScript update its source, tint, configuration and pending loads.
109
+ // Both assignments happen in this turn, before UIKit draws the next frame.
110
+ view.loadMode = 'sync';
111
+ view.src = effect.replace;
112
+ }
113
+ finally {
114
+ view.loadMode = loadMode;
115
+ this.changingSource = false;
116
+ }
117
+ // A source-change listener may have started another effect or changed the src.
118
+ if (this.settled)
119
+ return;
120
+ if (view.src !== effect.replace)
121
+ return this.end(false);
122
+ const image = native.image;
123
+ if (!image)
124
+ return this.end(false);
125
+ if (reducedMotion ||
126
+ !previous?.symbolImage ||
127
+ !native.respondsToSelector('setSymbolImage:withContentTransition:options:completion:'))
128
+ return this.end(true);
129
+ this.replacement = image;
130
+ native.image = previous;
131
+ native.setSymbolImageWithContentTransitionOptionsCompletion(image, NSSymbolReplaceContentTransition.transition(), this.options(), this.completed);
132
+ return;
133
+ }
134
+ if (reducedMotion ||
135
+ !native.image?.symbolImage ||
136
+ !native.respondsToSelector('addSymbolEffect:options:animated:completion:'))
137
+ return this.end(false);
138
+ const factories = {
139
+ bounce: NSSymbolBounceEffect,
140
+ pulse: NSSymbolPulseEffect,
141
+ scale: NSSymbolScaleEffect,
142
+ variableColor: NSSymbolVariableColorEffect,
143
+ };
144
+ this.nativeEffect = factories[effect].effect();
145
+ native.addSymbolEffectOptionsAnimatedCompletion(this.nativeEffect, this.options(), true, this.completed);
146
+ };
147
+ options() {
148
+ const options = this.repeat
149
+ ? NSSymbolEffectOptions.optionsWithRepeating()
150
+ : NSSymbolEffectOptions.optionsWithNonRepeating();
151
+ return options.optionsWithSpeed(this.speed);
152
+ }
153
+ completed = (context) => {
154
+ // Scale's entrance can complete while the effect is still holding its state.
155
+ if (context.finished && (this.effect === 'scale' || this.repeat))
156
+ return;
157
+ this.end(context.finished);
158
+ };
159
+ end(finished, remove = false) {
160
+ if (this.settled)
161
+ return;
162
+ this.settled = true;
163
+ const { view, native, nativeEffect, replacement } = this;
164
+ view.off('loaded', this.loaded);
165
+ view.off('isLoadingChange', this.start);
166
+ view.off('unloaded', this.stop);
167
+ view.off('srcChange', this.sourceChanged);
168
+ view.off('imageSourceChange', this.imageChanged);
169
+ if (running.get(view) === this)
170
+ running.delete(view);
171
+ if (remove && native) {
172
+ if (nativeEffect)
173
+ native.removeSymbolEffectOfType(nativeEffect);
174
+ // Content transitions have no removal API. Keep the destination icon.
175
+ if (replacement && native.image === replacement) {
176
+ native.image = null;
177
+ native.image = replacement;
178
+ }
179
+ }
180
+ this.native = undefined;
181
+ this.nativeEffect = undefined;
182
+ this.replacement = undefined;
183
+ this.resolve(finished);
184
+ }
185
+ }
186
+ //# sourceMappingURL=symbol-effect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"symbol-effect.js","sourceRoot":"","sources":["../src/symbol-effect.ts"],"names":[],"mappings":"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"}
package/dist/types.d.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
  /** A value to animate to, or keyframes. `null` stands for the value the view has when the animation starts. */
4
5
  export type KeyframeValue = number | readonly (number | null)[];
@@ -36,5 +37,7 @@ export type AnimateOptions = Transition & {
36
37
  relative?: boolean;
37
38
  /** With several targets, delays each one by this much more than the previous. */
38
39
  stagger?: number | Stagger;
40
+ /** iOS impact feedback at arrival, or at explicit points in a finite animation. */
41
+ haptic?: Haptic;
39
42
  };
40
43
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAC9C,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAE7D,+GAA+G;AAC/G,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAA;AAE/D,MAAM,WAAW,SAAS;IACxB,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,qCAAqC;IACrC,CAAC,CAAC,EAAE,aAAa,CAAA;IACjB,qCAAqC;IACrC,CAAC,CAAC,EAAE,aAAa,CAAA;IACjB,2CAA2C;IAC3C,KAAK,CAAC,EAAE,aAAa,CAAA;IACrB,MAAM,CAAC,EAAE,aAAa,CAAA;IACtB,MAAM,CAAC,EAAE,aAAa,CAAA;IACtB,eAAe;IACf,MAAM,CAAC,EAAE,aAAa,CAAA;IACtB,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,OAAO,CAAC,EAAE,aAAa,CAAA;CACxB;AAED,+FAA+F;AAC/F,MAAM,MAAM,MAAM,GAChB,IAAI,GAAG;IAAE,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,aAAa,EAAE,IAAI,CAAA;CAAE,CAAA;AAEzE,MAAM,WAAW,OAAO;IACtB,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ,iCAAiC;IACjC,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAA;CAC5C;AAED,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG;KACvC,CAAC,IAAI,MAAM,SAAS,CAAC,CAAC,EAAE,UAAU,GAAG,UAAU;CACjD,GAAG;IACF,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAC3B,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAC9C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAE7D,+GAA+G;AAC/G,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAA;AAE/D,MAAM,WAAW,SAAS;IACxB,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,qCAAqC;IACrC,CAAC,CAAC,EAAE,aAAa,CAAA;IACjB,qCAAqC;IACrC,CAAC,CAAC,EAAE,aAAa,CAAA;IACjB,2CAA2C;IAC3C,KAAK,CAAC,EAAE,aAAa,CAAA;IACrB,MAAM,CAAC,EAAE,aAAa,CAAA;IACtB,MAAM,CAAC,EAAE,aAAa,CAAA;IACtB,eAAe;IACf,MAAM,CAAC,EAAE,aAAa,CAAA;IACtB,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,OAAO,CAAC,EAAE,aAAa,CAAA;CACxB;AAED,+FAA+F;AAC/F,MAAM,MAAM,MAAM,GAChB,IAAI,GAAG;IAAE,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,aAAa,EAAE,IAAI,CAAA;CAAE,CAAA;AAEzE,MAAM,WAAW,OAAO;IACtB,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ,iCAAiC;IACjC,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAA;CAC5C;AAED,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG;KACvC,CAAC,IAAI,MAAM,SAAS,CAAC,CAAC,EAAE,UAAU,GAAG,UAAU;CACjD,GAAG;IACF,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IAC1B,mFAAmF;IACnF,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rafaelaugusto/jolt",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "Native animations for NativeScript: presets, springs, keyframes and stagger in one call.",
5
5
  "keywords": [
6
6
  "nativescript",
@@ -39,6 +39,7 @@
39
39
  "build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
40
40
  "dev": "tsc -p tsconfig.build.json --watch",
41
41
  "typecheck": "tsc --noEmit",
42
+ "benchmark:typecheck": "tsc -p benchmarks/tsconfig.json",
42
43
  "test": "vitest run",
43
44
  "test:watch": "vitest",
44
45
  "format": "prettier --write .",
package/src/animate.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type Plan,
9
9
  } from './engine.js'
10
10
  import { getPreset, type PresetName } from './presets.js'
11
+ import { resolveHaptic, type Haptic } from './haptics.js'
11
12
  import { isProperty, type Property } from './properties.js'
12
13
  import {
13
14
  hasCurve,
@@ -30,6 +31,7 @@ interface Options {
30
31
  overrides: Partial<Record<keyof Keyframes, Transition>>
31
32
  stagger: number | Stagger | undefined
32
33
  relative: boolean | undefined
34
+ haptic: Haptic | undefined
33
35
  }
34
36
 
35
37
  /**
@@ -47,12 +49,17 @@ export function animate(
47
49
  animation: Keyframes | PresetName,
48
50
  options?: AnimateOptions | SpringName,
49
51
  ): Animation {
50
- const { keyframes, base, overrides, stagger, relative } = prepare(animation, options)
52
+ const { keyframes, base, overrides, stagger, relative, haptic } = prepare(
53
+ animation,
54
+ options,
55
+ )
56
+ const feedback = resolveHaptic(haptic)
51
57
 
52
58
  if (!isList(target)) {
53
59
  return new ViewAnimation(
54
60
  toView(target),
55
61
  plan(keyframes, base, overrides, 0, relative),
62
+ feedback,
56
63
  )
57
64
  }
58
65
 
@@ -69,6 +76,7 @@ export function animate(
69
76
  staggerDelay(stagger, index, views.length),
70
77
  relative,
71
78
  ),
79
+ feedback,
72
80
  ),
73
81
  ),
74
82
  )
@@ -133,33 +141,43 @@ function prepare(
133
141
  : { ...own.overrides, ...user.overrides },
134
142
  stagger: user.stagger ?? own.stagger,
135
143
  relative: user.relative ?? own.relative,
144
+ haptic: user.haptic ?? own.haptic,
136
145
  }
137
146
  }
138
147
 
139
148
  function normalize(options: AnimateOptions | SpringName | undefined): Options {
140
149
  if (options === undefined)
141
- return { base: {}, overrides: {}, stagger: undefined, relative: undefined }
150
+ return {
151
+ base: {},
152
+ overrides: {},
153
+ stagger: undefined,
154
+ relative: undefined,
155
+ haptic: undefined,
156
+ }
142
157
  if (typeof options === 'string')
143
158
  return {
144
159
  base: spring(options),
145
160
  overrides: {},
146
161
  stagger: undefined,
147
162
  relative: undefined,
163
+ haptic: undefined,
148
164
  }
149
165
 
150
166
  const base: Record<string, unknown> = {}
151
167
  const overrides: Options['overrides'] = {}
152
168
  let stagger: Options['stagger']
153
169
  let relative: Options['relative']
170
+ let haptic: Options['haptic']
154
171
  for (const [key, value] of Object.entries(options)) {
155
172
  if (value === undefined) continue
156
173
  if (key === 'stagger') stagger = value as Options['stagger']
157
174
  else if (key === 'relative') relative = value as Options['relative']
175
+ else if (key === 'haptic') haptic = value as Options['haptic']
158
176
  else if (key === 'scale' || isProperty(key)) {
159
177
  overrides[key] = typeof value === 'string' ? spring(value as SpringName) : value
160
178
  } else base[key] = value
161
179
  }
162
- return { base, overrides, stagger, relative }
180
+ return { base, overrides, stagger, relative, haptic }
163
181
  }
164
182
 
165
183
  function plan(
package/src/driver/ios.ts CHANGED
@@ -58,6 +58,17 @@ export function createIOSDriver(): Driver {
58
58
  }
59
59
 
60
60
  const animation = CAKeyframeAnimation.animationWithKeyPath(keyPaths[track.property])
61
+ // iOS 15+. The app must opt in through CADisableMinimumFrameDurationOnPhone.
62
+ if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
63
+ const nativeView = view.nativeViewProtected as UIView
64
+ const screen = nativeView.window?.screen ?? UIScreen.mainScreen
65
+ const maximum = Math.min(120, screen.maximumFramesPerSecond)
66
+ animation.preferredFrameRateRange = CAFrameRateRangeMake(
67
+ Math.min(80, maximum),
68
+ maximum,
69
+ maximum,
70
+ )
71
+ }
61
72
  animation.values = NSArray.arrayWithArray(
62
73
  track.values.map((value) => (rotation ? radians(value) : value)),
63
74
  )
package/src/engine.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { View } from '@nativescript/core'
2
2
  import { getDriver } from './driver/index.js'
3
+ import { playHaptics, type HapticOptions } from './haptics.js'
3
4
  import { readValue, writeValue, type Property } from './properties.js'
4
5
  import { createTrack, type Track } from './track.js'
5
6
  import type { ResolvedTransition } from './transition.js'
@@ -7,11 +8,11 @@ import type { ResolvedTransition } from './transition.js'
7
8
  export interface Animation extends PromiseLike<boolean> {
8
9
  /**
9
10
  * Resolves with `true` once the animation played to the end, or with `false`
10
- * if it was stopped or another animation took over one of its properties.
11
+ * if it was stopped, superseded or skipped.
11
12
  * Never rejects.
12
13
  */
13
14
  readonly finished: Promise<boolean>
14
- /** Stops where it is. The view keeps the values it had at that moment. */
15
+ /** Stops playback. Property animations keep their current values; symbol effects are removed. */
15
16
  stop(): void
16
17
  }
17
18
 
@@ -56,10 +57,12 @@ export class ViewAnimation implements Animation {
56
57
  private cancelWait: (() => void) | undefined
57
58
  private arrive!: () => void
58
59
  private cancelArrival: (() => void) | undefined
60
+ private haptics: ReturnType<typeof playHaptics>
59
61
 
60
62
  constructor(
61
63
  readonly view: View,
62
64
  plans: readonly Plan[],
65
+ private readonly haptic?: HapticOptions,
63
66
  ) {
64
67
  this.finished = new Promise((resolve) => (this.resolve = resolve))
65
68
  setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
@@ -77,6 +80,7 @@ export class ViewAnimation implements Animation {
77
80
  stop() {
78
81
  if (this.settled) return
79
82
  this.cancelWait?.()
83
+ this.haptics?.cancel()
80
84
  const now = getDriver().now()
81
85
  for (const entry of this.entries) {
82
86
  if (!entry.done) release(entry, now, true)
@@ -86,7 +90,10 @@ export class ViewAnimation implements Animation {
86
90
  }
87
91
 
88
92
  ended(finished: boolean) {
89
- if (!finished) this.complete = false
93
+ if (!finished) {
94
+ this.complete = false
95
+ this.haptics?.cancel()
96
+ }
90
97
  if (--this.pending === 0) this.settle()
91
98
  }
92
99
 
@@ -137,6 +144,8 @@ export class ViewAnimation implements Animation {
137
144
  this.pending++
138
145
  }
139
146
 
147
+ this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now)
148
+
140
149
  for (const entry of this.entries) {
141
150
  entry.handle = driver.play(this.view, entry.track, now, () => {
142
151
  if (entry.done) return
@@ -174,6 +183,9 @@ export class ViewAnimation implements Animation {
174
183
  if (this.settled) return
175
184
  this.settled = true
176
185
  this.cancelArrival?.()
186
+ if (this.complete) this.haptics?.finish()
187
+ else this.haptics?.cancel()
188
+ this.haptics = undefined
177
189
  this.arrive()
178
190
  this.resolve(this.complete)
179
191
  }
package/src/haptics.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { isIOS, type View } from '@nativescript/core'
2
+ import type { Driver } from './driver/index.js'
3
+ import type { Track } from './track.js'
4
+
5
+ export type HapticStyle = 'light' | 'medium' | 'heavy'
6
+
7
+ export interface HapticOptions {
8
+ style: HapticStyle
9
+ /** Points from 0 to 1 over the finite animation, excluding its initial delay. Defaults to arrival. */
10
+ times?: readonly number[]
11
+ }
12
+
13
+ export type Haptic = HapticStyle | HapticOptions | false
14
+
15
+ /** Validate before any running animation is interrupted. */
16
+ export function resolveHaptic(haptic: Haptic | undefined): HapticOptions | undefined {
17
+ if (haptic === undefined || haptic === false) return undefined
18
+ const options = typeof haptic === 'string' ? { style: haptic } : haptic
19
+ if (!options || !['light', 'medium', 'heavy'].includes(options.style))
20
+ throw new TypeError('Haptic style must be light, medium or heavy.')
21
+ const times = options.times
22
+ if (
23
+ times !== undefined &&
24
+ (!Array.isArray(times) ||
25
+ times.length === 0 ||
26
+ Array.from(times).some(
27
+ (time, i) =>
28
+ !Number.isFinite(time) ||
29
+ time <= 0 ||
30
+ time > 1 ||
31
+ (i > 0 && time <= times[i - 1]!),
32
+ ))
33
+ )
34
+ throw new RangeError(
35
+ 'Haptic times must be increasing points greater than 0 and at most 1.',
36
+ )
37
+ return { style: options.style, times: times && [...times] }
38
+ }
39
+
40
+ const PREPARE_AHEAD = 100
41
+ const MAX_LATENESS = 100
42
+ const COMPLETION_TOLERANCE = 20
43
+
44
+ /** Discrete feedback only: animation playback remains entirely native. */
45
+ export function playHaptics(
46
+ view: View,
47
+ tracks: readonly Track[],
48
+ options: HapticOptions | undefined,
49
+ driver: Driver,
50
+ startedAt: number,
51
+ ) {
52
+ if (!isIOS || !options || typeof UIImpactFeedbackGenerator === 'undefined') return
53
+ const moving = tracks.filter(
54
+ (track) =>
55
+ track.duration > 0 && track.values.some((value) => value !== track.values[0]),
56
+ )
57
+ if (!moving.length) return
58
+ const end = Math.max(...tracks.map((track) => track.end))
59
+ if (!Number.isFinite(end)) return
60
+ const delay = Math.min(...moving.map((track) => track.delay))
61
+ const offsets = options.times
62
+ ? options.times.map((time) => delay + (end - delay) * time)
63
+ : [Math.max(...tracks.map((track) => track.arrival))]
64
+ const styles = {
65
+ light: 0,
66
+ medium: 1,
67
+ heavy: 2,
68
+ } satisfies Record<HapticStyle, UIImpactFeedbackStyle>
69
+ let generator: UIImpactFeedbackGenerator | undefined =
70
+ UIImpactFeedbackGenerator.alloc().initWithStyle(styles[options.style])
71
+ const native = view.nativeViewProtected as UIView
72
+ let next = 0
73
+ let cancelPrepare: (() => void) | undefined
74
+ let cancelImpact: (() => void) | undefined
75
+
76
+ const active = () =>
77
+ view.isLoaded &&
78
+ view.nativeViewProtected === native &&
79
+ !!native.window &&
80
+ UIApplication.sharedApplication.applicationState === 0 // UIApplicationState.Active
81
+
82
+ const cancel = () => {
83
+ cancelPrepare?.()
84
+ cancelImpact?.()
85
+ cancelPrepare = cancelImpact = undefined
86
+ generator = undefined
87
+ view.off('unloaded', cancel)
88
+ }
89
+
90
+ const fire = (tolerance = 0) => {
91
+ const elapsed = driver.now() - startedAt
92
+ // A busy UI thread can delay timers. Drop stale impacts instead of replaying
93
+ // several old taps at once when it becomes responsive again.
94
+ while (next < offsets.length && offsets[next]! <= elapsed + tolerance) {
95
+ const offset = offsets[next++]!
96
+ if (active() && elapsed - offset <= MAX_LATENESS) generator?.impactOccurred()
97
+ }
98
+ }
99
+
100
+ const schedule = () => {
101
+ if (!generator || next === offsets.length) return cancel()
102
+ const remaining = startedAt + offsets[next]! - driver.now()
103
+ if (active()) generator.prepare()
104
+ if (remaining > PREPARE_AHEAD) {
105
+ cancelPrepare = driver.after(remaining - PREPARE_AHEAD, () => {
106
+ if (active()) generator?.prepare()
107
+ })
108
+ }
109
+ cancelImpact = driver.after(Math.max(0, remaining), () => {
110
+ fire()
111
+ schedule()
112
+ })
113
+ }
114
+
115
+ view.on('unloaded', cancel)
116
+ schedule()
117
+ return {
118
+ cancel,
119
+ finish() {
120
+ // Native completion may beat the timer at the final keyframe by a frame.
121
+ fire(COMPLETION_TOLERANCE)
122
+ cancel()
123
+ },
124
+ }
125
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,17 @@ export { animate, stop } from './animate.js'
2
2
  export { sequence } from './sequence.js'
3
3
  export type { Step } from './sequence.js'
4
4
  export { press } from './press.js'
5
+ export type { Haptic, HapticOptions, HapticStyle } from './haptics.js'
6
+ export { confetti, particles } from './particles.js'
7
+ export type { ConfettiOptions, ParticleOptions } from './particles.js'
8
+ export { shimmer } from './shimmer.js'
9
+ export type { ShimmerOptions } from './shimmer.js'
10
+ export { symbolEffect } from './symbol-effect.js'
11
+ export type {
12
+ SymbolEffect,
13
+ SymbolEffectName,
14
+ SymbolEffectOptions,
15
+ } from './symbol-effect.js'
5
16
  export { definePreset, presets } from './presets.js'
6
17
  export type { Preset, PresetName, Presets } from './presets.js'
7
18
  export { springs } from './transition.js'