@rafaelaugusto/jolt 0.1.0-beta.11 → 0.1.0-beta.13

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 (151) hide show
  1. package/README.md +42 -8
  2. package/dist/animate.d.ts +15 -3
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +52 -34
  5. package/dist/animate.js.map +1 -1
  6. package/dist/drag.d.ts +17 -3
  7. package/dist/drag.d.ts.map +1 -1
  8. package/dist/drag.js +34 -11
  9. package/dist/drag.js.map +1 -1
  10. package/dist/driver/android.d.ts.map +1 -1
  11. package/dist/driver/android.js +2 -0
  12. package/dist/driver/android.js.map +1 -1
  13. package/dist/driver/frame.d.ts +3 -0
  14. package/dist/driver/frame.d.ts.map +1 -0
  15. package/dist/driver/frame.js +7 -0
  16. package/dist/driver/frame.js.map +1 -0
  17. package/dist/driver/index.d.ts +2 -0
  18. package/dist/driver/index.d.ts.map +1 -1
  19. package/dist/driver/index.js.map +1 -1
  20. package/dist/driver/ios.d.ts.map +1 -1
  21. package/dist/driver/ios.js +2 -0
  22. package/dist/driver/ios.js.map +1 -1
  23. package/dist/easing.d.ts +1 -1
  24. package/dist/easing.d.ts.map +1 -1
  25. package/dist/easing.js +11 -0
  26. package/dist/easing.js.map +1 -1
  27. package/dist/engine.d.ts +20 -2
  28. package/dist/engine.d.ts.map +1 -1
  29. package/dist/engine.js +166 -38
  30. package/dist/engine.js.map +1 -1
  31. package/dist/gradient.d.ts.map +1 -1
  32. package/dist/gradient.js +10 -4
  33. package/dist/gradient.js.map +1 -1
  34. package/dist/image-effect.d.ts +1 -0
  35. package/dist/image-effect.d.ts.map +1 -1
  36. package/dist/image-effect.js +10 -2
  37. package/dist/image-effect.js.map +1 -1
  38. package/dist/index.d.ts +8 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/keyboard.d.ts.map +1 -1
  43. package/dist/keyboard.js +63 -10
  44. package/dist/keyboard.js.map +1 -1
  45. package/dist/mixer.d.ts +49 -0
  46. package/dist/mixer.d.ts.map +1 -0
  47. package/dist/mixer.js +238 -0
  48. package/dist/mixer.js.map +1 -0
  49. package/dist/motion-scope.d.ts +6 -2
  50. package/dist/motion-scope.d.ts.map +1 -1
  51. package/dist/motion-scope.js +12 -4
  52. package/dist/motion-scope.js.map +1 -1
  53. package/dist/motion-states.d.ts +10 -5
  54. package/dist/motion-states.d.ts.map +1 -1
  55. package/dist/motion-states.js +55 -22
  56. package/dist/motion-states.js.map +1 -1
  57. package/dist/motion-value.d.ts +152 -0
  58. package/dist/motion-value.d.ts.map +1 -0
  59. package/dist/motion-value.js +635 -0
  60. package/dist/motion-value.js.map +1 -0
  61. package/dist/native/index.d.ts +6 -0
  62. package/dist/native/index.d.ts.map +1 -0
  63. package/dist/native/index.js +3 -0
  64. package/dist/native/index.js.map +1 -0
  65. package/dist/native-properties.d.ts +28 -0
  66. package/dist/native-properties.d.ts.map +1 -0
  67. package/dist/native-properties.js +154 -0
  68. package/dist/native-properties.js.map +1 -0
  69. package/dist/native-property-driver.d.ts +19 -0
  70. package/dist/native-property-driver.d.ts.map +1 -0
  71. package/dist/native-property-driver.js +69 -0
  72. package/dist/native-property-driver.js.map +1 -0
  73. package/dist/native-property.d.ts +35 -0
  74. package/dist/native-property.d.ts.map +1 -0
  75. package/dist/native-property.js +277 -0
  76. package/dist/native-property.js.map +1 -0
  77. package/dist/path.d.ts.map +1 -1
  78. package/dist/path.js +2 -2
  79. package/dist/path.js.map +1 -1
  80. package/dist/playback-group.d.ts.map +1 -1
  81. package/dist/playback-group.js +17 -1
  82. package/dist/playback-group.js.map +1 -1
  83. package/dist/playback.d.ts +15 -0
  84. package/dist/playback.d.ts.map +1 -1
  85. package/dist/playback.js +23 -0
  86. package/dist/playback.js.map +1 -1
  87. package/dist/sequence.d.ts +24 -0
  88. package/dist/sequence.d.ts.map +1 -1
  89. package/dist/sequence.js +181 -19
  90. package/dist/sequence.js.map +1 -1
  91. package/dist/timed-animation.d.ts +38 -0
  92. package/dist/timed-animation.d.ts.map +1 -0
  93. package/dist/timed-animation.js +177 -0
  94. package/dist/timed-animation.js.map +1 -0
  95. package/dist/track.d.ts.map +1 -1
  96. package/dist/track.js +31 -12
  97. package/dist/track.js.map +1 -1
  98. package/dist/transition.d.ts +15 -4
  99. package/dist/transition.d.ts.map +1 -1
  100. package/dist/transition.js +46 -7
  101. package/dist/transition.js.map +1 -1
  102. package/dist/types.d.ts +5 -0
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/value-observers.d.ts +19 -0
  105. package/dist/value-observers.d.ts.map +1 -0
  106. package/dist/value-observers.js +111 -0
  107. package/dist/value-observers.js.map +1 -0
  108. package/dist/value-track.d.ts +13 -0
  109. package/dist/value-track.d.ts.map +1 -0
  110. package/dist/value-track.js +59 -0
  111. package/dist/value-track.js.map +1 -0
  112. package/dist/vue/index.d.ts +28 -2
  113. package/dist/vue/index.d.ts.map +1 -1
  114. package/dist/vue/index.js +78 -2
  115. package/dist/vue/index.js.map +1 -1
  116. package/package.json +6 -2
  117. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrame.java +53 -0
  118. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrameFollower.java +57 -0
  119. package/src/android-plugin.d.ts +64 -0
  120. package/src/animate.ts +106 -17
  121. package/src/drag.ts +67 -21
  122. package/src/driver/android.ts +2 -0
  123. package/src/driver/frame.ts +10 -0
  124. package/src/driver/index.ts +2 -0
  125. package/src/driver/ios.ts +2 -0
  126. package/src/easing.ts +14 -0
  127. package/src/engine.ts +181 -55
  128. package/src/gradient.ts +10 -4
  129. package/src/image-effect.ts +10 -2
  130. package/src/index.ts +23 -1
  131. package/src/ios-plugin.d.ts +33 -0
  132. package/src/keyboard.ts +83 -18
  133. package/src/mixer.ts +264 -0
  134. package/src/motion-scope.ts +17 -5
  135. package/src/motion-states.ts +79 -29
  136. package/src/motion-value.ts +795 -0
  137. package/src/native/index.ts +16 -0
  138. package/src/native-properties.ts +188 -0
  139. package/src/native-property-driver.ts +123 -0
  140. package/src/native-property.ts +353 -0
  141. package/src/path.ts +2 -1
  142. package/src/playback-group.ts +17 -1
  143. package/src/playback.ts +41 -0
  144. package/src/sequence.ts +209 -20
  145. package/src/timed-animation.ts +192 -0
  146. package/src/track.ts +37 -12
  147. package/src/transition.ts +69 -10
  148. package/src/types.ts +5 -0
  149. package/src/value-observers.ts +133 -0
  150. package/src/value-track.ts +71 -0
  151. package/src/vue/index.ts +98 -1
