@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/animate.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { View } from '@nativescript/core'
2
+ import { DEFAULT_FRICTION, solveDecay } from './decay.js'
2
3
  import {
3
4
  arrivalOf,
4
5
  setArrival,
@@ -8,7 +9,8 @@ import {
8
9
  type Plan,
9
10
  } from './engine.js'
10
11
  import { getPreset, type PresetName } from './presets.js'
11
- import { isProperty, type Property } from './properties.js'
12
+ import { resolveHaptic, type Haptic } from './haptics.js'
13
+ import { isProperty, precision, type Property } from './properties.js'
12
14
  import {
13
15
  hasCurve,
14
16
  mergeTransitions,
@@ -19,6 +21,8 @@ import {
19
21
  } from './transition.js'
20
22
  import type {
21
23
  AnimateOptions,
24
+ DecayOptions,
25
+ DecayVelocity,
22
26
  Keyframes,
23
27
  KeyframeValue,
24
28
  Stagger,
@@ -30,6 +34,7 @@ interface Options {
30
34
  overrides: Partial<Record<keyof Keyframes, Transition>>
31
35
  stagger: number | Stagger | undefined
32
36
  relative: boolean | undefined
37
+ haptic: Haptic | undefined
33
38
  }
34
39
 
35
40
  /**
@@ -47,12 +52,17 @@ export function animate(
47
52
  animation: Keyframes | PresetName,
48
53
  options?: AnimateOptions | SpringName,
49
54
  ): Animation {
50
- const { keyframes, base, overrides, stagger, relative } = prepare(animation, options)
55
+ const { keyframes, base, overrides, stagger, relative, haptic } = prepare(
56
+ animation,
57
+ options,
58
+ )
59
+ const feedback = resolveHaptic(haptic)
51
60
 
52
61
  if (!isList(target)) {
53
62
  return new ViewAnimation(
54
63
  toView(target),
55
64
  plan(keyframes, base, overrides, 0, relative),
65
+ feedback,
56
66
  )
57
67
  }
58
68
 
@@ -69,11 +79,83 @@ export function animate(
69
79
  staggerDelay(stagger, index, views.length),
70
80
  relative,
71
81
  ),
82
+ feedback,
72
83
  ),
73
84
  ),
74
85
  )
75
86
  }
76
87
 
88
+ /** Starts at the current translation and slows down from a velocity in DIPs per second. */
89
+ export function decay(
90
+ target: Target | readonly Target[],
91
+ velocity: DecayVelocity,
92
+ options: DecayOptions = {},
93
+ ): Animation {
94
+ const friction = options.friction ?? DEFAULT_FRICTION
95
+ if (!Number.isFinite(friction) || friction <= 0 || friction >= 1) {
96
+ throw new RangeError('Decay friction must be between 0 and 1 exclusively.')
97
+ }
98
+
99
+ const stops = (values: readonly number[] | undefined) => {
100
+ if (values === undefined) return undefined
101
+ if (
102
+ !Array.isArray(values) ||
103
+ values.length === 0 ||
104
+ ![...values].every(Number.isFinite)
105
+ ) {
106
+ throw new RangeError('Decay snap needs a nonempty array of finite positions.')
107
+ }
108
+ return [...values]
109
+ }
110
+ const snap = options.snap
111
+ if (
112
+ snap !== undefined &&
113
+ !Array.isArray(snap) &&
114
+ (snap === null ||
115
+ typeof snap !== 'object' ||
116
+ Object.keys(snap).some((key) => key !== 'x' && key !== 'y'))
117
+ ) {
118
+ throw new TypeError('Decay snap must be an array or an object with x and y stops.')
119
+ }
120
+ const shared = Array.isArray(snap)
121
+ const perAxis = snap as { x?: readonly number[]; y?: readonly number[] } | undefined
122
+ const snapped = shared ? stops(snap) : undefined
123
+ const snapX = shared ? snapped : stops(perAxis?.x)
124
+ const snapY = shared ? snapped : stops(perAxis?.y)
125
+
126
+ const plans = Object.entries(velocity).flatMap<Plan>(([property, value]) => {
127
+ if (property !== 'x' && property !== 'y') {
128
+ throw new Error(`Can't decay "${property}". Use x or y.`)
129
+ }
130
+ if (value === undefined) return []
131
+ if (!Number.isFinite(value)) {
132
+ throw new RangeError(`Invalid decay velocity for "${property}".`)
133
+ }
134
+ // Validate every axis before handing any plans to the engine.
135
+ solveDecay(0, value, friction, precision[property])
136
+ return [
137
+ {
138
+ property,
139
+ keyframes: [null, null],
140
+ transition: {
141
+ curve: {
142
+ type: 'decay',
143
+ friction,
144
+ velocity: value,
145
+ snap: property === 'x' ? snapX : snapY,
146
+ },
147
+ delay: 0,
148
+ repeat: 0,
149
+ reverse: false,
150
+ },
151
+ },
152
+ ]
153
+ })
154
+ const views = toViews(target)
155
+ if (plans.length === 0) return group([])
156
+ return group(views.map((view) => new ViewAnimation(view, plans)))
157
+ }
158
+
77
159
  /** Stops all or selected properties at their current values. `scale` stops both axes. */
78
160
  export function stop(
79
161
  target: Target | readonly Target[],
@@ -133,33 +215,43 @@ function prepare(
133
215
  : { ...own.overrides, ...user.overrides },
134
216
  stagger: user.stagger ?? own.stagger,
135
217
  relative: user.relative ?? own.relative,
218
+ haptic: user.haptic ?? own.haptic,
136
219
  }
137
220
  }
138
221
 
139
222
  function normalize(options: AnimateOptions | SpringName | undefined): Options {
140
223
  if (options === undefined)
141
- return { base: {}, overrides: {}, stagger: undefined, relative: undefined }
224
+ return {
225
+ base: {},
226
+ overrides: {},
227
+ stagger: undefined,
228
+ relative: undefined,
229
+ haptic: undefined,
230
+ }
142
231
  if (typeof options === 'string')
143
232
  return {
144
233
  base: spring(options),
145
234
  overrides: {},
146
235
  stagger: undefined,
147
236
  relative: undefined,
237
+ haptic: undefined,
148
238
  }
149
239
 
150
240
  const base: Record<string, unknown> = {}
151
241
  const overrides: Options['overrides'] = {}
152
242
  let stagger: Options['stagger']
153
243
  let relative: Options['relative']
244
+ let haptic: Options['haptic']
154
245
  for (const [key, value] of Object.entries(options)) {
155
246
  if (value === undefined) continue
156
247
  if (key === 'stagger') stagger = value as Options['stagger']
157
248
  else if (key === 'relative') relative = value as Options['relative']
249
+ else if (key === 'haptic') haptic = value as Options['haptic']
158
250
  else if (key === 'scale' || isProperty(key)) {
159
251
  overrides[key] = typeof value === 'string' ? spring(value as SpringName) : value
160
252
  } else base[key] = value
161
253
  }
162
- return { base, overrides, stagger, relative }
254
+ return { base, overrides, stagger, relative, haptic }
163
255
  }
164
256
 
165
257
  function plan(
package/src/decay.ts ADDED
@@ -0,0 +1,45 @@
1
+ export interface DecayMotion {
2
+ duration: number
3
+ to: number
4
+ position(time: number): number
5
+ velocity(time: number): number
6
+ }
7
+
8
+ /** Velocity retains this fraction every millisecond, independently of the display's refresh rate. */
9
+ export const DEFAULT_FRICTION = 0.998
10
+
11
+ /** Exponential motion, with velocity in units per second and time in milliseconds. */
12
+ export function solveDecay(
13
+ from: number,
14
+ velocity: number,
15
+ friction: number,
16
+ precision: number,
17
+ ): DecayMotion {
18
+ const rate = -Math.log(friction)
19
+ const distance = velocity / (1000 * rate)
20
+ const to = from + distance
21
+ // Stop only when both the remaining distance and speed are imperceptible.
22
+ const duration =
23
+ velocity === 0
24
+ ? 0
25
+ : Math.ceil(
26
+ Math.max(
27
+ 0,
28
+ Math.log(Math.abs(distance) / precision) / rate,
29
+ Math.log(Math.abs(velocity) / (precision * 10)) / rate,
30
+ ),
31
+ )
32
+
33
+ if (!Number.isFinite(to) || !Number.isFinite(duration) || duration > 2_147_483_647) {
34
+ throw new RangeError('Decay velocity and friction produce an unplayable motion.')
35
+ }
36
+
37
+ return {
38
+ duration,
39
+ to,
40
+ position: (time) =>
41
+ time >= duration ? to : from - distance * Math.expm1(-rate * Math.max(0, time)),
42
+ velocity: (time) =>
43
+ time >= duration ? 0 : velocity * Math.exp(-rate * Math.max(0, time)),
44
+ }
45
+ }
@@ -1,9 +1,48 @@
1
1
  import { Utils, type View } from '@nativescript/core'
2
2
  import type { Property } from '../properties.js'
3
- import { after, type Driver } from './index.js'
3
+ import type { Driver } from './index.js'
4
4
 
5
5
  type NativeView = android.view.View
6
6
 
7
+ interface Handle {
8
+ cancel(): void
9
+ elapsed(): number
10
+ }
11
+
12
+ /** Own only layers we create; keep app-configured hardware/software layers and paints intact. */
13
+ function fadeLayer(view: View, nativeView: NativeView): (() => void) | undefined {
14
+ const { View, ViewGroup } = android.view
15
+ if (
16
+ !view.isLoaded ||
17
+ view.nativeViewProtected !== nativeView ||
18
+ !(nativeView instanceof ViewGroup) ||
19
+ nativeView.getChildCount() === 0 ||
20
+ !nativeView.isHardwareAccelerated() ||
21
+ nativeView.getLayerType() !== View.LAYER_TYPE_NONE
22
+ )
23
+ return undefined
24
+
25
+ // Do not cache an entire oversized scrolling/content tree into a GPU texture.
26
+ const { widthPixels, heightPixels } = nativeView.getResources().getDisplayMetrics()
27
+ const width = nativeView.getWidth()
28
+ const height = nativeView.getHeight()
29
+ if (width <= 0 || height <= 0 || width > widthPixels || height > heightPixels)
30
+ return undefined
31
+
32
+ nativeView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
33
+ let active = true
34
+ const restore = () => {
35
+ if (!active) return
36
+ active = false
37
+ view.off('unloaded', restore)
38
+ if (nativeView.getLayerType() === View.LAYER_TYPE_HARDWARE) {
39
+ nativeView.setLayerType(View.LAYER_TYPE_NONE, null)
40
+ }
41
+ }
42
+ view.on('unloaded', restore)
43
+ return restore
44
+ }
45
+
7
46
  const accessors: Record<
8
47
  Property,
9
48
  { get(view: NativeView): number; set(view: NativeView, value: number): void }
@@ -42,6 +81,12 @@ export function createAndroidDriver(): Driver {
42
81
  const nativeView = view.nativeViewProtected as NativeView
43
82
  const scale = unit(track.property)
44
83
 
84
+ if (track.property === 'rotateX' || track.property === 'rotateY') {
85
+ nativeView.setCameraDistance(
86
+ (view.perspective || 300) * Utils.layout.getDisplayDensity(),
87
+ )
88
+ }
89
+
45
90
  const keyframes = Array.create(android.animation.Keyframe, track.values.length)
46
91
  track.values.forEach((value, i) => {
47
92
  keyframes[i] = android.animation.Keyframe.ofFloat(track.times[i]!, value * scale)
@@ -56,6 +101,14 @@ export function createAndroidDriver(): Driver {
56
101
  nativeView,
57
102
  holders,
58
103
  )
104
+ let cancelled = false
105
+ let ended = false
106
+ let started = false
107
+ let restoreLayer: (() => void) | undefined
108
+ const releaseLayer = () => {
109
+ restoreLayer?.()
110
+ restoreLayer = undefined
111
+ }
59
112
  animator.setDuration(Math.max(track.duration, 1))
60
113
  animator.setStartDelay(track.delay)
61
114
  animator.setInterpolator(interpolator)
@@ -73,10 +126,25 @@ export function createAndroidDriver(): Driver {
73
126
  }
74
127
  animator.addListener(
75
128
  new android.animation.Animator.AnimatorListener({
76
- onAnimationStart() {},
129
+ onAnimationStart() {
130
+ started = true
131
+ if (
132
+ !cancelled &&
133
+ !ended &&
134
+ track.property === 'opacity' &&
135
+ track.values.some((value) => value !== track.values[0])
136
+ )
137
+ restoreLayer = fadeLayer(view, nativeView)
138
+ },
77
139
  onAnimationRepeat() {},
78
- onAnimationCancel() {},
140
+ onAnimationCancel() {
141
+ cancelled = true
142
+ releaseLayer()
143
+ },
79
144
  onAnimationEnd() {
145
+ if (ended) return
146
+ ended = true
147
+ releaseLayer()
80
148
  onEnd()
81
149
  },
82
150
  }),
@@ -84,18 +152,56 @@ export function createAndroidDriver(): Driver {
84
152
 
85
153
  accessors[track.property].set(nativeView, track.values[0]! * scale)
86
154
  animator.start()
87
- return animator
155
+ return {
156
+ elapsed: () => (started ? track.delay + animator.getCurrentPlayTime() : 0),
157
+ cancel() {
158
+ // Cancel before native callbacks: Android can send onStart while cancelling a delay.
159
+ cancelled = true
160
+ releaseLayer()
161
+ animator.cancel()
162
+ },
163
+ } satisfies Handle
88
164
  },
89
165
 
90
166
  stop(_view, handle) {
91
- ;(handle as android.animation.ObjectAnimator).cancel()
167
+ ;(handle as Handle).cancel()
92
168
  },
93
169
 
170
+ elapsed: (handle) => (handle as Handle).elapsed(),
171
+
94
172
  presented(view, property) {
95
173
  const nativeView = view.nativeViewProtected as NativeView | undefined
96
174
  return nativeView ? accessors[property].get(nativeView) / unit(property) : undefined
97
175
  },
98
176
 
99
- after,
177
+ after(ms, callback) {
178
+ // A native marker follows animator duration scale, including changes during playback.
179
+ // Only completion crosses the bridge; there is no JavaScript update listener.
180
+ const values = Array.create('float', 2)
181
+ values[0] = 0
182
+ values[1] = 1
183
+ const marker = android.animation.ValueAnimator.ofFloat(values)
184
+ marker.setDuration(ms)
185
+ let active = true
186
+ marker.addListener(
187
+ new android.animation.Animator.AnimatorListener({
188
+ onAnimationStart() {},
189
+ onAnimationRepeat() {},
190
+ onAnimationCancel() {
191
+ active = false
192
+ },
193
+ onAnimationEnd() {
194
+ if (!active) return
195
+ active = false
196
+ callback()
197
+ },
198
+ }),
199
+ )
200
+ marker.start()
201
+ return () => {
202
+ active = false
203
+ marker.cancel()
204
+ }
205
+ },
100
206
  }
101
207
  }
@@ -10,9 +10,11 @@ export interface Driver {
10
10
  /** Starts the track at `startedAt + track.delay` and calls `onEnd` once, however it ends. */
11
11
  play(view: View, track: Track, startedAt: number, onEnd: () => void): unknown
12
12
  stop(view: View, handle: unknown): void
13
+ /** Track time, including its delay, when the native clock differs from `now()`. */
14
+ elapsed?(handle: unknown): number
13
15
  /** The value on screen, when the platform knows it better than the track does. */
14
16
  presented(view: View, property: Property): number | undefined
15
- /** Calls back after `ms` on the same clock, and returns a function that cancels it. */
17
+ /** Calls back after `ms` of animation time, respecting platform speed settings. */
16
18
  after(ms: number, callback: () => void): () => void
17
19
  }
18
20
 
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. Each effect documents whether it preserves or restores its visual state. */
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
 
@@ -100,7 +107,7 @@ export class ViewAnimation implements Animation {
100
107
  let current: number
101
108
  let velocity = 0
102
109
  if (previous) {
103
- const elapsed = now - previous.startedAt
110
+ const elapsed = elapsedOf(previous, now)
104
111
  current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
105
112
  velocity = previous.track.velocityAt(elapsed)
106
113
  release(previous, now, false)
@@ -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
@@ -148,7 +157,7 @@ export class ViewAnimation implements Animation {
148
157
 
149
158
  const arrival = Math.max(...this.entries.map((entry) => entry.track.arrival))
150
159
  if (this.pending === 0) this.settle()
151
- else if (Number.isFinite(arrival)) {
160
+ else if (Number.isFinite(arrival) && tracks.some((track) => track.end > arrival)) {
152
161
  this.cancelArrival = driver.after(arrival, this.arrive)
153
162
  }
154
163
  }
@@ -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
  }
@@ -208,9 +220,13 @@ function release(entry: Entry, now: number, commit: boolean) {
208
220
  forget(entry)
209
221
  if (commit) {
210
222
  const value =
211
- driver.presented(view, property) ?? entry.track.valueAt(now - entry.startedAt)
223
+ driver.presented(view, property) ?? entry.track.valueAt(elapsedOf(entry, now))
212
224
  writeValue(view, property, value)
213
225
  }
214
226
  driver.stop(view, entry.handle)
215
227
  entry.owner.ended(false)
216
228
  }
229
+
230
+ function elapsedOf(entry: Entry, now: number) {
231
+ return getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt
232
+ }
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
@@ -1,7 +1,24 @@
1
- export { animate, stop } from './animate.js'
1
+ export { animate, decay, 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 { loader } from './loader.js'
11
+ export type { LoaderOptions, LoaderStyle } from './loader.js'
12
+ export { reveal, conceal } from './reveal.js'
13
+ export type { RevealOptions, ConcealOptions } from './reveal.js'
14
+ export { animateText } from './text.js'
15
+ export type { TextOptions } from './text.js'
16
+ export { symbolEffect } from './symbol-effect.js'
17
+ export type {
18
+ SymbolEffect,
19
+ SymbolEffectName,
20
+ SymbolEffectOptions,
21
+ } from './symbol-effect.js'
5
22
  export { definePreset, presets } from './presets.js'
6
23
  export type { Preset, PresetName, Presets } from './presets.js'
7
24
  export { springs } from './transition.js'