@rafaelaugusto/jolt 0.1.0-beta.5 → 0.1.0-beta.6

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 (150) hide show
  1. package/README.md +110 -3
  2. package/dist/animate.d.ts +4 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +48 -11
  5. package/dist/animate.js.map +1 -1
  6. package/dist/config.d.ts +22 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +34 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/driver/android.d.ts.map +1 -1
  11. package/dist/driver/android.js +7 -0
  12. package/dist/driver/android.js.map +1 -1
  13. package/dist/driver/index.d.ts +2 -0
  14. package/dist/driver/index.d.ts.map +1 -1
  15. package/dist/driver/index.js.map +1 -1
  16. package/dist/driver/ios-color.js +1 -1
  17. package/dist/driver/ios-color.js.map +1 -1
  18. package/dist/driver/ios-decoration.js +1 -1
  19. package/dist/driver/ios-decoration.js.map +1 -1
  20. package/dist/driver/ios-playback.d.ts +1 -1
  21. package/dist/driver/ios-playback.d.ts.map +1 -1
  22. package/dist/driver/ios-playback.js +14 -0
  23. package/dist/driver/ios-playback.js.map +1 -1
  24. package/dist/driver/ios.d.ts.map +1 -1
  25. package/dist/driver/ios.js +1 -0
  26. package/dist/driver/ios.js.map +1 -1
  27. package/dist/engine.d.ts +16 -1
  28. package/dist/engine.d.ts.map +1 -1
  29. package/dist/engine.js +33 -1
  30. package/dist/engine.js.map +1 -1
  31. package/dist/index.d.ts +11 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +5 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/items.d.ts +20 -0
  36. package/dist/items.d.ts.map +1 -0
  37. package/dist/items.js +109 -0
  38. package/dist/items.js.map +1 -0
  39. package/dist/keyboard.d.ts +17 -0
  40. package/dist/keyboard.d.ts.map +1 -0
  41. package/dist/keyboard.js +116 -0
  42. package/dist/keyboard.js.map +1 -0
  43. package/dist/label.d.ts +7 -0
  44. package/dist/label.d.ts.map +1 -0
  45. package/dist/label.js +60 -0
  46. package/dist/label.js.map +1 -0
  47. package/dist/liquid.d.ts.map +1 -1
  48. package/dist/liquid.js +2 -1
  49. package/dist/liquid.js.map +1 -1
  50. package/dist/loader.d.ts.map +1 -1
  51. package/dist/loader.js +2 -1
  52. package/dist/loader.js.map +1 -1
  53. package/dist/particles.d.ts.map +1 -1
  54. package/dist/particles.js +4 -3
  55. package/dist/particles.js.map +1 -1
  56. package/dist/path.d.ts +12 -0
  57. package/dist/path.d.ts.map +1 -0
  58. package/dist/path.js +90 -0
  59. package/dist/path.js.map +1 -0
  60. package/dist/playback-group.d.ts.map +1 -1
  61. package/dist/playback-group.js +29 -1
  62. package/dist/playback-group.js.map +1 -1
  63. package/dist/press.d.ts.map +1 -1
  64. package/dist/press.js +3 -3
  65. package/dist/press.js.map +1 -1
  66. package/dist/reveal.d.ts.map +1 -1
  67. package/dist/reveal.js +3 -2
  68. package/dist/reveal.js.map +1 -1
  69. package/dist/roll-diff.d.ts +22 -0
  70. package/dist/roll-diff.d.ts.map +1 -0
  71. package/dist/roll-diff.js +36 -0
  72. package/dist/roll-diff.js.map +1 -0
  73. package/dist/roll.d.ts +29 -0
  74. package/dist/roll.d.ts.map +1 -0
  75. package/dist/roll.js +442 -0
  76. package/dist/roll.js.map +1 -0
  77. package/dist/scroll-linked.d.ts +10 -0
  78. package/dist/scroll-linked.d.ts.map +1 -0
  79. package/dist/scroll-linked.js +58 -0
  80. package/dist/scroll-linked.js.map +1 -0
  81. package/dist/sequence.d.ts +10 -5
  82. package/dist/sequence.d.ts.map +1 -1
  83. package/dist/sequence.js +202 -14
  84. package/dist/sequence.js.map +1 -1
  85. package/dist/shimmer.d.ts.map +1 -1
  86. package/dist/shimmer.js +2 -1
  87. package/dist/shimmer.js.map +1 -1
  88. package/dist/svg-path.d.ts +14 -0
  89. package/dist/svg-path.d.ts.map +1 -0
  90. package/dist/svg-path.js +281 -0
  91. package/dist/svg-path.js.map +1 -0
  92. package/dist/symbol-effect.d.ts.map +1 -1
  93. package/dist/symbol-effect.js +4 -3
  94. package/dist/symbol-effect.js.map +1 -1
  95. package/dist/text-layout.d.ts +8 -0
  96. package/dist/text-layout.d.ts.map +1 -1
  97. package/dist/text-layout.js +8 -2
  98. package/dist/text-layout.js.map +1 -1
  99. package/dist/text.d.ts +1 -1
  100. package/dist/text.d.ts.map +1 -1
  101. package/dist/text.js +60 -64
  102. package/dist/text.js.map +1 -1
  103. package/dist/track.d.ts +2 -0
  104. package/dist/track.d.ts.map +1 -1
  105. package/dist/track.js +62 -15
  106. package/dist/track.js.map +1 -1
  107. package/dist/types.d.ts +7 -1
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/vue/index.d.ts +89 -0
  110. package/dist/vue/index.d.ts.map +1 -0
  111. package/dist/vue/index.js +134 -0
  112. package/dist/vue/index.js.map +1 -0
  113. package/package.json +15 -3
  114. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  115. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  116. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  117. package/platforms/android/proguard-rules.pro +6 -0
  118. package/src/android-plugin.d.ts +64 -0
  119. package/src/animate.ts +80 -30
  120. package/src/config.ts +46 -0
  121. package/src/driver/android.ts +14 -0
  122. package/src/driver/index.ts +2 -0
  123. package/src/driver/ios-color.ts +1 -1
  124. package/src/driver/ios-decoration.ts +1 -1
  125. package/src/driver/ios-playback.ts +15 -1
  126. package/src/driver/ios.ts +2 -0
  127. package/src/engine.ts +47 -2
  128. package/src/index.ts +11 -1
  129. package/src/items.ts +140 -0
  130. package/src/keyboard.ts +132 -0
  131. package/src/label.ts +76 -0
  132. package/src/liquid.ts +2 -1
  133. package/src/loader.ts +2 -1
  134. package/src/particles.ts +4 -4
  135. package/src/path.ts +124 -0
  136. package/src/playback-group.ts +34 -1
  137. package/src/press.ts +3 -5
  138. package/src/reveal.ts +3 -3
  139. package/src/roll-diff.ts +57 -0
  140. package/src/roll.ts +515 -0
  141. package/src/scroll-linked.ts +73 -0
  142. package/src/sequence.ts +199 -14
  143. package/src/shimmer.ts +2 -1
  144. package/src/svg-path.ts +355 -0
  145. package/src/symbol-effect.ts +4 -3
  146. package/src/text-layout.ts +14 -2
  147. package/src/text.ts +62 -81
  148. package/src/track.ts +89 -24
  149. package/src/types.ts +8 -1
  150. package/src/vue/index.ts +189 -0
