@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/text.ts ADDED
@@ -0,0 +1,296 @@
1
+ import { Application, Label, isIOS } from '@nativescript/core'
2
+ import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
3
+ import { toView } from './animate.js'
4
+ import type { Animation } from './engine.js'
5
+ import { textLayer } from './text-layout.js'
6
+ import type { Target } from './types.js'
7
+
8
+ export interface TextOptions {
9
+ /** Composed letters (including emoji), or words. Defaults to 'letter'. */
10
+ by?: 'letter' | 'word'
11
+ /** Milliseconds per piece. Defaults to 400. */
12
+ duration?: number
13
+ /** Milliseconds between pieces. Defaults to 35. */
14
+ stagger?: number
15
+ /** Milliseconds before the first piece. Defaults to zero. */
16
+ delay?: number
17
+ /** Initial vertical offset in DIPs. Defaults to 12. */
18
+ y?: number
19
+ }
20
+ type Options = Required<TextOptions>
21
+ const running = new WeakMap<Label, TextAnimation>()
22
+ const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
23
+ let Delegate: typeof NSObject | undefined
24
+ const styleEvents = [
25
+ 'fontInternalChange',
26
+ 'colorChange',
27
+ 'textAlignmentChange',
28
+ 'textDecorationChange',
29
+ 'textTransformChange',
30
+ 'letterSpacingChange',
31
+ 'lineHeightChange',
32
+ 'textShadowChange',
33
+ 'textStrokeChange',
34
+ 'whiteSpaceChange',
35
+ 'textOverflowChange',
36
+ 'maxLinesChange',
37
+ 'paddingLeftChange',
38
+ 'paddingRightChange',
39
+ 'paddingTopChange',
40
+ 'paddingBottomChange',
41
+ 'visibilityChange',
42
+ 'directionChange',
43
+ 'borderLeftWidthChange',
44
+ 'borderRightWidthChange',
45
+ 'borderTopWidthChange',
46
+ 'borderBottomWidthChange',
47
+ ]
48
+
49
+ /** Reveals the text of an iOS Label without splitting its layout or accessibility element. */
50
+ export function animateText(target: Target, options: TextOptions = {}): Animation {
51
+ const config: Options = {
52
+ by: options.by ?? 'letter',
53
+ duration: options.duration ?? 400,
54
+ stagger: options.stagger ?? 35,
55
+ delay: options.delay ?? 0,
56
+ y: options.y ?? 12,
57
+ }
58
+ if (config.by !== 'letter' && config.by !== 'word')
59
+ throw new TypeError('Text grouping must be letter or word.')
60
+ for (const key of ['duration', 'stagger', 'delay'] as const) {
61
+ if (!Number.isFinite(config[key]) || config[key] < 0)
62
+ throw new RangeError(`Text ${key} must be a nonnegative finite number.`)
63
+ }
64
+ if (!Number.isFinite(config.y)) throw new RangeError('Text y must be finite.')
65
+ const view = toView(target)
66
+ if (!(view instanceof Label)) throw new TypeError('Expected a NativeScript Label.')
67
+ running.get(view)?.stop()
68
+ return new TextAnimation(view, config)
69
+ }
70
+
71
+ function completion(
72
+ callback: (finished: boolean) => void,
73
+ ): NSObject & CAAnimationDelegate {
74
+ if (!Delegate) {
75
+ const base = NSObject as typeof NSObject & {
76
+ extend(
77
+ methods: object,
78
+ options: { protocols: unknown[]; name: string },
79
+ ): typeof NSObject
80
+ }
81
+ Delegate = base.extend(
82
+ {
83
+ animationDidStopFinished(
84
+ this: NSObject,
85
+ _animation: CAAnimation,
86
+ finished: boolean,
87
+ ) {
88
+ callbacks.get(this)?.(finished)
89
+ },
90
+ },
91
+ { protocols: [CAAnimationDelegate], name: 'JoltTextAnimationDelegate' },
92
+ )
93
+ }
94
+ const delegate = Delegate.new()
95
+ callbacks.set(delegate, callback)
96
+ return delegate
97
+ }
98
+
99
+ class TextAnimation implements Animation {
100
+ readonly finished: Promise<boolean>
101
+ private resolve!: (finished: boolean) => void
102
+ private readonly formatted: Label['formattedText']
103
+ private settled = false
104
+ private queued = false
105
+ private native: UILabel | undefined
106
+ private bounds: CGRect | undefined
107
+ private layer: CALayer | undefined
108
+ private originalText: string | null = null
109
+ private originalAttributed: NSAttributedString | null = null
110
+ private accessibility: string | null = null
111
+ private temporaryAccessibility: string | undefined
112
+ private delegate: NSObject | undefined
113
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
114
+
115
+ constructor(
116
+ private readonly view: Label,
117
+ private readonly options: Options,
118
+ ) {
119
+ this.finished = new Promise((resolve) => {
120
+ this.resolve = resolve
121
+ })
122
+ this.formatted = view.formattedText
123
+ running.set(view, this)
124
+ if (!isIOS) {
125
+ this.end(false)
126
+ return
127
+ }
128
+ if (options.duration === 0 || UIAccessibilityIsReduceMotionEnabled()) {
129
+ this.end(true)
130
+ return
131
+ }
132
+ view.on('loaded', this.schedule)
133
+ view.on('layoutChanged', this.layoutChanged)
134
+ view.on('unloaded', this.stop)
135
+ view.on('textChange', this.changed)
136
+ view.on('formattedTextChange', this.changed)
137
+ this.formatted?.on('propertyChange', this.changed)
138
+ for (const event of styleEvents) view.style.on(event, this.changed)
139
+ this.schedule()
140
+ }
141
+
142
+ then<A = boolean, B = never>(
143
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
144
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
145
+ ): Promise<A | B> {
146
+ return this.finished.then(onfulfilled, onrejected)
147
+ }
148
+ /** Cancels the entrance and restores the full text. */
149
+ stop = () => this.end(false)
150
+ private changed = () => this.end(false, true)
151
+ private motionChanged = () => {
152
+ if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
153
+ }
154
+ private layoutChanged = () => {
155
+ const native = this.view.nativeViewProtected as UILabel | undefined
156
+ const a = this.bounds
157
+ const b = native?.bounds
158
+ if (
159
+ this.layer &&
160
+ (native !== this.native ||
161
+ !native?.window ||
162
+ !b ||
163
+ !a ||
164
+ a.origin.x !== b.origin.x ||
165
+ a.origin.y !== b.origin.y ||
166
+ a.size.width !== b.size.width ||
167
+ a.size.height !== b.size.height)
168
+ )
169
+ return this.stop()
170
+ this.schedule()
171
+ }
172
+ private schedule = () => {
173
+ if (this.settled || this.queued) return
174
+ this.queued = true
175
+ void Promise.resolve().then(() => {
176
+ this.queued = false
177
+ if (this.settled || this.layer || !this.view.isLoaded) return
178
+ try {
179
+ this.start()
180
+ } catch {
181
+ this.end(false)
182
+ }
183
+ })
184
+ }
185
+ private start() {
186
+ if (this.options.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
187
+ return this.end(true)
188
+ if (this.view.visibility !== 'visible') return this.end(false)
189
+ const native = this.view.nativeViewProtected as UILabel | undefined
190
+ if (!(native instanceof UILabel) || !native.window) return
191
+ if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
192
+ const prepared = textLayer(native, this.options.by)
193
+ if (!prepared) return this.end(false)
194
+ if (!prepared.layer?.sublayers?.count) return this.end(true)
195
+ if (
196
+ !Number.isFinite(
197
+ this.options.delay +
198
+ this.options.duration +
199
+ (prepared.layer.sublayers.count - 1) * this.options.stagger,
200
+ )
201
+ )
202
+ return this.end(false)
203
+ this.native = native
204
+ this.bounds = native.bounds
205
+ this.layer = prepared.layer
206
+ this.originalText = native.text
207
+ this.originalAttributed = native.attributedText
208
+ this.accessibility = native.accessibilityLabel
209
+ this.temporaryAccessibility = this.accessibility ?? prepared.text
210
+ const { duration, delay, stagger, y } = this.options
211
+ const start = CACurrentMediaTime()
212
+ CATransaction.begin()
213
+ CATransaction.setDisableActions(true)
214
+ try {
215
+ native.text = ''
216
+ native.accessibilityLabel = this.temporaryAccessibility
217
+ native.layer.addSublayer(this.layer)
218
+ const pieces = this.layer.sublayers!
219
+ for (let i = 0; i < pieces.count; i++) {
220
+ const piece = pieces.objectAtIndex(i)
221
+ const fade = CABasicAnimation.animationWithKeyPath('opacity')
222
+ fade.fromValue = 0
223
+ fade.toValue = 1
224
+ const move = CABasicAnimation.animationWithKeyPath('transform.translation.y')
225
+ move.fromValue = y
226
+ move.toValue = 0
227
+ fade.duration = move.duration = duration / 1000
228
+ const group = CAAnimationGroup.new()
229
+ group.animations = NSArray.arrayWithArray([fade, move])
230
+ group.duration = duration / 1000
231
+ group.beginTime = start + (delay + i * stagger) / 1000
232
+ group.fillMode = kCAFillModeBackwards
233
+ group.timingFunction = CAMediaTimingFunction.functionWithName(
234
+ kCAMediaTimingFunctionEaseOut,
235
+ )
236
+ if (i === pieces.count - 1)
237
+ this.delegate = group.delegate = completion((finished) => this.end(finished))
238
+ piece.addAnimationForKey(group, 'jolt:text')
239
+ }
240
+ this.observer = Application.ios.addNotificationObserver(
241
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
242
+ this.motionChanged,
243
+ )
244
+ } finally {
245
+ CATransaction.commit()
246
+ }
247
+ }
248
+ private end(finished: boolean, refresh = false) {
249
+ if (this.settled) return
250
+ this.settled = true
251
+ const { view, native, layer } = this
252
+ view.off('loaded', this.schedule)
253
+ view.off('layoutChanged', this.layoutChanged)
254
+ view.off('unloaded', this.stop)
255
+ view.off('textChange', this.changed)
256
+ view.off('formattedTextChange', this.changed)
257
+ this.formatted?.off('propertyChange', this.changed)
258
+ for (const event of styleEvents) view.style.off(event, this.changed)
259
+ if (running.get(view) === this) running.delete(view)
260
+ if (this.observer)
261
+ Application.ios.removeNotificationObserver(
262
+ this.observer,
263
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
264
+ )
265
+ if (this.delegate) callbacks.delete(this.delegate)
266
+ if (native && layer) {
267
+ CATransaction.begin()
268
+ CATransaction.setDisableActions(true)
269
+ try {
270
+ // Restore only the native text we suppressed; app changes win.
271
+ if (!native.text && !native.attributedText?.length) {
272
+ if (refresh && view.nativeViewProtected === native) {
273
+ const property = view.formattedText ? formattedTextProperty : textProperty
274
+ ;(view as unknown as Record<symbol, (value: unknown) => void>)[
275
+ property.setNative
276
+ ]!(view.formattedText ?? view.text)
277
+ } else if (this.originalAttributed)
278
+ native.attributedText = this.originalAttributed
279
+ else native.text = this.originalText
280
+ }
281
+ if (native.accessibilityLabel === this.temporaryAccessibility)
282
+ native.accessibilityLabel = this.accessibility
283
+ layer.removeFromSuperlayer()
284
+ const pieces = layer.sublayers
285
+ if (pieces)
286
+ for (let i = 0; i < pieces.count; i++)
287
+ pieces.objectAtIndex(i).removeAnimationForKey('jolt:text')
288
+ } finally {
289
+ CATransaction.commit()
290
+ }
291
+ }
292
+ this.native = this.layer = this.delegate = this.observer = undefined
293
+ this.originalAttributed = null
294
+ this.resolve(finished)
295
+ }
296
+ }
package/src/track.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { linear } from './easing.js'
2
+ import { solveDecay } from './decay.js'
2
3
  import { precision, type Property } from './properties.js'
3
- import { solveSpring } from './spring.js'
4
+ import { solveSpring, springPhysics } from './spring.js'
4
5
  import type { Curve, ResolvedTransition } from './transition.js'
5
6
 
6
7
  /**
@@ -37,6 +38,7 @@ interface Motion {
37
38
  }
38
39
 
39
40
  const SAMPLES_PER_SECOND = 60
41
+ const MAX_DECAY_SAMPLES = 6000
40
42
  const ARRIVAL_TOLERANCE = 0.05
41
43
 
42
44
  export function createTrack(
@@ -48,7 +50,9 @@ export function createTrack(
48
50
  const motion =
49
51
  curve.type === 'spring'
50
52
  ? spring(property, keyframes, curve, velocity)
51
- : tween(keyframes, curve)
53
+ : curve.type === 'decay'
54
+ ? decay(property, keyframes[0]!, curve)
55
+ : tween(keyframes, curve)
52
56
  const { duration } = motion
53
57
  const first = motion.values[0]!
54
58
  const last = motion.values.at(-1)!
@@ -75,7 +79,11 @@ export function createTrack(
75
79
  values: motion.values,
76
80
  to: !finite || endsBackwards ? first : last,
77
81
  end,
78
- arrival: curve.type === 'spring' && repeat === 0 ? delay + arrival(motion) : end,
82
+ arrival:
83
+ (curve.type === 'spring' || (curve.type === 'decay' && curve.snap !== undefined)) &&
84
+ repeat === 0
85
+ ? delay + arrival(motion)
86
+ : end,
79
87
  valueAt: (elapsed) => motion.position(localTime(elapsed)[0]),
80
88
  velocityAt: (elapsed) => {
81
89
  if (elapsed <= delay || elapsed >= end) return 0
@@ -85,6 +93,37 @@ export function createTrack(
85
93
  }
86
94
  }
87
95
 
96
+ function decay(
97
+ property: Property,
98
+ from: number,
99
+ curve: Extract<Curve, { type: 'decay' }>,
100
+ ): Motion {
101
+ const motion = solveDecay(from, curve.velocity, curve.friction, precision[property])
102
+ if (curve.snap) {
103
+ const to = curve.snap.reduce((closest, stop) =>
104
+ Math.abs(stop - motion.to) < Math.abs(closest - motion.to) ? stop : closest,
105
+ )
106
+ return spring(
107
+ property,
108
+ [from, to],
109
+ { type: 'spring', physics: springPhysics({}), velocity: curve.velocity },
110
+ 0,
111
+ )
112
+ }
113
+
114
+ const steps = Math.max(
115
+ 1,
116
+ Math.min(MAX_DECAY_SAMPLES, Math.ceil((motion.duration / 1000) * SAMPLES_PER_SECOND)),
117
+ )
118
+ const times: number[] = []
119
+ const values: number[] = []
120
+ for (let i = 0; i <= steps; i++) {
121
+ times.push(i / steps)
122
+ values.push(motion.position((i / steps) * motion.duration))
123
+ }
124
+ return { ...motion, times, values }
125
+ }
126
+
88
127
  function arrival({ times, values, duration }: Motion) {
89
128
  const target = values.at(-1)!
90
129
  const tolerance =
package/src/transition.ts CHANGED
@@ -52,6 +52,12 @@ export type Curve =
52
52
  times: readonly number[] | undefined
53
53
  }
54
54
  | { type: 'spring'; physics: SpringPhysics; velocity: number | undefined }
55
+ | {
56
+ type: 'decay'
57
+ friction: number
58
+ velocity: number
59
+ snap: readonly number[] | undefined
60
+ }
55
61
 
56
62
  export interface ResolvedTransition {
57
63
  curve: Curve
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. */
@@ -24,6 +25,19 @@ export interface Keyframes {
24
25
  export type Target =
25
26
  View | { readonly nativeView: View } | { readonly nativeElement: View }
26
27
 
28
+ /** Translation velocities in DIPs per second. Omitted axes keep their current motion. */
29
+ export interface DecayVelocity {
30
+ x?: number
31
+ y?: number
32
+ }
33
+
34
+ export interface DecayOptions {
35
+ /** Velocity multiplier per millisecond, between 0 and 1 exclusively. Defaults to 0.998. */
36
+ friction?: number
37
+ /** Spring to the closest stop to the projected landing, shared or set per axis. */
38
+ snap?: readonly number[] | { x?: readonly number[]; y?: readonly number[] }
39
+ }
40
+
27
41
  export interface Stagger {
28
42
  /** Milliseconds between one target and the next. */
29
43
  each: number
@@ -38,4 +52,6 @@ export type AnimateOptions = Transition & {
38
52
  relative?: boolean
39
53
  /** With several targets, delays each one by this much more than the previous. */
40
54
  stagger?: number | Stagger
55
+ /** iOS impact feedback at arrival, or at explicit points in a finite animation. */
56
+ haptic?: Haptic
41
57
  }