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

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 (79) hide show
  1. package/README.md +38 -7
  2. package/dist/animate.d.ts +14 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +50 -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/easing.d.ts +1 -1
  11. package/dist/easing.d.ts.map +1 -1
  12. package/dist/easing.js +11 -0
  13. package/dist/easing.js.map +1 -1
  14. package/dist/engine.d.ts +18 -1
  15. package/dist/engine.d.ts.map +1 -1
  16. package/dist/engine.js +143 -36
  17. package/dist/engine.js.map +1 -1
  18. package/dist/gradient.d.ts.map +1 -1
  19. package/dist/gradient.js +10 -4
  20. package/dist/gradient.js.map +1 -1
  21. package/dist/index.d.ts +4 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/keyboard.d.ts.map +1 -1
  26. package/dist/keyboard.js +63 -10
  27. package/dist/keyboard.js.map +1 -1
  28. package/dist/mixer.d.ts +49 -0
  29. package/dist/mixer.d.ts.map +1 -0
  30. package/dist/mixer.js +238 -0
  31. package/dist/mixer.js.map +1 -0
  32. package/dist/motion-scope.d.ts +4 -1
  33. package/dist/motion-scope.d.ts.map +1 -1
  34. package/dist/motion-scope.js +8 -3
  35. package/dist/motion-scope.js.map +1 -1
  36. package/dist/motion-states.d.ts +10 -5
  37. package/dist/motion-states.d.ts.map +1 -1
  38. package/dist/motion-states.js +55 -22
  39. package/dist/motion-states.js.map +1 -1
  40. package/dist/motion-value.d.ts +115 -0
  41. package/dist/motion-value.d.ts.map +1 -0
  42. package/dist/motion-value.js +437 -0
  43. package/dist/motion-value.js.map +1 -0
  44. package/dist/path.d.ts.map +1 -1
  45. package/dist/path.js +2 -2
  46. package/dist/path.js.map +1 -1
  47. package/dist/track.d.ts.map +1 -1
  48. package/dist/track.js +31 -12
  49. package/dist/track.js.map +1 -1
  50. package/dist/transition.d.ts +15 -4
  51. package/dist/transition.d.ts.map +1 -1
  52. package/dist/transition.js +46 -7
  53. package/dist/transition.js.map +1 -1
  54. package/dist/types.d.ts +5 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/vue/index.d.ts +28 -2
  57. package/dist/vue/index.d.ts.map +1 -1
  58. package/dist/vue/index.js +78 -2
  59. package/dist/vue/index.js.map +1 -1
  60. package/package.json +1 -1
  61. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrame.java +53 -0
  62. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrameFollower.java +57 -0
  63. package/src/android-plugin.d.ts +27 -0
  64. package/src/animate.ts +99 -16
  65. package/src/drag.ts +67 -21
  66. package/src/easing.ts +14 -0
  67. package/src/engine.ts +158 -53
  68. package/src/gradient.ts +10 -4
  69. package/src/index.ts +18 -1
  70. package/src/keyboard.ts +83 -18
  71. package/src/mixer.ts +264 -0
  72. package/src/motion-scope.ts +12 -4
  73. package/src/motion-states.ts +79 -29
  74. package/src/motion-value.ts +563 -0
  75. package/src/path.ts +2 -1
  76. package/src/track.ts +37 -12
  77. package/src/transition.ts +69 -10
  78. package/src/types.ts +5 -0
  79. package/src/vue/index.ts +98 -1