package/src/track.ts CHANGED
@@ -4,7 +4,7 @@ import { solveDecay } from './decay.js'
4
4
  import { solveDragRelease } from './drag-physics.js'
5
5
  import { precision, type Property } from './properties.js'
6
6
  import { solveSpring, springPhysics } from './spring.js'
7
- import type { Curve, ResolvedTransition } from './transition.js'
7
+ import type { Curve, RepeatDelayRange, ResolvedTransition } from './transition.js'
8
8
  import { colorMixer, isColorProperty, type ColorProperty } from './color.js'
9
9
  import { reducedMotion, timeScale } from './config.js'
10
10
 
@@ -74,7 +74,9 @@ export function createTrack(
74
74
  played,
75
75
  transition.repeat,
76
76
  transition.reverse,
77
- (transition.repeatDelay ?? 0) * slowdown,
77
+ transition.repeatDelay ?? 0,
78
+ slowdown,
79
+ transition.seed,
78
80
  )
79
81
  const { duration } = motion
80
82
  const first = motion.values[0]!
@@ -222,32 +224,44 @@ function colorTrack(
222
224
  }
223
225
  }
224
226
 
227
+ /** An endless loop with drawn pauses plays this many of them, then starts over. */
228
+ const DRAWN_CYCLES = 16
229
+
225
230
  /**
226
- * Holds the end value for `pause` ms before each repeat, inside the keyframes, so both
231
+ * Holds the end value for `delay` ms before each repeat, inside the keyframes, so both
227
232
  * platforms play it natively. Finite repeats are laid out one after another, with no
228
- * wait after the last; endless ones loop a cycle that waits at each turn.
233
+ * wait after the last; endless ones loop a cycle that waits at each turn,
234
+ * or DRAWN_CYCLES cycles when the waits are drawn from a range.
229
235
  */