@@ -1,6 +1,12 @@
1
1
  /** TextKit lays out the complete string before any glyphs are separated. */
2
2
  type Insets = { top: number; left: number; bottom: number; right: number }
3
3
  type NativeLabel = UILabel & { padding?: Insets; borderThickness?: Insets }
4
+ /** The characters one piece draws, as UTF-16 offsets into the label text. */
5
+ export interface TextPiece {
6
+ text: string
7
+ location: number
8
+ length: number
9
+ }
4
10
  const MAX_PIECES = 256
5
11
  const MAX_PIXELS = 4_000_000
6
12
 
@@ -30,7 +36,7 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
30
36
  if (native.adjustsFontSizeToFitWidth) return undefined
31
37
  const original = native.attributedText
32
38
  const text = original?.string ?? native.text ?? ''
33
- if (!text.trim()) return { text, layer: undefined }
39
+ if (!text.trim()) return { text, layer: undefined, pieces: [] as TextPiece[] }
34
40
  const string = NSString.stringWithString(text)
35
41
  const full = { location: 0, length: string.length }
36
42
  const attributed = original
@@ -169,11 +175,17 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
169
175
  )
170
176
  if (attachments) return undefined
171
177
  const shadowColor = native.shadowColor
178
+ const drawn: TextPiece[] = []
172
179
  try {
173
180
  native.shadowColor = null
174
181
  for (const { range, rect } of pieces) {
175
182
  const fragment = NSMutableAttributedString.alloc().initWithAttributedString(hidden)
176
183
  const characters = manager.characterRangeForGlyphRangeActualGlyphRange(range, null)
184
+ drawn.push({
185
+ text: string.substringWithRange(characters),
186
+ location: characters.location,
187
+ length: characters.length,
188
+ })
177
189
  attributed.enumerateAttributesInRangeOptionsUsingBlock(
178
190
  characters,
179
191
  0 as NSAttributedStringEnumerationOptions,
@@ -212,5 +224,5 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
212
224
  else native.text = text
213
225
  native.shadowColor = shadowColor
214
226
  }
215
- return { text, layer }
227
+ return { text, layer, pieces: drawn }
216
228
  }
package/src/text.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { stopIOSColorText } from './driver/ios-color.js'
2
- import { Application, Label, isIOS } from '@nativescript/core'
2
+ import { Application, Label, Utils, isAndroid, isIOS } from '@nativescript/core'
3
3
  import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
4
4
  import { toView } from './animate.js'
5
+ import { reducedMotion, timeScale } from './config.js'
5
6
  import type { Animation } from './engine.js'
7
+ import { completion, forgetCompletion, watchLabel } from './label.js'
6
8
  import { textLayer } from './text-layout.js'
7
9
  import type { Target } from './types.js'
8
10
 
@@ -20,34 +22,8 @@ export interface TextOptions {
20
22
  }
21
23
  type Options = Required<TextOptions>
22
24
  const running = new WeakMap<Label, TextAnimation>()
23
- const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
24
- let Delegate: typeof NSObject | undefined
25
- const styleEvents = [
26
- 'fontInternalChange',
27
- 'colorChange',
28
- 'textAlignmentChange',
29
- 'textDecorationChange',
30
- 'textTransformChange',
31
- 'letterSpacingChange',
32
- 'lineHeightChange',
33
- 'textShadowChange',
34
- 'textStrokeChange',
35
- 'whiteSpaceChange',
36
- 'textOverflowChange',
37
- 'maxLinesChange',
38
- 'paddingLeftChange',
39
- 'paddingRightChange',
40
- 'paddingTopChange',
41
- 'paddingBottomChange',
42
- 'visibilityChange',
43
- 'directionChange',
44
- 'borderLeftWidthChange',
45
- 'borderRightWidthChange',
46
- 'borderTopWidthChange',
47
- 'borderBottomWidthChange',
48
- ]
49
25
 
50
- /** Reveals the text of an iOS Label without splitting its layout or accessibility element. */
26
+ /** Reveals the text of a Label without splitting its layout or accessibility element. */
51
27
  export function animateText(target: Target, options: TextOptions = {}): Animation {
52
28
  const config: Options = {
53
29
  by: options.by ?? 'letter',
@@ -69,41 +45,14 @@ export function animateText(target: Target, options: TextOptions = {}): Animatio
69
45
  return new TextAnimation(view, config)
70
46
  }
71
47
 
72
- function completion(
73
- callback: (finished: boolean) => void,
74
- ): NSObject & CAAnimationDelegate {
75
- if (!Delegate) {
76
- const base = NSObject as typeof NSObject & {
77
- extend(
78
- methods: object,
79
- options: { protocols: unknown[]; name: string },
80
- ): typeof NSObject
81
- }
82
- Delegate = base.extend(
83
- {
84
- animationDidStopFinished(
85
- this: NSObject,
86
- _animation: CAAnimation,
87
- finished: boolean,
88
- ) {
89
- callbacks.get(this)?.(finished)
90
- },
91
- },
92
- { protocols: [CAAnimationDelegate], name: 'JoltTextAnimationDelegate' },
93
- )
94
- }
95
- const delegate = Delegate.new()
96
- callbacks.set(delegate, callback)
97
- return delegate
98
- }
99
-
100
48
  class TextAnimation implements Animation {
101
49
  readonly finished: Promise<boolean>
102
50
  private resolve!: (finished: boolean) => void
103
- private readonly formatted: Label['formattedText']
104
51
  private settled = false
105
52
  private queued = false
106
- private native: UILabel | undefined
53
+ private native: UILabel | android.widget.TextView | undefined
54
+ private reveal: org.rafaelaugusto.jolt.TextReveal | undefined
55
+ private unwatch: (() => void) | undefined
107
56
  private bounds: CGRect | undefined
108
57
  private layer: CALayer | undefined
109
58
  private originalText: string | null = null
@@ -121,23 +70,19 @@ class TextAnimation implements Animation {
121
70
  this.resolve = resolve
122
71
  })
123
72
  if (isIOS) stopIOSColorText(view)
124
- this.formatted = view.formattedText
125
73
  running.set(view, this)
126
- if (!isIOS) {
74
+ if (!isIOS && !isAndroid) {
127
75
  this.end(false)
128
76
  return
129
77
  }
130
- if (options.duration === 0 || UIAccessibilityIsReduceMotionEnabled()) {
78
+ if (options.duration === 0 || reducedMotion()) {
131
79
  this.end(true)
132
80
  return
133
81
  }
134
82
  view.on('loaded', this.schedule)
135
83
  view.on('layoutChanged', this.layoutChanged)
136
84
  view.on('unloaded', this.stop)
137
- view.on('textChange', this.changed)
138
- view.on('formattedTextChange', this.changed)
139
- this.formatted?.on('propertyChange', this.changed)
140
- for (const event of styleEvents) view.style.on(event, this.changed)
85
+ this.unwatch = watchLabel(view, this.changed)
141
86
  this.schedule()
142
87
  }
143
88
 
@@ -151,9 +96,14 @@ class TextAnimation implements Animation {
151
96
  stop = () => this.end(false)
152
97
  private changed = () => this.end(false, true)
153
98
  private motionChanged = () => {
154
- if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
99
+ if (reducedMotion()) this.end(true)
155
100
  }
156
101
  private layoutChanged = () => {
102
+ if (isAndroid) {
103
+ if (!this.reveal) this.schedule()
104
+ else if (this.view.nativeViewProtected !== this.native) this.stop()
105
+ return
106
+ }
157
107
  const native = this.view.nativeViewProtected as UILabel | undefined
158
108
  const a = this.bounds
159
109
  const b = native?.bounds
@@ -176,7 +126,7 @@ class TextAnimation implements Animation {
176
126
  this.queued = true
177
127
  void Promise.resolve().then(() => {
178
128
  this.queued = false
179
- if (this.settled || this.layer || !this.view.isLoaded) return
129
+ if (this.settled || this.layer || this.reveal || !this.view.isLoaded) return
180
130
  try {
181
131
  this.start()
182
132
  } catch {
@@ -185,21 +135,21 @@ class TextAnimation implements Animation {
185
135
  })
186
136
  }
187
137
  private start() {
188
- if (this.options.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
189
- return this.end(true)
138
+ if (this.options.duration === 0 || reducedMotion()) return this.end(true)
190
139
  if (this.view.visibility !== 'visible') return this.end(false)
140
+ if (isAndroid) return this.startAndroid()
191
141
  const native = this.view.nativeViewProtected as UILabel | undefined
192
142
  if (!(native instanceof UILabel) || !native.window) return
193
143
  if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
194
144
  const prepared = textLayer(native, this.options.by)
195
145
  if (!prepared) return this.end(false)
196
146
  if (!prepared.layer?.sublayers?.count) return this.end(true)
147
+ const slowdown = 1 / timeScale()
148
+ const duration = this.options.duration * slowdown
149
+ const stagger = this.options.stagger * slowdown
150
+ const delay = this.options.delay * slowdown
197
151
  if (
198
- !Number.isFinite(
199
- this.options.delay +
200
- this.options.duration +
201
- (prepared.layer.sublayers.count - 1) * this.options.stagger,
202
- )
152
+ !Number.isFinite(delay + duration + (prepared.layer.sublayers.count - 1) * stagger)
203
153
  )
204
154
  return this.end(false)
205
155
  this.native = native
@@ -209,7 +159,7 @@ class TextAnimation implements Animation {
209
159
  this.originalAttributed = native.attributedText
210
160
  this.accessibility = native.accessibilityLabel
211
161
  this.temporaryAccessibility = this.accessibility ?? prepared.text
212
- const { duration, delay, stagger, y } = this.options
162
+ const { y } = this.options
213
163
  const start = CACurrentMediaTime()
214
164
  CATransaction.begin()
215
165
  CATransaction.setDisableActions(true)
@@ -247,24 +197,55 @@ class TextAnimation implements Animation {
247
197
  CATransaction.commit()
248
198
  }
249
199
  }
200
+ private startAndroid() {
201
+ const native = this.view.nativeViewProtected as android.widget.TextView | undefined
202
+ if (!(native instanceof android.widget.TextView)) return this.end(false)
203
+ const { by, duration, stagger, delay, y } = this.options
204
+ let reveal: org.rafaelaugusto.jolt.TextReveal
205
+ try {
206
+ reveal = new org.rafaelaugusto.jolt.TextReveal(
207
+ native,
208
+ by === 'word',
209
+ Math.round(duration),
210
+ Math.round(stagger),
211
+ Math.round(delay),
212
+ y * Utils.layout.getDisplayDensity(),
213
+ new org.rafaelaugusto.jolt.TextReveal.Callback({
214
+ onEnd: (finished: boolean) => {
215
+ if (this.reveal !== reveal) return
216
+ this.reveal = undefined
217
+ this.end(finished)
218
+ },
219
+ }),
220
+ )
221
+ } catch {
222
+ // A JS-only update cannot install the native plugin. Keep the label as it is.
223
+ return this.end(false)
224
+ }
225
+ if (!reveal.isSupported()) return this.end(false)
226
+ this.native = native
227
+ this.reveal = reveal
228
+ reveal.start()
229
+ }
250
230
  private end(finished: boolean, refresh = false) {
251
231
  if (this.settled) return
252
232
  this.settled = true
253
- const { view, native, layer } = this
233
+ const { view, layer } = this
234
+ const native = this.native as UILabel | undefined
235
+ const reveal = this.reveal
236
+ this.reveal = undefined
237
+ reveal?.cancel()
254
238
  view.off('loaded', this.schedule)
255
239
  view.off('layoutChanged', this.layoutChanged)
256
240
  view.off('unloaded', this.stop)
257
- view.off('textChange', this.changed)
258
- view.off('formattedTextChange', this.changed)
259
- this.formatted?.off('propertyChange', this.changed)
260
- for (const event of styleEvents) view.style.off(event, this.changed)
241
+ this.unwatch?.()
261
242
  if (running.get(view) === this) running.delete(view)
262
243
  if (this.observer)
263
244
  Application.ios.removeNotificationObserver(
264
245
  this.observer,
265
246
  UIAccessibilityReduceMotionStatusDidChangeNotification,
266
247
  )
267
- if (this.delegate) callbacks.delete(this.delegate)
248
+ if (this.delegate) forgetCompletion(this.delegate)
268
249
  if (native && layer) {
269
250
  CATransaction.begin()
270
251
  CATransaction.setDisableActions(true)
package/src/track.ts CHANGED
@@ -5,6 +5,7 @@ import { precision, type Property } from './properties.js'
5
5
  import { solveSpring, springPhysics } from './spring.js'
6
6
  import type { Curve, ResolvedTransition } from './transition.js'
7
7
  import { colorMixer, isColorProperty, type ColorProperty } from './color.js'
8
+ import { reducedMotion, timeScale } from './config.js'
8
9
 
9
10
  /**
10
11
  * One property animated over time. The curve is computed here and sampled,
@@ -50,13 +51,22 @@ export function createTrack(
50
51
  velocity: number,
51
52
  ): Track {
52
53
  if (isColorProperty(property)) return colorTrack(property, keyframes, transition)
53
- const { curve, delay, repeat, reverse } = transition
54
- const motion =
54
+ const { curve, repeat, reverse } = transition
55
+ if (property !== 'opacity' && reducedMotion())
56
+ return instantTrack(
57
+ property,
58
+ restingValue(property, keyframes, curve, repeat, reverse),
59
+ )
60
+ const slowdown = 1 / timeScale()
61
+ const delay = transition.delay * slowdown
62
+ const motion = slow(
55
63
  curve.type === 'spring'
56
- ? spring(property, keyframes, curve, velocity)
64
+ ? spring(property, keyframes, curve, velocity * slowdown, slowdown)
57
65
  : curve.type === 'decay'
58
- ? decay(property, keyframes[0]!, curve)
59
- : tween(keyframes, curve)
66
+ ? decay(property, keyframes[0]!, curve, slowdown)
67
+ : tween(keyframes, curve, slowdown),
68
+ slowdown,
69
+ )
60
70
  const { duration } = motion
61
71
  const first = motion.values[0]!
62
72
  const last = motion.values.at(-1)!
@@ -104,6 +114,50 @@ export function createTrack(
104
114
  }
105
115
  }
106
116
 
117
+ /** Already at its end: the engine writes the value and plays nothing. */
118
+ export function instantTrack(property: Property, value: number): Track {
119
+ const to = isDecorationProperty(property) ? clampDecoration(property, value) : value
120
+ return {
121
+ property,
122
+ delay: 0,
123
+ duration: 0,
124
+ repeat: 0,
125
+ reverse: false,
126
+ times: [0, 1],
127
+ values: [to, to],
128
+ to,
129
+ end: 0,
130
+ arrival: 0,
131
+ valueAt: () => to,
132
+ velocityAt: () => 0,
133
+ }
134
+ }
135
+
136
+ function restingValue(
137
+ property: Property,
138
+ keyframes: readonly number[],
139
+ curve: Curve,
140
+ repeat: number,
141
+ reverse: boolean,
142
+ ) {
143
+ const first = keyframes[0]!
144
+ if (!Number.isFinite(repeat) || (reverse && repeat % 2 === 1)) return first
145
+ if (curve.type !== 'decay') return keyframes.at(-1)!
146
+ const landing = solveDecay(
147
+ first,
148
+ curve.velocity,
149
+ curve.friction,
150
+ precision[property],
151
+ ).to
152
+ return curve.snap ? closest(curve.snap, landing) : landing
153
+ }
154
+
155
+ function closest(stops: readonly number[], target: number) {
156
+ return stops.reduce((best, stop) =>
157
+ Math.abs(stop - target) < Math.abs(best - target) ? stop : best,
158
+ )
159
+ }
160
+
107
161
  function colorTrack(
108
162
  property: ColorProperty,
109
163
  colors: readonly number[],
@@ -156,28 +210,46 @@ function colorTrack(
156
210
  }
157
211
  }
158
212
 
213
+ /** Plays the same trajectory over a longer or shorter time. */
214
+ function slow(motion: Motion, factor: number): Motion {
215
+ if (factor === 1) return motion
216
+ return {
217
+ ...motion,
218
+ duration: motion.duration * factor,
219
+ position: (time) => motion.position(time / factor),
220
+ velocity: (time) => motion.velocity(time / factor) / factor,
221
+ }
222
+ }
223
+
224
+ /** Keyframes per second of playback, so slowed curves stay smooth. */
225
+ function samples(duration: number, density: number) {
226
+ return Math.max(
227
+ 1,
228
+ Math.min(
229
+ MAX_DECAY_SAMPLES,
230
+ Math.ceil(((duration * density) / 1000) * SAMPLES_PER_SECOND),
231
+ ),
232
+ )
233
+ }
234
+
159
235
  function decay(
160
236
  property: Property,
161
237
  from: number,
162
238
  curve: Extract<Curve, { type: 'decay' }>,
239
+ density: number,
163
240
  ): Motion {
164
241
  const motion = solveDecay(from, curve.velocity, curve.friction, precision[property])
165
242
  if (curve.snap) {
166
- const to = curve.snap.reduce((closest, stop) =>
167
- Math.abs(stop - motion.to) < Math.abs(closest - motion.to) ? stop : closest,
168
- )
169
243
  return spring(
170
244
  property,
171
- [from, to],
245
+ [from, closest(curve.snap, motion.to)],
172
246
  { type: 'spring', physics: springPhysics({}), velocity: curve.velocity },
173
247
  0,
248
+ density,
174
249
  )
175
250
  }
176
251
 
177
- const steps = Math.max(
178
- 1,
179
- Math.min(MAX_DECAY_SAMPLES, Math.ceil((motion.duration / 1000) * SAMPLES_PER_SECOND)),
180
- )
252
+ const steps = samples(motion.duration, density)
181
253
  const times: number[] = []
182
254
  const values: number[] = []
183
255
  for (let i = 0; i <= steps; i++) {
@@ -201,6 +273,7 @@ function spring(
201
273
  [from, to]: readonly number[],
202
274
  curve: Extract<Curve, { type: 'spring' }>,
203
275
  velocity: number,
276
+ density: number,
204
277
  ): Motion {
205
278
  const motion = solveSpring(
206
279
  curve.physics,
@@ -209,7 +282,7 @@ function spring(
209
282
  curve.velocity ?? velocity,
210
283
  precision[property],
211
284
  )
212
- const steps = Math.max(1, Math.ceil((motion.duration / 1000) * SAMPLES_PER_SECOND))
285
+ const steps = samples(motion.duration, density)
213
286
  const times: number[] = []
214
287
  const values: number[] = []
215
288
  for (let i = 0; i <= steps; i++) {
@@ -222,6 +295,7 @@ function spring(
222
295
  function tween(
223
296
  keyframes: readonly number[],
224
297
  curve: Extract<Curve, { type: 'tween' }>,
298
+ density: number,
225
299
  ): Motion {
226
300
  const { duration, ease } = curve
227
301
  const offsets = curve.times ?? keyframes.map((_, i) => i / (keyframes.length - 1))
@@ -245,16 +319,7 @@ function tween(
245
319
  const from = keyframes[i]!
246
320
  const to = keyframes[i + 1]!
247
321
  const easing = ease[i]!
248
- const steps =
249
- easing === linear || from === to
250
- ? 1
251
- : Math.max(
252
- 1,
253
- Math.min(
254
- MAX_DECAY_SAMPLES,
255
- Math.ceil(((span * duration) / 1000) * SAMPLES_PER_SECOND),
256
- ),
257
- )
322
+ const steps = easing === linear || from === to ? 1 : samples(span * duration, density)
258
323
  for (let step = 1; step <= steps; step++) {
259
324
  times.push(start + (span * step) / steps)
260
325
  values.push(step === steps ? to : from + (to - from) * easing(step / steps))
package/src/types.ts CHANGED
@@ -7,6 +7,10 @@ import type { ColorKeyframes } from './color.js'
7
7
  export type KeyframeValue = number | readonly (number | null)[]
8
8
 
9
9
  export interface Keyframes {
10
+ /** One continuous SVG path, expressed as translation coordinates in DIPs. */
11
+ path?: string
12
+ /** Face the path's forward tangent (+X artwork). Requires path. */
13
+ orient?: boolean
10
14
  backgroundColor?: ColorKeyframes
11
15
  /** Uniform starting edge colors; widths and radii may differ. */
12
16
  borderColor?: ColorKeyframes
@@ -41,6 +45,9 @@ export interface Keyframes {
41
45
  rotateY?: KeyframeValue
42
46
  }
43
47
 
48
+ /** Properties that can be timed or stopped independently. A path owns x/y and optionally rotate. */
49
+ export type KeyframeProperty = Exclude<keyof Keyframes, 'path' | 'orient'>
50
+
44
51
  /** A view, or anything that holds one: a NativeScript-Vue element, an Angular `ElementRef`. */
45
52
  export type Target =
46
53
  View | { readonly nativeView: View } | { readonly nativeElement: View }
@@ -66,7 +73,7 @@ export interface Stagger {
66
73
  }
67
74
 
68
75
  export type AnimateOptions = Transition & {
69
- [K in keyof Keyframes]?: Transition | SpringName
76
+ [K in KeyframeProperty | 'path']?: Transition | SpringName
70
77
  } & {
71
78
  /** Offset translations/rotations and multiply scale/opacity by their values at the start. */
72
79
  relative?: boolean
@@ -0,0 +1,189 @@
1
+ import {
2
+ BaseTransition,
3
+ defineComponent,
4
+ h,
5
+ type App,
6
+ type BaseTransitionProps,
7
+ type NSVElement,
8
+ type ObjectDirective,
9
+ type PropType,
10
+ } from 'nativescript-vue'
11
+ import {
12
+ animate,
13
+ press,
14
+ type AnimateOptions,
15
+ type Keyframes,
16
+ type PlaybackAnimation,
17
+ type PresetName,
18
+ type SpringName,
19
+ } from '../index.js'
20
+
21
+ type Host = NSVElement
22
+ type Options = AnimateOptions | SpringName
23
+
24
+ /** A preset name or keyframes, alone or paired with a transition. */
25
+ export type AnimateValue =
26
+ PresetName | Keyframes | readonly [animation: PresetName | Keyframes, options?: Options]
27
+
28
+ /** The pressed keyframes, alone or paired with a transition. */
29
+ export type PressValue = Keyframes | readonly [pressed: Keyframes, options?: Options]
30
+
31
+ export interface TransitionProps {
32
+ /** Played when the child is inserted. */
33
+ enter?: AnimateValue
34
+ /** Played before the child is removed. */
35
+ leave?: AnimateValue
36
+ /** Also play `enter` on the first render. */
37
+ appear?: boolean
38
+ mode?: 'in-out' | 'out-in' | 'default'
39
+ }
40
+
41
+ const animations = new WeakMap<object, PlaybackAnimation>()
42
+ const removers = new WeakMap<object, () => void>()
43
+
44
+ function split<T>(
45
+ value: T | readonly [T, Options?],
46
+ ): [animation: T, options: Options | undefined] {
47
+ return Array.isArray(value)
48
+ ? [value[0] as T, value[1] as Options | undefined]
49
+ : [value as T, undefined]
50
+ }
51
+
52
+ function same(a: unknown, b: unknown): boolean {
53
+ if (Object.is(a, b)) return true
54
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
55
+ return false
56
+ if (Array.isArray(a) !== Array.isArray(b)) return false
57
+ const keys = Object.keys(a)
58
+ return (
59
+ keys.length === Object.keys(b).length &&
60
+ keys.every((key) =>
61
+ same((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
62
+ )
63
+ )
64
+ }
65
+
66
+ function play(el: Host, value: AnimateValue) {
67
+ const [animation, options] = split<PresetName | Keyframes>(value)
68
+ const handle = animate(el, animation, options)
69
+ animations.set(el, handle)
70
+ return handle
71
+ }
72
+
73
+ function cancel(el: Host) {
74
+ animations.get(el)?.stop()
75
+ animations.delete(el)
76
+ }
77
+
78
+ function attach(el: Host, value: PressValue | undefined) {
79
+ const [pressed, options] = value === undefined ? [] : split<Keyframes>(value)
80
+ removers.set(el, press(el, pressed, options))
81
+ }
82
+
83
+ /**
84
+ * Plays an animation when the element mounts, and again whenever the value changes.
85
+ *
86
+ * ```vue
87
+ * <Label v-animate="'fadeInUp'" />
88
+ * <Label v-animate="['popIn', { delay: 200 }]" />
89
+ * ```
90
+ */
91
+ export const vAnimate: ObjectDirective<Host, AnimateValue> = {
92
+ mounted(el, { value }) {
93
+ play(el, value)
94
+ },
95
+ updated(el, { value, oldValue }) {
96
+ if (!same(value, oldValue)) play(el, value)
97
+ },
98
+ unmounted: cancel,
99
+ }
100
+
101
+ /**
102
+ * Press feedback while a finger is on the element.
103
+ *
104
+ * ```vue
105
+ * <GridLayout v-press />
106
+ * <GridLayout v-press="{ scale: 0.95 }" />
107
+ * ```
108
+ */
109
+ export const vPress: ObjectDirective<Host, PressValue | undefined> = {
110
+ mounted(el, { value }) {
111
+ attach(el, value)
112
+ },
113
+ updated(el, { value, oldValue }) {
114
+ if (same(value, oldValue)) return
115
+ removers.get(el)?.()
116
+ attach(el, value)
117
+ },
118
+ unmounted(el) {
119
+ removers.get(el)?.()
120
+ removers.delete(el)
121
+ },
122
+ }
123
+
124
+ /** The hooks `JoltTransition` hands to Vue's `BaseTransition`. */
125
+ export function transitionHooks(props: TransitionProps): BaseTransitionProps<Host> {
126
+ const run = (value: AnimateValue | undefined) => (el: Host, done: () => void) => {
127
+ if (value === undefined) {
128
+ done()
129
+ return
130
+ }
131
+ const handle = play(el, value)
132
+ void handle.finished.then(() => {
133
+ if (animations.get(el) === handle) animations.delete(el)
134
+ done()
135
+ })
136
+ }
137
+ const hooks: BaseTransitionProps<Host> = {
138
+ onEnter: run(props.enter),
139
+ onEnterCancelled: cancel,
140
+ onLeave: run(props.leave),
141
+ onLeaveCancelled: cancel,
142
+ }
143
+ if (props.appear) {
144
+ hooks.onAppear = hooks.onEnter
145
+ hooks.onAppearCancelled = cancel
146
+ }
147
+ return hooks
148
+ }
149
+
150
+ const value = {
151
+ type: [String, Object, Array] as PropType<AnimateValue>,
152
+ default: undefined,
153
+ }
154
+
155
+ /**
156
+ * Animates a child in and out. Vue keeps the element until the leave animation settles.
157
+ *
158
+ * ```vue
159
+ * <JoltTransition enter="popIn" leave="fadeOut">
160
+ * <Label v-if="visible" text="Saved" />
161
+ * </JoltTransition>
162
+ * ```
163
+ */
164
+ export const JoltTransition = defineComponent({
165
+ name: 'JoltTransition',
166
+ props: {
167
+ enter: value,
168
+ leave: value,
169
+ appear: Boolean,
170
+ mode: { type: String as PropType<TransitionProps['mode']>, default: undefined },
171
+ },
172
+ setup(props, { slots }) {
173
+ return () =>
174
+ h(
175
+ BaseTransition,
176
+ { ...transitionHooks(props), mode: props.mode, appear: props.appear },
177
+ slots,
178
+ )
179
+ },
180
+ })
181
+
182
+ /** Registers `v-animate`, `v-press` and `JoltTransition` on the app. */
183
+ export function install(app: App) {
184
+ app.directive('animate', vAnimate)
185
+ app.directive('press', vPress)
186
+ app.component('JoltTransition', JoltTransition)
187
+ }
188
+
189
+ export default { install }