@@ -0,0 +1,563 @@
1
+ import {
2
+ Color,
3
+ ScrollView,
4
+ type EventData,
5
+ type ScrollEventData,
6
+ type View,
7
+ } from '@nativescript/core'
8
+ import { toViews } from './animate.js'
9
+ import {
10
+ colorMixer,
11
+ isColorProperty,
12
+ parseColor,
13
+ validateColorTarget,
14
+ type ColorValue,
15
+ } from './color.js'
16
+ import { reducedMotion } from './config.js'
17
+ import {
18
+ clampDecoration,
19
+ isDecorationProperty,
20
+ validateDecorationTarget,
21
+ } from './decoration.js'
22
+ import { getDriver } from './driver/index.js'
23
+ import {
24
+ setArrival,
25
+ setBase,
26
+ stopBase,
27
+ ViewAnimation,
28
+ type Animation,
29
+ type Plan,
30
+ } from './engine.js'
31
+ import { linear } from './easing.js'
32
+ import { isProperty, type Property } from './properties.js'
33
+ import { createTrack, instantTrack, type Track } from './track.js'
34
+ import {
35
+ resolveTransition,
36
+ springs,
37
+ type ResolvedTransition,
38
+ type SpringName,
39
+ type Transition,
40
+ } from './transition.js'
41
+ import type { KeyframeProperty, Target } from './types.js'
42
+
43
+ export interface MapOptions {
44
+ /** Hold the first and last outputs outside the input range. Defaults to `true`. */
45
+ clamp?: boolean
46
+ }
47
+
48
+ declare const motionValueBrand: unique symbol
49
+
50
+ /** A number derived from a motion value, ready to bind to a view. */
51
+ export interface DerivedValue {
52
+ /** The value now, while it animates too. */
53
+ get(): number
54
+ /** Maps through input and output ranges, like React Native's `interpolate`. */
55
+ to(
56
+ input: readonly number[],
57
+ output: readonly number[],
58
+ options?: MapOptions,
59
+ ): DerivedValue
60
+ to(input: readonly number[], output: readonly ColorValue[]): DerivedColor
61
+ }
62
+
63
+ /** A color derived from a motion value, for `backgroundColor`, `borderColor` or `color`. */
64
+ export interface DerivedColor {
65
+ get(): Color
66
+ }
67
+
68
+ /** A number that animates natively and drives every view property bound to it. */
69
+ export interface MotionValue extends DerivedValue {
70
+ readonly [motionValueBrand]: true
71
+ }
72
+
73
+ type ColorKey = 'backgroundColor' | 'borderColor' | 'color'
74
+ export type Bindings = {
75
+ [K in Exclude<KeyframeProperty, ColorKey>]?: DerivedValue
76
+ } & { [K in ColorKey]?: DerivedColor }
77
+
78
+ interface Binding {
79
+ readonly view: View
80
+ readonly property: Property
81
+ readonly root: Root
82
+ readonly read: (value: number) => number
83
+ animation?: ViewAnimation
84
+ sync(): void
85
+ }
86
+
87
+ interface Run {
88
+ readonly track: Track
89
+ readonly base: number
90
+ readonly span: number
91
+ readonly animation: Animation
92
+ readonly startedAt: number
93
+ readonly settle: (finished: boolean) => void
94
+ readonly cancel: () => void
95
+ }
96
+
97
+ const bound = new WeakMap<View, Map<Property, Binding>>()
98
+
99
+ class Derived {
100
+ constructor(
101
+ private readonly source: Root | undefined,
102
+ readonly read: (value: number) => number,
103
+ readonly color: boolean,
104
+ ) {}
105
+
106
+ get root(): Root {
107
+ return this.source ?? (this as unknown as Root)
108
+ }
109
+
110
+ get(): number | Color {
111
+ const value = this.read(this.root.raw())
112
+ return this.color ? new Color(value) : value
113
+ }
114
+
115
+ to(
116
+ input: readonly number[],
117
+ output: readonly (number | ColorValue)[],
118
+ options?: MapOptions,
119
+ ): Derived {
120
+ if (this.color) throw new TypeError('A color cannot be mapped again.')
121
+ const map = mapping(input, output, options)
122
+ const read = this.read
123
+ return new Derived(this.root, (value) => map.read(read(value)), map.color)
124
+ }
125
+ }
126
+
127
+ class Root extends Derived {
128
+ readonly bindings = new Set<Binding>()
129
+ private current: number
130
+ private run: Run | undefined
131
+
132
+ constructor(initial: number) {
133
+ super(undefined, (value) => value, false)
134
+ if (!Number.isFinite(initial))
135
+ throw new RangeError(`A motion value starts from a finite number, got ${initial}.`)
136
+ this.current = initial
137
+ }
138
+
139
+ raw(): number {
140
+ const run = this.run
141
+ if (!run) return this.current
142
+ return run.base + run.span * run.track.valueAt(getDriver().now() - run.startedAt)
143
+ }
144
+
145
+ private velocity(): number {
146
+ const run = this.run
147
+ if (!run) return 0
148
+ return run.span * run.track.velocityAt(getDriver().now() - run.startedAt)
149
+ }
150
+
151
+ animate(
152
+ target: number | readonly (number | null)[],
153
+ transition: Transition | SpringName = {},
154
+ ): Animation {
155
+ const from = this.raw()
156
+ const frames = (typeof target === 'number' ? [null, target] : [...target]).map(
157
+ (frame) => (frame === null ? from : frame),
158
+ )
159
+ if (frames.length < 2) frames.unshift(from)
160
+ if (frames.some((frame) => typeof frame !== 'number' || !Number.isFinite(frame)))
161
+ throw new RangeError(`Invalid motion value keyframes: ${JSON.stringify(target)}.`)
162
+ const options = typeof transition === 'string' ? springOf(transition) : transition
163
+ const resolved = resolveTransition(options, 'x', frames.length)
164
+ const base = Math.min(...frames)
165
+ const span = Math.max(...frames) - base
166
+ const velocity = this.velocity()
167
+ this.halt()
168
+ if (span === 0) return this.jump(frames.at(-1)!)
169
+
170
+ // Sampled in the value's own range so springs settle relative to the distance.
171
+ const track = createTrack(
172
+ 'opacity',
173
+ frames.map((frame) => (frame - base) / span),
174
+ resolved,
175
+ velocity / span,
176
+ )
177
+ return this.start(track, base, span)
178
+ }
179
+
180
+ /** Plays a track sampled in the value's own units, such as a drag release. */
181
+ play(track: Track): Animation {
182
+ this.halt()
183
+ return track.end === 0 ? this.jump(track.to) : this.start(track, 0, 1)
184
+ }
185
+
186
+ private jump(value: number): Animation {
187
+ const { animation, settle } = this.handle()
188
+ this.current = value
189
+ for (const binding of this.bindings) binding.sync()
190
+ settle(true)
191
+ setArrival(animation, Promise.resolve())
192
+ return animation
193
+ }
194
+
195
+ private handle() {
196
+ let settle!: (finished: boolean) => void
197
+ const finished = new Promise<boolean>((resolve) => (settle = resolve))
198
+ const animation: Animation = {
199
+ finished,
200
+ stop: () => {
201
+ if (this.run?.animation === animation) this.stop()
202
+ },
203
+ then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
204
+ }
205
+ return { animation, settle }
206
+ }
207
+
208
+ private start(track: Track, base: number, span: number): Animation {
209
+ const { animation, settle } = this.handle()
210
+ let arrive: () => void = () => {}
211
+ let run: Run | undefined
212
+ const driver = getDriver()
213
+ setArrival(animation, new Promise<void>((resolve) => (arrive = resolve)))
214
+ const timers = [
215
+ Number.isFinite(track.end)
216
+ ? driver.after(track.end, () => {
217
+ if (this.run !== run) return
218
+ this.current = base + span * track.to
219
+ this.run = undefined
220
+ arrive()
221
+ settle(true)
222
+ })
223
+ : undefined,
224
+ Number.isFinite(track.arrival)
225
+ ? driver.after(track.arrival, () => arrive())
226
+ : undefined,
227
+ ]
228
+ this.run = run = {
229
+ track,
230
+ base,
231
+ span,
232
+ animation,
233
+ startedAt: driver.now(),
234
+ settle: (completed) => {
235
+ arrive()
236
+ settle(completed)
237
+ },
238
+ cancel: () => timers.forEach((cancel) => cancel?.()),
239
+ }
240
+ for (const binding of this.bindings) binding.sync()
241
+ return animation
242
+ }
243
+
244
+ set(value: number) {
245
+ if (!Number.isFinite(value))
246
+ throw new RangeError(`A motion value takes a finite number, got ${value}.`)
247
+ this.halt()
248
+ this.current = value
249
+ for (const binding of this.bindings) binding.sync()
250
+ }
251
+
252
+ stop() {
253
+ if (!this.run) return
254
+ const value = this.raw()
255
+ this.halt()
256
+ this.current = value
257
+ for (const binding of this.bindings) binding.sync()
258
+ }
259
+
260
+ /** The plan that plays this value's run, mapped, on one bound property. */
261
+ plan(binding: Binding): Plan {
262
+ const run = this.run
263
+ const { property, read } = binding
264
+ const write = (value: number) =>
265
+ isDecorationProperty(property)
266
+ ? clampDecoration(property, read(value))
267
+ : read(value)
268
+ if (!run) {
269
+ const value = write(this.current)
270
+ return {
271
+ property,
272
+ keyframes: [],
273
+ transition: still,
274
+ track: () => instantTrack(property, value),
275
+ }
276
+ }
277
+ const { track, base, span } = run
278
+ const at = (progress: number) => write(base + span * progress)
279
+ const transition: ResolvedTransition = {
280
+ curve: {
281
+ type: 'tween',
282
+ duration: track.duration,
283
+ ease: [linear],
284
+ times: undefined,
285
+ },
286
+ delay: track.delay,
287
+ repeat: track.repeat,
288
+ reverse: track.reverse,
289
+ }
290
+ const mapped = (): Track => {
291
+ if (reducedMotion() && property !== 'opacity' && !isColorProperty(property))
292
+ return instantTrack(property, at(track.to))
293
+ return {
294
+ property,
295
+ delay: track.delay,
296
+ duration: track.duration,
297
+ repeat: track.repeat,
298
+ reverse: track.reverse,
299
+ times: track.times,
300
+ values: track.values.map(at),
301
+ to: at(track.to),
302
+ end: track.end,
303
+ arrival: track.arrival,
304
+ valueAt: (elapsed) => at(track.valueAt(elapsed)),
305
+ velocityAt: isColorProperty(property)
306
+ ? () => 0
307
+ : (elapsed) =>
308
+ (at(track.valueAt(elapsed + 0.5)) - at(track.valueAt(elapsed - 0.5))) *
309
+ 1000,
310
+ }
311
+ }
312
+ return { property, keyframes: [], transition, track: mapped }
313
+ }
314
+
315
+ elapsed(): number {
316
+ return this.run ? getDriver().now() - this.run.startedAt : 0
317
+ }
318
+
319
+ get running(): boolean {
320
+ return this.run !== undefined
321
+ }
322
+
323
+ private halt() {
324
+ const run = this.run
325
+ if (!run) return
326
+ this.run = undefined
327
+ run.cancel()
328
+ run.settle(false)
329
+ }
330
+ }
331
+
332
+ const still: ResolvedTransition = {
333
+ curve: { type: 'tween', duration: 0, ease: [linear], times: undefined },
334
+ delay: 0,
335
+ repeat: 0,
336
+ reverse: false,
337
+ }
338
+
339
+ /**
340
+ * A number that animates natively and drives the view properties bound to it.
341
+ *
342
+ * ```ts
343
+ * const progress = motionValue(0)
344
+ * bind(sheet, { y: progress.to([0, 1], [600, 0]) })
345
+ * animate(progress, 1, 'snappy')
346
+ * ```
347
+ */
348
+ export function motionValue(initial = 0): MotionValue {
349
+ return new Root(initial) as unknown as MotionValue
350
+ }
351
+
352
+ export function isMotionValue(target: unknown): target is MotionValue {
353
+ return target instanceof Root
354
+ }
355
+
356
+ export function asRoot(value: MotionValue): Root {
357
+ return value as unknown as Root
358
+ }
359
+
360
+ /**
361
+ * Lets motion values drive a view's properties. The view takes their current values
362
+ * at once and follows every change on the same native clock. Returns a cleanup function.
363
+ *
364
+ * ```ts
365
+ * const release = bind(backdrop, { opacity: progress.to([0, 1], [0, 0.4]) })
366
+ * ```
367
+ */
368
+ export function bind(target: Target | readonly Target[], bindings: Bindings): () => void {
369
+ if (!bindings || typeof bindings !== 'object' || Array.isArray(bindings))
370
+ throw new TypeError('bind() takes an object of motion values.')
371
+ const views = toViews(target)
372
+ const entries: [Property, Derived][] = []
373
+ for (const [key, source] of Object.entries(bindings)) {
374
+ if (source === undefined) continue
375
+ if (!(source instanceof Derived))
376
+ throw new TypeError(`"${key}" needs a motion value, got ${String(source)}.`)
377
+ const properties: Property[] =
378
+ key === 'scale' ? ['scaleX', 'scaleY'] : isProperty(key) ? [key] : []
379
+ if (!properties.length) throw new Error(`Can't bind "${key}".`)
380
+ for (const property of properties) {
381
+ if (isColorProperty(property) !== source.color)
382
+ throw new TypeError(
383
+ source.color
384
+ ? `"${key}" takes numbers; map the value to numbers.`
385
+ : `"${key}" is a color; map the value to colors with to(input, colors).`,
386
+ )
387
+ entries.push([property, source])
388
+ }
389
+ }
390
+ for (const view of views)
391
+ for (const [property] of entries)
392
+ if (isColorProperty(property)) validateColorTarget(view, property)
393
+ else if (isDecorationProperty(property)) validateDecorationTarget(view, property)
394
+
395
+ const made = views.flatMap((view) =>
396
+ entries.map(([property, source]) => attach(view, property, source)),
397
+ )
398
+ return () => made.forEach((release) => release())
399
+ }
400
+
401
+ function attach(view: View, property: Property, source: Derived) {
402
+ const existing = bound.get(view)?.get(property)
403
+ if (existing) release(existing)
404
+ const root = source.root
405
+ const binding: Binding = {
406
+ view,
407
+ property,
408
+ root,
409
+ read: source.read,
410
+ sync() {
411
+ if (!view.isLoaded || !view.nativeViewProtected) return
412
+ if (!root.running) {
413
+ // A value at rest writes straight through, so scroll offsets cost one write per event.
414
+ binding.animation?.stop()
415
+ binding.animation = undefined
416
+ stopBase(view, [property])
417
+ const value = source.read(root.raw())
418
+ setBase(
419
+ view,
420
+ property,
421
+ isDecorationProperty(property) ? clampDecoration(property, value) : value,
422
+ )
423
+ if (isColorProperty(property) || isDecorationProperty(property))
424
+ getDriver().sync?.(view)
425
+ return
426
+ }
427
+ const elapsed = root.elapsed()
428
+ const animation = new ViewAnimation(view, [root.plan(binding)])
429
+ if (elapsed > 1 && animation.duration > 0) animation.seekTime(elapsed)
430
+ binding.animation = animation
431
+ },
432
+ }
433
+ let properties = bound.get(view)
434
+ if (!properties) bound.set(view, (properties = new Map()))
435
+ properties.set(property, binding)
436
+ root.bindings.add(binding)
437
+ view.on('loaded', binding.sync)
438
+ binding.sync()
439
+ return () => {
440
+ if (root.bindings.has(binding)) {
441
+ release(binding)
442
+ binding.animation?.stop()
443
+ }
444
+ }
445
+ }
446
+
447
+ function release(binding: Binding) {
448
+ binding.root.bindings.delete(binding)
449
+ binding.view.off('loaded', binding.sync)
450
+ const properties = bound.get(binding.view)
451
+ if (properties?.get(binding.property) === binding) properties.delete(binding.property)
452
+ }
453
+
454
+ /** A regular animation on a bound property takes it over; the value keeps its other bindings. */
455
+ export function detachBindings(view: View, properties: readonly Property[]) {
456
+ const map = bound.get(view)
457
+ if (!map) return
458
+ for (const property of properties) {
459
+ const binding = map.get(property)
460
+ if (binding) release(binding)
461
+ }
462
+ }
463
+
464
+ function mapping(
465
+ input: readonly number[],
466
+ output: readonly (number | ColorValue)[],
467
+ options: MapOptions = {},
468
+ ) {
469
+ if (!Array.isArray(input) || !Array.isArray(output) || input.length < 2)
470
+ throw new RangeError('to() needs at least two input and output values.')
471
+ if (input.length !== output.length)
472
+ throw new RangeError(
473
+ `to() has ${input.length} inputs but ${output.length} outputs; give one output per input.`,
474
+ )
475
+ if (
476
+ input.some(
477
+ (value, i) => !Number.isFinite(value) || value < (input[i - 1] ?? -Infinity),
478
+ )
479
+ )
480
+ throw new RangeError('to() inputs must be finite and never decrease.')
481
+ const color = typeof output[0] !== 'number'
482
+ const numbers = color ? output.map((value) => parseColor(value)) : (output as number[])
483
+ if (
484
+ !color &&
485
+ numbers.some((value) => typeof value !== 'number' || !Number.isFinite(value))
486
+ )
487
+ throw new RangeError('to() outputs must all be finite numbers, or all colors.')
488
+ const mixers = color
489
+ ? numbers.slice(1).map((value, i) => colorMixer(numbers[i]!, value))
490
+ : undefined
491
+ const clamp = color || options.clamp !== false
492
+ const last = input.length - 1
493
+ const read = (value: number) => {
494
+ if (clamp && value <= input[0]!) return numbers[0]!
495
+ if (clamp && value >= input[last]!) return numbers[last]!
496
+ let i = 0
497
+ while (i < last - 1 && value > input[i + 1]!) i++
498
+ const span = input[i + 1]! - input[i]!
499
+ const t = span > 0 ? (value - input[i]!) / span : value < input[i]! ? 0 : 1
500
+ if (mixers) return mixers[i]!(Math.min(1, Math.max(0, t)))
501
+ return numbers[i]! + (numbers[i + 1]! - numbers[i]!) * t
502
+ }
503
+ return { read, color }
504
+ }
505
+
506
+ function springOf(name: SpringName): Transition {
507
+ if (!Object.hasOwn(springs, name)) throw new Error(`Unknown spring "${name}".`)
508
+ return springs[name]
509
+ }
510
+
511
+ /** A number that follows a scroll view's offset. Bind it like a motion value. */
512
+ export interface ScrollValue extends DerivedValue {
513
+ /** Follows this scroll view instead, or none with `null`. Stable, so it works as a template ref. */
514
+ readonly ref: (scrollView: Target | null) => void
515
+ /** Stops following; bound views stay where they are. */
516
+ dispose(): void
517
+ }
518
+
519
+ /**
520
+ * The scroll offset of a ScrollView, in DIPs, as a value that bindings follow.
521
+ *
522
+ * ```ts
523
+ * const offset = scrollValue(scroll)
524
+ * bind(header, { y: offset.to([0, 120], [0, -60]), opacity: offset.to([0, 80], [1, 0]) })
525
+ * ```
526
+ */
527
+ export function scrollValue(
528
+ scrollView?: Target | null,
529
+ options: { axis?: 'x' | 'y' } = {},
530
+ ): ScrollValue {
531
+ const axis = options.axis ?? 'y'
532
+ if (axis !== 'x' && axis !== 'y') throw new TypeError('Scroll axis must be "x" or "y".')
533
+ const root = new Root(0)
534
+ let source: ScrollView | undefined
535
+ const read = () => {
536
+ if (!source) return
537
+ const offset = axis === 'x' ? source.horizontalOffset : source.verticalOffset
538
+ if (Number.isFinite(offset) && offset !== root.raw()) root.set(offset)
539
+ }
540
+ const onScroll = (event: EventData) => {
541
+ const { scrollX, scrollY } = event as ScrollEventData
542
+ const offset = axis === 'x' ? scrollX : scrollY
543
+ if (Number.isFinite(offset) && offset !== root.raw()) root.set(offset)
544
+ }
545
+ const detach = () => {
546
+ source?.off(ScrollView.scrollEvent, onScroll)
547
+ source?.off('loaded', read)
548
+ source = undefined
549
+ }
550
+ const ref = (target: Target | null) => {
551
+ const next = target === null ? undefined : toViews(target)[0]
552
+ if (next === source) return
553
+ if (next !== undefined && !(next instanceof ScrollView))
554
+ throw new TypeError('scrollValue() follows a NativeScript ScrollView.')
555
+ detach()
556
+ source = next
557
+ source?.on(ScrollView.scrollEvent, onScroll)
558
+ source?.on('loaded', read)
559
+ read()
560
+ }
561
+ if (scrollView) ref(scrollView)
562
+ return Object.assign(root as unknown as DerivedValue, { ref, dispose: detach })
563
+ }
package/src/path.ts CHANGED
@@ -2,6 +2,7 @@ import { parsePath, type MotionPath } from './svg-path.js'
2
2
  import { reducedMotion, timeScale } from './config.js'
3
3
  import { createTrack, instantTrack, type Track } from './track.js'
4
4
  import {
5
+ fitTransition,
5
6
  mergeTransitions,
6
7
  resolveTransition,
7
8
  type Transition,
@@ -37,7 +38,7 @@ export function preparePath(
37
38
  const geometry = parsePath(keyframes.path)
38
39
  const orient = keyframes.orient === true
39
40
  const transition = resolveTransition(
40
- overrides.path ? mergeTransitions(base, overrides.path) : base,
41
+ fitTransition(overrides.path ? mergeTransitions(base, overrides.path) : base, 2),
41
42
  'opacity',
42
43
  2,
43
44
  )
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