230
236
  function pauseBetweenRepeats(
231
237
  motion: Motion,
232
238
  repeat: number,
233
239
  reverse: boolean,
234
- pause: number,
240
+ delay: number | RepeatDelayRange,
241
+ slowdown: number,
242
+ seed = Math.random(),
235
243
  ): { motion: Motion; repeat: number; reverse: boolean } {
236
- if (pause <= 0 || repeat <= 0 || motion.duration === 0)
244
+ const [shortest, longest] = typeof delay === 'number' ? [delay, delay] : delay
245
+ if (longest <= 0 || repeat <= 0 || motion.duration === 0)
237
246
  return { motion, repeat, reverse }
247
+ const draw = random(seed)
248
+ const pause = () => (shortest + draw() * (longest - shortest)) * slowdown
238
249
  type Segment = { kind: 'forward' | 'backward' | 'hold'; duration: number }
239
250
  const play = (backward: boolean): Segment => ({
240
251
  kind: backward ? 'backward' : 'forward',
241
252
  duration: motion.duration,
242
253
  })
243
- const hold: Segment = { kind: 'hold', duration: pause }
254
+ const hold = (): Segment => ({ kind: 'hold', duration: pause() })
244
255
  const segments: Segment[] = []
245
256
  if (Number.isFinite(repeat))
246
257
  for (let i = 0; i <= repeat; i++) {
247
- if (i > 0) segments.push(hold)
258
+ if (i > 0) segments.push(hold())
248
259
  segments.push(play(reverse && i % 2 === 1))
249
260
  }
250
- else segments.push(play(false), hold, ...(reverse ? [play(true), hold] : []))
261
+ else {
262
+ const cycles = shortest === longest ? (reverse ? 2 : 1) : DRAWN_CYCLES
263
+ for (let i = 0; i < cycles; i++) segments.push(play(reverse && i % 2 === 1), hold())
264
+ }
251
265
 
252
266
  const total = segments.reduce((sum, segment) => sum + segment.duration, 0)
253
267
  const starts = segments.map((_, i) =>
@@ -260,8 +274,6 @@ function pauseBetweenRepeats(
260
274
  if (segment.kind === 'backward') return motion.values[0]!
261
275
  return endOf(i - 1)
262
276
  }
263
- // A loop jumps back to its start at the end of the hold, not across it.
264
- const jump = Math.min(1, pause / 10)
265
277
 
266
278
  const times: number[] = []
267
279
  const values: number[] = []
@@ -273,7 +285,9 @@ function pauseBetweenRepeats(
273
285
  const jumps =
274
286
  next !== undefined &&
275
287
  (next.kind === 'forward' ? motion.values[0] : motion.values[last]) !== held
276
- times.push((start + segment.duration - (jumps ? jump : 0)) / total)
288
+ // A loop jumps back to its start at the end of the hold, not across it.
289
+ const jump = jumps ? Math.min(1, segment.duration / 10) : 0
290
+ times.push((start + segment.duration - jump) / total)
277
291
  values.push(held)
278
292
  return
279
293
  }
@@ -319,6 +333,17 @@ function pauseBetweenRepeats(
319
333
  }
320
334
  }
321
335
 
336
+ /** Mulberry32: the same seed draws the same pauses for every track of one animation. */
337
+ function random(seed: number) {
338
+ let state = Math.floor(seed * 2 ** 32) >>> 0
339
+ return () => {
340
+ state = (state + 0x6d2b79f5) >>> 0
341
+ let t = Math.imul(state ^ (state >>> 15), state | 1)
342
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
343
+ return ((t ^ (t >>> 14)) >>> 0) / 2 ** 32
344
+ }
345
+ }
346
+
322
347
  /** Plays the same trajectory over a longer or shorter time. */
323
348
  function slow(motion: Motion, factor: number): Motion {
324
349
  if (factor === 1) return motion
package/src/transition.ts CHANGED
@@ -12,10 +12,15 @@ export interface Timing {
12
12
  repeat?: number
13
13
  /** `reverse` plays every other repeat backwards. */
14
14
  repeatType?: 'loop' | 'reverse'
15
- /** Milliseconds to hold the end value before each repeat. The first play doesn't wait. */
16
- repeatDelay?: number
15
+ /**
16
+ * Milliseconds to hold the end value before each repeat. The first play doesn't wait.
17
+ * `[min, max]` draws a new pause for each repeat.
18
+ */
19
+ repeatDelay?: number | RepeatDelayRange
17
20
  }
18
21
 
22
+ export type RepeatDelayRange = readonly [min: number, max: number]
23
+
19
24
  export interface Tween extends Timing {
20
25
  type?: 'tween'
21
26
  /** Milliseconds. */
@@ -107,7 +112,9 @@ export interface ResolvedTransition {
107
112
  delay: number
108
113
  repeat: number
109
114
  reverse: boolean
110
- repeatDelay?: number
115
+ repeatDelay?: number | RepeatDelayRange
116
+ /** Draws the pauses of a `repeatDelay` range; tracks of one animation share it. */
117
+ seed?: number
111
118
  }
112
119
 
113
120
  const springKeys = ['stiffness', 'damping', 'mass', 'bounce', 'velocity'] as const
@@ -137,11 +144,53 @@ export function timingOf(transition: Transition): Timing {
137
144
  return timing
138
145
  }
139
146
 
140
- /** Timing merges key by key, the curve is taken whole from whichever side has one. */
147
+ const isSpring = (transition: Transition) =>
148
+ transition.type === 'spring' ||
149
+ springKeys.some((key) => (transition as Spring)[key] !== undefined)
150
+
151
+ /** Curve keys a merged transition took from the main one rather than from its override. */
152
+ const inherited = new WeakMap<Transition, ReadonlySet<string>>()
153
+
154
+ /**
155
+ * Timing merges key by key. Over a tween, a curve that stays a tween merges key by key too;
156
+ * otherwise the curve is taken whole from whichever side has one.
157
+ */
141
158
  export function mergeTransitions(base: Transition, override: Transition): Transition {
142
- return hasCurve(override)
143
- ? { ...timingOf(base), ...override }
144
- : { ...base, ...timingOf(override) }
159
+ if (!hasCurve(override)) {
160
+ const merged = { ...base, ...timingOf(override) }
161
+ const taken = inherited.get(base)
162
+ if (taken) inherited.set(merged, taken)
163
+ return merged
164
+ }
165
+ if (!hasCurve(base) || isSpring(base) || isSpring(override))
166
+ return { ...timingOf(base), ...override }
167
+ const merged = { ...base, ...override } as Transition
168
+ const own = override as Record<string, unknown>
169
+ inherited.set(
170
+ merged,
171
+ new Set(
172
+ curveKeys.filter(
173
+ (key) =>
174
+ (base as Record<string, unknown>)[key] !== undefined && own[key] === undefined,
175
+ ),
176
+ ),
177
+ )
178
+ return merged
179
+ }
180
+
181
+ /** Drops inherited `times` and per-segment easings that don't fit this property's keyframes. */
182
+ export function fitTransition(transition: Transition, keyframes: number): Transition {
183
+ const taken = inherited.get(transition)
184
+ if (!taken) return transition
185
+ const tween = { ...transition } as Tween
186
+ const segments = (ease: Tween['ease']) =>
187
+ ease !== undefined && isEasingList(ease) ? ease.length : undefined
188
+ if (taken.has('times') && tween.times && tween.times.length !== keyframes)
189
+ delete tween.times
190
+ if (taken.has('ease') && (segments(tween.ease) ?? keyframes - 1) !== keyframes - 1)
191
+ delete tween.ease
192
+ if (taken.has('easeTrack') && segments(tween.ease) !== undefined) delete tween.easeTrack
193
+ return tween
145
194
  }
146
195
 
147
196
  export function resolveTransition(
@@ -167,13 +216,23 @@ export function resolveTransition(
167
216
  if (repeat < 0 || Number.isNaN(repeat))
168
217
  throw new RangeError(`Invalid repeat: ${repeat}.`)
169
218
  const repeatDelay = transition.repeatDelay ?? 0
170
- if (!Number.isFinite(repeatDelay) || repeatDelay < 0)
171
- throw new RangeError(`Invalid repeat delay: ${repeatDelay}.`)
219
+ const [shortest, longest] =
220
+ typeof repeatDelay === 'number' ? [repeatDelay, repeatDelay] : repeatDelay
221
+ if (
222
+ (typeof repeatDelay !== 'number' &&
223
+ (!Array.isArray(repeatDelay) || repeatDelay.length !== 2)) ||
224
+ !Number.isFinite(shortest) ||
225
+ !Number.isFinite(longest) ||
226
+ shortest! < 0 ||
227
+ shortest! > longest!
228
+ )
229
+ throw new RangeError(`Invalid repeat delay: ${JSON.stringify(repeatDelay)}.`)
172
230
  const timing = {
173
231
  delay,
174
232
  repeat,
175
233
  reverse: transition.repeatType === 'reverse',
176
- ...(repeatDelay > 0 && repeat > 0 && { repeatDelay }),
234
+ ...(longest! > 0 && repeat > 0 && { repeatDelay }),
235
+ ...(longest! > shortest! && repeat > 0 && { seed: Math.random() }),
177
236
  }
178
237
 
179
238
  if (type === 'spring') {
package/src/types.ts CHANGED
@@ -86,4 +86,9 @@ export type AnimateOptions = Transition & {
86
86
  stagger?: number | Stagger
87
87
  /** iOS impact feedback at arrival, or at explicit points in a finite animation. */
88
88
  haptic?: Haptic
89
+ /**
90
+ * Plays on a named layer, on top of the property's other animations instead of replacing
91
+ * them. Translations and rotations add; scale and opacity multiply.
92
+ */
93
+ layer?: string
89
94
  }
@@ -0,0 +1,133 @@
1
+ import { Application, Color, Trace } from '@nativescript/core'
2
+ import { getDriver } from './driver/index.js'
3
+
4
+ export interface SubscribeOptions {
5
+ /** Deliver the current value synchronously when subscribing. Defaults to true. */
6
+ immediate?: boolean
7
+ }
8
+
9
+ /** `time` is milliseconds on the native animation clock, shared by a notification batch. */
10
+ export type ValueListener<T> = (value: T, time: number) => void
11
+
12
+ interface Observer {
13
+ read(value: number): number
14
+ color: boolean
15
+ listener: ValueListener<number | Color>
16
+ previous: number
17
+ }
18
+
19
+ // Only sources that need a frame live here. Idle subscriptions retain no global root reference.
20
+ const pending = new Set<ValueObservers>()
21
+ let cancelFrame: (() => void) | undefined
22
+ let listening = false
23
+ let suspended = false
24
+
25
+ function suspend() {
26
+ suspended = true
27
+ cancelFrame?.()
28
+ cancelFrame = undefined
29
+ }
30
+
31
+ function resume() {
32
+ suspended = false
33
+ schedule()
34
+ }
35
+
36
+ function schedule() {
37
+ if (!pending.size) {
38
+ cancelFrame?.()
39
+ cancelFrame = undefined
40
+ if (listening && !suspended) {
41
+ Application.off(Application.suspendEvent, suspend)
42
+ Application.off(Application.resumeEvent, resume)
43
+ listening = false
44
+ }
45
+ return
46
+ }
47
+ if (!listening) {
48
+ Application.on(Application.suspendEvent, suspend)
49
+ Application.on(Application.resumeEvent, resume)
50
+ listening = true
51
+ }
52
+ if (cancelFrame || suspended) return
53
+ cancelFrame = getDriver().frame(() => {
54
+ cancelFrame = undefined
55
+ const time = getDriver().now()
56
+ const sources = [...pending]
57
+ pending.clear()
58
+ // Capture every source before calling any listener: reentrant writes belong to the next batch.
59
+ const deliveries = sources.map((source) => source.snapshot(time))
60
+ for (const source of sources) if (source.active) pending.add(source)
61
+ for (const deliver of deliveries) deliver()
62
+ schedule()
63
+ })
64
+ }
65
+
66
+ function deliver(observer: Observer, value: number, time: number) {
67
+ try {
68
+ observer.listener(observer.color ? new Color(value) : value, time)
69
+ } catch (error) {
70
+ // A consumer failure must not interrupt other consumers or strand the shared frame source.
71
+ Trace.write(
72
+ `Jolt motion value subscriber failed: ${error instanceof Error ? error.stack : String(error)}`,
73
+ Trace.categories.Error,
74
+ Trace.messageType.error,
75
+ )
76
+ }
77
+ }
78
+
79
+ /** Root-owned subscriptions; mappings are evaluated only for consumers that opted in. */
80
+ export class ValueObservers {
81
+ private readonly observers = new Set<Observer>()
82
+ private running = false
83
+
84
+ constructor(private readonly read: (time: number) => number) {}
85
+
86
+ get active() {
87
+ return this.running && this.observers.size > 0
88
+ }
89
+
90
+ subscribe(
91
+ read: (value: number) => number,
92
+ color: boolean,
93
+ listener: ValueListener<number | Color>,
94
+ options: SubscribeOptions = {},
95
+ ): () => void {
96
+ if (typeof listener !== 'function')
97
+ throw new TypeError('subscribe() needs a listener function.')
98
+ const time = getDriver().now()
99
+ const observer = { read, color, listener, previous: read(this.read(time)) }
100
+ this.observers.add(observer)
101
+ if (options.immediate !== false) deliver(observer, observer.previous, time)
102
+ if (this.active) this.changed(true)
103
+ return () => {
104
+ this.observers.delete(observer)
105
+ if (!this.observers.size) {
106
+ pending.delete(this)
107
+ schedule()
108
+ }
109
+ }
110
+ }
111
+
112
+ changed(running: boolean) {
113
+ this.running = running
114
+ if (this.observers.size) {
115
+ pending.add(this)
116
+ schedule()
117
+ }
118
+ }
119
+
120
+ snapshot(time: number): () => void {
121
+ const value = this.read(time)
122
+ const deliveries = [...this.observers].flatMap((observer) => {
123
+ const next = observer.read(value)
124
+ if (Object.is(next, observer.previous)) return []
125
+ observer.previous = next
126
+ return [{ observer, next }]
127
+ })
128
+ return () => {
129
+ for (const { observer, next } of deliveries)
130
+ if (this.observers.has(observer)) deliver(observer, next, time)
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,71 @@
1
+ import { instantTrack, type Track } from './track.js'
2
+
3
+ export interface ValuePart {
4
+ readonly track: Track
5
+ readonly elapsed: number
6
+ readonly base: number
7
+ readonly span: number
8
+ }
9
+
10
+ /** Compile scalar addition before any binding's mapping. The snapshot is independent of later edits. */
11
+ export function valueTrack(parts: readonly ValuePart[], rest: number) {
12
+ const value = (time: number) =>
13
+ parts.reduce(
14
+ (sum, part) =>
15
+ sum + part.base + part.span * part.track.valueAt(part.elapsed + time),
16
+ rest,
17
+ )
18
+ let finite = 0
19
+ const periods: number[] = []
20
+ for (const { track, elapsed } of parts) {
21
+ if (Number.isFinite(track.end)) finite = Math.max(finite, track.end - elapsed)
22
+ else if (elapsed < track.delay) finite = Math.max(finite, track.delay - elapsed)
23
+ else periods.push(Math.max(1, track.duration * (track.reverse ? 2 : 1)))
24
+ }
25
+ if (finite <= 0 && !periods.length)
26
+ return { track: instantTrack('x', value(0)), windowed: false }
27
+ const loop = finite <= 0 && periods.length ? commonPeriod(periods) : undefined
28
+ // Bound finite compositions too: a minutes-long base must not undersample a short pulse.
29
+ const duration = finite > 0 ? Math.min(finite, 4000) : (loop ?? 4000)
30
+ const steps = Math.max(1, Math.min(6000, Math.ceil((duration * 60) / 1000)))
31
+ const times = Array.from({ length: steps + 1 }, (_, i) => i / steps)
32
+ const values = times.map((time) => value(time * duration))
33
+ const endless = loop !== undefined
34
+ const at = (time: number) =>
35
+ value(
36
+ endless
37
+ ? ((time % duration) + duration) % duration
38
+ : Math.min(duration, Math.max(0, time)),
39
+ )
40
+ const track: Track = {
41
+ property: 'x',
42
+ delay: 0,
43
+ duration,
44
+ repeat: endless ? Infinity : 0,
45
+ reverse: false,
46
+ times,
47
+ values,
48
+ to: endless ? values[0]! : values[steps]!,
49
+ end: endless ? Infinity : duration,
50
+ arrival: endless ? Infinity : duration,
51
+ valueAt: at,
52
+ velocityAt: (time) => (at(time + 0.5) - at(time - 0.5)) * 1000,
53
+ }
54
+ return {
55
+ track,
56
+ windowed:
57
+ finite > duration ||
58
+ (!endless && parts.some(({ track }) => !Number.isFinite(track.end))),
59
+ }
60
+ }
61
+
62
+ function commonPeriod(periods: readonly number[]) {
63
+ const gcd = (a: number, b: number): number => (b ? gcd(b, a % b) : a)
64
+ let total = 1
65
+ for (const period of periods) {
66
+ if (!Number.isInteger(period)) return undefined
67
+ total = (total / gcd(total, period)) * period
68
+ if (total > 30000) return undefined
69
+ }
70
+ return total
71
+ }
package/src/vue/index.ts CHANGED
@@ -15,10 +15,19 @@ import {
15
15
  type Ref,
16
16
  } from 'nativescript-vue'
17
17
  import {
18
+ gradient,
18
19
  press,
19
20
  motionScope,
21
+ bind,
22
+ motionValue,
23
+ scrollValue,
20
24
  onKeyboard,
21
25
  onReducedMotion,
26
+ stop,
27
+ type Bindings,
28
+ type MotionValue,
29
+ type ScrollValue,
30
+ type Target,
22
31
  reducedMotion,
23
32
  type KeyboardState,
24
33
  type MotionScope,
@@ -27,6 +36,9 @@ import {
27
36
  type PlaybackAnimation,
28
37
  type PresetName,
29
38
  type SpringName,
39
+ type Gradient,
40
+ type GradientOptions,
41
+ type Transition,
30
42
  } from '../index.js'
31
43
  import { currentKeyboard } from '../keyboard.js'
32
44
 
@@ -37,6 +49,11 @@ type Options = AnimateOptions | SpringName
37
49
  export type AnimateValue =
38
50
  PresetName | Keyframes | readonly [animation: PresetName | Keyframes, options?: Options]
39
51
 
52
+ /** The gradient, alone or paired with the transition for later changes. */
53
+ export type GradientValue =
54
+ | GradientOptions
55
+ | readonly [gradient: GradientOptions, transition?: Transition | SpringName]
56
+
40
57
  /** The pressed keyframes, alone or paired with a transition. */
41
58
  export type PressValue = Keyframes | readonly [pressed: Keyframes, options?: Options]
42
59
 
@@ -53,6 +70,7 @@ export interface TransitionProps {
53
70
  const animations = new WeakMap<object, PlaybackAnimation>()
54
71
  const scopes = new WeakMap<object, MotionScope>()
55
72
  const removers = new WeakMap<object, () => void>()
73
+ const gradients = new WeakMap<object, { gradient: Gradient; colors: number }>()
56
74
 
57
75
  function split<T>(
58
76
  value: T | readonly [T, Options?],
@@ -137,6 +155,47 @@ export const vPress: ObjectDirective<Host, PressValue | undefined> = {
137
155
  },
138
156
  }
139
157
 
158
+ function paint(el: Host, value: GradientValue, animated: boolean) {
159
+ const [options, transition] = (Array.isArray(value) ? value : [value]) as [
160
+ GradientOptions,
161
+ (Transition | SpringName)?,
162
+ ]
163
+ const current = gradients.get(el)
164
+ const colors = options.colors.length
165
+ if (current && current.gradient.type === options.type) {
166
+ const { type, ...values } = options
167
+ if (animated && current.colors === colors)
168
+ current.gradient.animate(values, transition)
169
+ else current.gradient.set(values)
170
+ current.colors = colors
171
+ return
172
+ }
173
+ current?.gradient.remove()
174
+ gradients.set(el, { gradient: gradient(el, options), colors })
175
+ }
176
+
177
+ /**
178
+ * Paints a native gradient behind the element, and animates to the new values when they change.
179
+ * A change of type, or of the number of colors, applies at once.
180
+ *
181
+ * ```vue
182
+ * <GridLayout v-gradient="{ type: 'linear', colors: ['#18181B', '#3F3F46'], angle: 135 }" />
183
+ * <GridLayout v-gradient="[{ type: 'linear', colors: active ? hot : cold }, 'snappy']" />
184
+ * ```
185
+ */
186
+ export const vGradient: ObjectDirective<Host, GradientValue> = {
187
+ mounted(el, { value }) {
188
+ paint(el, value, false)
189
+ },
190
+ updated(el, { value, oldValue }) {
191
+ if (!same(value, oldValue)) paint(el, value, true)
192
+ },
193
+ unmounted(el) {
194
+ gradients.get(el)?.gradient.remove()
195
+ gradients.delete(el)
196
+ },
197
+ }
198
+
140
199
  /** The hooks `JoltTransition` hands to Vue's `BaseTransition`. */
141
200
  export function transitionHooks(props: TransitionProps): BaseTransitionProps<Host> {
142
201
  const run = (value: AnimateValue | undefined) => (el: Host, done: () => void) => {
@@ -195,10 +254,11 @@ export const JoltTransition = defineComponent({
195
254
  },
196
255
  })
197
256
 
198
- /** Registers `v-animate`, `v-press` and `JoltTransition` on the app. */
257
+ /** Registers `v-animate`, `v-press`, `v-gradient` and `JoltTransition` on the app. */
199
258
  export function install(app: App) {
200
259
  app.directive('animate', vAnimate)
201
260
  app.directive('press', vPress)
261
+ app.directive('gradient', vGradient)
202
262
  app.component('JoltTransition', JoltTransition)
203
263
  }
204
264
 
@@ -229,3 +289,40 @@ export function useKeyboard(): Readonly<Ref<KeyboardState>> {
229
289
  onUnmounted(off)
230
290
  return readonly(keyboard) as Readonly<Ref<KeyboardState>>
231
291
  }
292
+
293
+ /** Call in setup(): a motion value that stops when the component unmounts. */
294
+ export function useMotionValue(initial = 0): MotionValue {
295
+ const value = motionValue(initial)
296
+ onUnmounted(() => stop(value))
297
+ return value
298
+ }
299
+
300
+ /**
301
+ * Call in setup(): a template ref that binds motion values to the element on mount
302
+ * and releases them on unmount. A recreated element picks the values up again.
303
+ */
304
+ export function useBinding(bindings: Bindings): (element: Target | null) => void {
305
+ let element: Target | null = null
306
+ let release: (() => void) | undefined
307
+ onUnmounted(() => {
308
+ release?.()
309
+ release = undefined
310
+ })
311
+ return (next) => {
312
+ if (next === element) return
313
+ release?.()
314
+ release = undefined
315
+ element = next
316
+ if (next) release = bind(next, bindings)
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Call in setup(): a scroll offset as a value. Pass its `ref` to the ScrollView's
322
+ * template ref; it stops following when the component unmounts.
323
+ */
324
+ export function useScrollValue(options?: { axis?: 'x' | 'y' }): ScrollValue {
325
+ const value = scrollValue(null, options)
326
+ onUnmounted(() => value.dispose())
327
+ return value
328
+ }