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

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 (105) hide show
  1. package/README.md +24 -1
  2. package/dist/animate.d.ts +3 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +11 -3
  5. package/dist/animate.js.map +1 -1
  6. package/dist/decoration.d.ts +18 -0
  7. package/dist/decoration.d.ts.map +1 -0
  8. package/dist/decoration.js +155 -0
  9. package/dist/decoration.js.map +1 -0
  10. package/dist/driver/android-color.d.ts +3 -0
  11. package/dist/driver/android-color.d.ts.map +1 -1
  12. package/dist/driver/android-color.js +20 -18
  13. package/dist/driver/android-color.js.map +1 -1
  14. package/dist/driver/android-decoration.d.ts +22 -0
  15. package/dist/driver/android-decoration.d.ts.map +1 -0
  16. package/dist/driver/android-decoration.js +139 -0
  17. package/dist/driver/android-decoration.js.map +1 -0
  18. package/dist/driver/android-playback.d.ts +13 -0
  19. package/dist/driver/android-playback.d.ts.map +1 -0
  20. package/dist/driver/android-playback.js +141 -0
  21. package/dist/driver/android-playback.js.map +1 -0
  22. package/dist/driver/android.d.ts.map +1 -1
  23. package/dist/driver/android.js +23 -36
  24. package/dist/driver/android.js.map +1 -1
  25. package/dist/driver/index.d.ts +7 -0
  26. package/dist/driver/index.d.ts.map +1 -1
  27. package/dist/driver/index.js.map +1 -1
  28. package/dist/driver/ios-color.d.ts +2 -0
  29. package/dist/driver/ios-color.d.ts.map +1 -1
  30. package/dist/driver/ios-color.js +19 -8
  31. package/dist/driver/ios-color.js.map +1 -1
  32. package/dist/driver/ios-decoration.d.ts +17 -0
  33. package/dist/driver/ios-decoration.d.ts.map +1 -0
  34. package/dist/driver/ios-decoration.js +271 -0
  35. package/dist/driver/ios-decoration.js.map +1 -0
  36. package/dist/driver/ios-playback.d.ts +10 -0
  37. package/dist/driver/ios-playback.d.ts.map +1 -0
  38. package/dist/driver/ios-playback.js +33 -0
  39. package/dist/driver/ios-playback.js.map +1 -0
  40. package/dist/driver/ios-shadow.d.ts +6 -0
  41. package/dist/driver/ios-shadow.d.ts.map +1 -0
  42. package/dist/driver/ios-shadow.js +52 -0
  43. package/dist/driver/ios-shadow.js.map +1 -0
  44. package/dist/driver/ios.d.ts.map +1 -1
  45. package/dist/driver/ios.js +75 -40
  46. package/dist/driver/ios.js.map +1 -1
  47. package/dist/engine.d.ts +48 -2
  48. package/dist/engine.d.ts.map +1 -1
  49. package/dist/engine.js +210 -18
  50. package/dist/engine.js.map +1 -1
  51. package/dist/haptics.d.ts +4 -1
  52. package/dist/haptics.d.ts.map +1 -1
  53. package/dist/haptics.js +27 -3
  54. package/dist/haptics.js.map +1 -1
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/playback-group.d.ts +6 -0
  58. package/dist/playback-group.d.ts.map +1 -0
  59. package/dist/playback-group.js +113 -0
  60. package/dist/playback-group.js.map +1 -0
  61. package/dist/playback.d.ts +25 -0
  62. package/dist/playback.d.ts.map +1 -0
  63. package/dist/playback.js +35 -0
  64. package/dist/playback.js.map +1 -0
  65. package/dist/properties.d.ts +2 -1
  66. package/dist/properties.d.ts.map +1 -1
  67. package/dist/properties.js +14 -1
  68. package/dist/properties.js.map +1 -1
  69. package/dist/sequence.d.ts.map +1 -1
  70. package/dist/sequence.js +6 -0
  71. package/dist/sequence.js.map +1 -1
  72. package/dist/track.d.ts.map +1 -1
  73. package/dist/track.js +10 -1
  74. package/dist/track.js.map +1 -1
  75. package/dist/transition.d.ts.map +1 -1
  76. package/dist/transition.js +4 -1
  77. package/dist/transition.js.map +1 -1
  78. package/dist/types.d.ts +15 -0
  79. package/dist/types.d.ts.map +1 -1
  80. package/package.json +1 -1
  81. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +2 -2
  82. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  83. package/platforms/android/proguard-rules.pro +1 -0
  84. package/src/animate.ts +18 -5
  85. package/src/decoration.ts +180 -0
  86. package/src/driver/android-color.ts +22 -20
  87. package/src/driver/android-decoration.ts +182 -0
  88. package/src/driver/android-playback.ts +157 -0
  89. package/src/driver/android.ts +43 -43
  90. package/src/driver/index.ts +13 -0
  91. package/src/driver/ios-color.ts +19 -10
  92. package/src/driver/ios-decoration.ts +332 -0
  93. package/src/driver/ios-playback.ts +37 -0
  94. package/src/driver/ios-shadow.ts +50 -0
  95. package/src/driver/ios.ts +85 -46
  96. package/src/engine.ts +220 -20
  97. package/src/haptics.ts +25 -2
  98. package/src/index.ts +1 -0
  99. package/src/playback-group.ts +110 -0
  100. package/src/playback.ts +62 -0
  101. package/src/properties.ts +18 -2
  102. package/src/sequence.ts +5 -0
  103. package/src/track.ts +9 -1
  104. package/src/transition.ts +4 -1
  105. package/src/types.ts +15 -0
package/src/engine.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import {
2
+ createClock,
3
+ validateProgress,
4
+ type PlaybackAnimation,
5
+ type PlaybackClock,
6
+ type PlaybackCommand,
7
+ } from './playback.js'
1
8
  import type { View } from '@nativescript/core'
2
9
  import { getDriver } from './driver/index.js'
3
10
  import { playHaptics, type HapticOptions } from './haptics.js'
@@ -31,8 +38,14 @@ interface Entry {
31
38
  readonly owner: ViewAnimation
32
39
  handle: unknown
33
40
  done: boolean
41
+ finished: boolean
42
+ generation: number
43
+ token: symbol
44
+ completedValue?: number
34
45
  }
35
46
 
47
+ const revisions = new WeakMap<View, Map<Property, symbol>>()
48
+
36
49
  const running = new WeakMap<View, Map<Property, Entry>>()
37
50
  const arrivals = new WeakMap<Animation, Promise<unknown>>()
38
51
 
@@ -48,7 +61,7 @@ export function setArrival(animation: Animation, arrival: Promise<unknown>) {
48
61
  arrivals.set(animation, arrival)
49
62
  }
50
63
 
51
- export class ViewAnimation implements Animation {
64
+ export class ViewAnimation implements PlaybackAnimation {
52
65
  readonly finished: Promise<boolean>
53
66
  private resolve!: (finished: boolean) => void
54
67
  private readonly entries: Entry[] = []
@@ -59,12 +72,180 @@ export class ViewAnimation implements Animation {
59
72
  private arrive!: () => void
60
73
  private cancelArrival: (() => void) | undefined
61
74
  private haptics: ReturnType<typeof playHaptics>
75
+ private clock: PlaybackClock | undefined
76
+ private _paused = false
77
+ private controlled = false
78
+ private pendingProgress: number | undefined
79
+ private total = 0
80
+ private started = false
81
+ private arrival = 0
82
+ private arrived = false
83
+ private idleListener: (() => void) | undefined
84
+ private readyListener: (() => void) | undefined
85
+ private readonly endless: boolean
86
+
87
+ get paused() {
88
+ return this._paused
89
+ }
90
+ get duration() {
91
+ return this.started ? this.total : this.endless ? Infinity : 0
92
+ }
93
+ get currentTime() {
94
+ return this.time()
95
+ }
96
+ get ready() {
97
+ return this.started || this.settled
98
+ }
99
+ get idle() {
100
+ return this.settled || (this.started && this.pending === 0)
101
+ }
102
+ get progress() {
103
+ if (!Number.isFinite(this.duration))
104
+ throw new RangeError('Progress requires a finite animation.')
105
+ if (this.settled && this.complete) return 1
106
+ return (
107
+ this.pendingProgress ??
108
+ (this.duration ? Math.min(1, this.time() / this.duration) : this.settled ? 1 : 0)
109
+ )
110
+ }
111
+ set progress(value: number) {
112
+ validateProgress(value, this.duration)
113
+ if (this.settled) return
114
+ if (!this.started) {
115
+ this.pendingProgress = value
116
+ return
117
+ }
118
+ this.seekTime(this.duration * value)
119
+ }
120
+ onIdle(callback: () => void) {
121
+ this.idleListener = callback
122
+ }
123
+ onReady(callback: () => void) {
124
+ this.readyListener = callback
125
+ }
126
+ releaseGroup() {
127
+ this.held = false
128
+ if (this.idle && !this._paused) this.settle()
129
+ }
130
+
131
+ pause() {
132
+ if (this.settled || this._paused) return
133
+ this.controlled = true
134
+ this._paused = true
135
+ this.clock?.pause()
136
+ this.cancelArrival?.()
137
+ this.haptics?.pause()
138
+ if (this.started) this.controlTracks(this.time(), 'pause')
139
+ }
140
+ play() {
141
+ if (this.settled || !this._paused) return
142
+ this.controlled = true
143
+ this._paused = false
144
+ this.clock?.play()
145
+ if (this.started) {
146
+ this.controlTracks(this.time(), 'play')
147
+ this.scheduleArrival()
148
+ this.haptics?.play()
149
+ if (this.pending === 0) this.becameIdle()
150
+ }
151
+ }
152
+ seekTime(time: number) {
153
+ if (this.settled) return
154
+ this.controlled = true
155
+ const position = Math.max(0, Math.min(this.total, time))
156
+ this.clock?.seek(position)
157
+ if (!this._paused) this.clock?.play()
158
+ for (const entry of this.entries) {
159
+ if (
160
+ entry.done &&
161
+ entry.finished &&
162
+ revisions.get(this.view)?.get(entry.track.property) === entry.token &&
163
+ readValue(this.view, entry.track.property) === entry.completedValue
164
+ ) {
165
+ entry.done = false
166
+ entry.finished = false
167
+ this.pending++
168
+ entriesOf(this.view).set(entry.track.property, entry)
169
+ this.playEntry(entry, position)
170
+ }
171
+ }
172
+ this.controlTracks(position)
173
+ this.scheduleArrival()
174
+ this.haptics?.seek()
175
+ if (this.pending === 0) this.becameIdle()
176
+ }
177
+ elapsed(entry: Entry, now: number) {
178
+ return this.controlled
179
+ ? this.time()
180
+ : (getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt)
181
+ }
182
+ private time() {
183
+ return this.clock?.time() ?? 0
184
+ }
185
+ private controlTracks(position: number, command: PlaybackCommand = 'seek') {
186
+ const driver = getDriver()
187
+ for (const entry of this.entries)
188
+ if (!entry.done)
189
+ driver.control?.(this.view, entry.handle, position, this._paused, command)
190
+ }
191
+ private scheduleArrival() {
192
+ this.cancelArrival?.()
193
+ this.cancelArrival = undefined
194
+ if (
195
+ this.pending === 0 ||
196
+ this.arrived ||
197
+ this._paused ||
198
+ !Number.isFinite(this.arrival) ||
199
+ this.total <= this.arrival
200
+ )
201
+ return
202
+ const remaining = this.arrival - this.time()
203
+ if (remaining <= 0) {
204
+ this.arrived = true
205
+ this.arrive()
206
+ } else
207
+ this.cancelArrival = getDriver().after(remaining, () => {
208
+ this.arrived = true
209
+ this.arrive()
210
+ })
211
+ }
212
+ private becameIdle() {
213
+ this.clock?.pause()
214
+ this.idleListener?.()
215
+ if (!this.complete || (!this.held && !this._paused)) this.settle()
216
+ }
217
+ private playEntry(entry: Entry, elapsed = 0) {
218
+ const initial = entry.generation === 0
219
+ const instance = ++entry.generation
220
+ const driver = getDriver()
221
+ entry.handle = driver.play(
222
+ this.view,
223
+ entry.track,
224
+ initial ? entry.startedAt : driver.now() - elapsed,
225
+ (finished = true) => {
226
+ if (entry.done || instance !== entry.generation) return
227
+ entry.done = true
228
+ entry.finished = finished
229
+ entry.completedValue = readValue(this.view, entry.track.property)
230
+ forget(entry)
231
+ this.ended(finished)
232
+ },
233
+ )
234
+ }
235
+ private unloaded = () => this.stop()
62
236
 
63
237
  constructor(
64
238
  readonly view: View,
65
239
  plans: readonly Plan[],
66
240
  private readonly haptic?: HapticOptions,
241
+ private held = false,
67
242
  ) {
243
+ this.endless = plans.some(
244
+ (plan) =>
245
+ !Number.isFinite(plan.transition.repeat) ||
246
+ (plan.transition.curve.type === 'tween' &&
247
+ !Number.isFinite(plan.transition.curve.duration)),
248
+ )
68
249
  this.finished = new Promise((resolve) => (this.resolve = resolve))
69
250
  setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
70
251
  if (view.isLoaded && view.nativeViewProtected) this.start(plans)
@@ -95,10 +276,11 @@ export class ViewAnimation implements Animation {
95
276
  this.complete = false
96
277
  this.haptics?.cancel()
97
278
  }
98
- if (--this.pending === 0) this.settle()
279
+ if (--this.pending === 0) this.becameIdle()
99
280
  }
100
281
 
101
282
  private start(plans: readonly Plan[]) {
283
+ this.started = true
102
284
  const driver = getDriver()
103
285
  const now = driver.now()
104
286
  const entries = entriesOf(this.view)
@@ -110,7 +292,7 @@ export class ViewAnimation implements Animation {
110
292
  if (previous) {
111
293
  const elapsed = elapsedOf(previous, now)
112
294
  current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
113
- velocity = previous.track.velocityAt(elapsed)
295
+ velocity = previous.owner.paused ? 0 : previous.track.velocityAt(elapsed)
114
296
  release(previous, now, false)
115
297
  } else {
116
298
  current =
@@ -133,8 +315,13 @@ export class ViewAnimation implements Animation {
133
315
  })
134
316
 
135
317
  for (const track of tracks) writeValue(this.view, track.property, track.to)
318
+ driver.sync?.(this.view)
136
319
 
137
320
  for (const track of tracks) {
321
+ let tokens = revisions.get(this.view)
322
+ if (!tokens) revisions.set(this.view, (tokens = new Map()))
323
+ const token = Symbol(track.property)
324
+ tokens.set(track.property, token)
138
325
  if (track.end === 0) continue
139
326
  const entry: Entry = {
140
327
  track,
@@ -142,28 +329,35 @@ export class ViewAnimation implements Animation {
142
329
  owner: this,
143
330
  handle: undefined,
144
331
  done: false,
332
+ finished: false,
333
+ generation: 0,
334
+ token,
145
335
  }
146
336
  entries.set(track.property, entry)
147
337
  this.entries.push(entry)
148
338
  this.pending++
149
339
  }
150
340
 
151
- this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now)
152
-
153
- for (const entry of this.entries) {
154
- entry.handle = driver.play(this.view, entry.track, now, (finished = true) => {
155
- if (entry.done) return
156
- entry.done = true
157
- forget(entry)
158
- this.ended(finished)
159
- })
160
- }
161
-
162
- const arrival = Math.max(...this.entries.map((entry) => entry.track.arrival))
163
- if (this.pending === 0) this.settle()
164
- else if (Number.isFinite(arrival) && tracks.some((track) => track.end > arrival)) {
165
- this.cancelArrival = driver.after(arrival, this.arrive)
166
- }
341
+ this.total = Math.max(0, ...tracks.map((track) => track.end))
342
+ this.arrival = Math.max(0, ...tracks.map((track) => track.arrival))
343
+ this.clock =
344
+ (this.pending ? driver.clock?.(this.total) : undefined) ??
345
+ createClock(() => driver.now())
346
+ if (this._paused) this.clock.pause()
347
+ this.view.on('unloaded', this.unloaded)
348
+ this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now, () =>
349
+ this.time(),
350
+ )
351
+ if (this._paused) this.haptics?.pause()
352
+ for (const entry of this.entries) this.playEntry(entry)
353
+ if (this.pendingProgress !== undefined) {
354
+ const progress = this.pendingProgress
355
+ this.pendingProgress = undefined
356
+ this.seekTime(progress * this.total)
357
+ } else if (this._paused) this.controlTracks(0, 'pause')
358
+ this.scheduleArrival()
359
+ this.readyListener?.()
360
+ if (this.pending === 0) this.becameIdle()
167
361
  }
168
362
 
169
363
  private waitForLoad(plans: readonly Plan[]) {
@@ -187,6 +381,8 @@ export class ViewAnimation implements Animation {
187
381
  if (this.settled) return
188
382
  this.settled = true
189
383
  this.cancelArrival?.()
384
+ this.clock?.cancel()
385
+ this.view.off('unloaded', this.unloaded)
190
386
  if (this.complete) this.haptics?.finish()
191
387
  else this.haptics?.cancel()
192
388
  this.haptics = undefined
@@ -197,6 +393,9 @@ export class ViewAnimation implements Animation {
197
393
 
198
394
  /** Stops whatever is animating these properties of the view, or all of them. */
199
395
  export function stopView(view: View, properties?: readonly Property[]) {
396
+ const tokens = revisions.get(view)
397
+ for (const property of properties ?? tokens?.keys() ?? [])
398
+ tokens?.set(property, Symbol(property))
200
399
  const entries = running.get(view)
201
400
  if (!entries) return
202
401
  const now = getDriver().now()
@@ -226,11 +425,12 @@ function release(entry: Entry, now: number, commit: boolean) {
226
425
  const value =
227
426
  driver.presented(view, property) ?? entry.track.valueAt(elapsedOf(entry, now))
228
427
  writeValue(view, property, value)
428
+ driver.sync?.(view, property)
229
429
  }
230
430
  driver.stop(view, entry.handle)
231
431
  entry.owner.ended(false)
232
432
  }
233
433
 
234
434
  function elapsedOf(entry: Entry, now: number) {
235
- return getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt
435
+ return entry.owner.elapsed(entry, now)
236
436
  }
package/src/haptics.ts CHANGED
@@ -48,6 +48,7 @@ export function playHaptics(
48
48
  options: HapticOptions | undefined,
49
49
  driver: Driver,
50
50
  startedAt: number,
51
+ elapsedTime = () => driver.now() - startedAt,
51
52
  ) {
52
53
  if (!isIOS || !options || typeof UIImpactFeedbackGenerator === 'undefined') return
53
54
  const moving = tracks.filter(
@@ -69,6 +70,7 @@ export function playHaptics(
69
70
  let generator: UIImpactFeedbackGenerator | undefined =
70
71
  UIImpactFeedbackGenerator.alloc().initWithStyle(styles[options.style])
71
72
  const native = view.nativeViewProtected as UIView
73
+ let paused = false
72
74
  let next = 0
73
75
  let cancelPrepare: (() => void) | undefined
74
76
  let cancelImpact: (() => void) | undefined
@@ -79,6 +81,11 @@ export function playHaptics(
79
81
  !!native.window &&
80
82
  UIApplication.sharedApplication.applicationState === 0 // UIApplicationState.Active
81
83
 
84
+ const cancelTimers = () => {
85
+ cancelPrepare?.()
86
+ cancelImpact?.()
87
+ cancelPrepare = cancelImpact = undefined
88
+ }
82
89
  const cancel = () => {
83
90
  cancelPrepare?.()
84
91
  cancelImpact?.()
@@ -88,7 +95,7 @@ export function playHaptics(
88
95
  }
89
96
 
90
97
  const fire = (tolerance = 0) => {
91
- const elapsed = driver.now() - startedAt
98
+ const elapsed = elapsedTime()
92
99
  // A busy UI thread can delay timers. Drop stale impacts instead of replaying
93
100
  // several old taps at once when it becomes responsive again.
94
101
  while (next < offsets.length && offsets[next]! <= elapsed + tolerance) {
@@ -98,8 +105,9 @@ export function playHaptics(
98
105
  }
99
106
 
100
107
  const schedule = () => {
108
+ if (paused) return
101
109
  if (!generator || next === offsets.length) return cancel()
102
- const remaining = startedAt + offsets[next]! - driver.now()
110
+ const remaining = offsets[next]! - elapsedTime()
103
111
  if (active()) generator.prepare()
104
112
  if (remaining > PREPARE_AHEAD) {
105
113
  cancelPrepare = driver.after(remaining - PREPARE_AHEAD, () => {
@@ -116,6 +124,21 @@ export function playHaptics(
116
124
  schedule()
117
125
  return {
118
126
  cancel,
127
+ pause() {
128
+ paused = true
129
+ cancelTimers()
130
+ },
131
+ play() {
132
+ if (paused) {
133
+ paused = false
134
+ schedule()
135
+ }
136
+ },
137
+ seek() {
138
+ cancelTimers()
139
+ while (next < offsets.length && offsets[next]! <= elapsedTime()) next++
140
+ schedule()
141
+ },
119
142
  finish() {
120
143
  // Native completion may beat the timer at the final keyframe by a frame.
121
144
  fire(COMPLETION_TOLERANCE)
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export { springs } from './transition.js'
27
27
  export type { Spring, SpringName, Timing, Transition, Tween } from './transition.js'
28
28
  export { cubicBezier } from './easing.js'
29
29
  export type { CubicBezier, Easing, EasingFunction, EasingName } from './easing.js'
30
+ export type { PlaybackAnimation } from './playback.js'
30
31
  export type { Animation } from './engine.js'
31
32
  export type { ColorValue, ColorKeyframes } from './color.js'
32
33
  export type * from './types.js'
@@ -0,0 +1,110 @@
1
+ import { arrivalOf, setArrival, type ViewAnimation } from './engine.js'
2
+ import { getDriver } from './driver/index.js'
3
+ import { createClock, type PlaybackClock } from './playback.js'
4
+ import { validateProgress, type PlaybackAnimation } from './playback.js'
5
+
6
+ /** Staggered views share a finite time range. Shorter children remain seekable
7
+ * until the whole group settles, unless another animation has taken ownership. */
8
+ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnimation {
9
+ let paused = false
10
+ let settled = false
11
+ let finalProgress: number | undefined
12
+ let clock: PlaybackClock | undefined
13
+ let pendingProgress: number | undefined
14
+ const duration = () => Math.max(0, ...children.map((child) => child.duration))
15
+ const ensureClock = () => {
16
+ if (clock || settled || children.some((child) => !child.ready)) return
17
+ const driver = getDriver()
18
+ clock = driver.clock?.(duration()) ?? createClock(() => driver.now())
19
+ clock.seek(Math.max(0, ...children.map((child) => child.currentTime)))
20
+ if (paused) clock.pause()
21
+ }
22
+ const finishIfIdle = () => {
23
+ if (settled || paused || children.some((child) => !child.idle || child.paused)) return
24
+ settled = true
25
+ clock?.cancel()
26
+ for (const child of children) child.releaseGroup()
27
+ }
28
+ const seek = (value: number) => {
29
+ const total = duration()
30
+ validateProgress(value, total)
31
+ if (settled) return
32
+ if (children.some((child) => !child.ready)) {
33
+ pendingProgress = value
34
+ return
35
+ }
36
+ pendingProgress = undefined
37
+ ensureClock()
38
+ clock?.seek(total * value)
39
+ for (const child of children) child.seekTime(total * value)
40
+ finishIfIdle()
41
+ }
42
+ for (const child of children) {
43
+ child.onIdle(finishIfIdle)
44
+ child.onReady(() => {
45
+ ensureClock()
46
+ if (pendingProgress !== undefined && children.every((child) => child.ready))
47
+ seek(pendingProgress)
48
+ finishIfIdle()
49
+ })
50
+ }
51
+ const finished = Promise.all(children.map((child) => child.finished)).then(
52
+ (results) => {
53
+ finalProgress ??= results.every(Boolean)
54
+ ? 1
55
+ : duration()
56
+ ? Math.min(1, (clock?.time() ?? 0) / duration())
57
+ : 0
58
+ settled = true
59
+ clock?.cancel()
60
+ return results.every(Boolean)
61
+ },
62
+ )
63
+ const animation: PlaybackAnimation = {
64
+ finished,
65
+ get duration() {
66
+ return duration()
67
+ },
68
+ get paused() {
69
+ return paused
70
+ },
71
+ get progress() {
72
+ if (!Number.isFinite(duration()))
73
+ throw new RangeError('Progress requires a finite animation.')
74
+ if (pendingProgress !== undefined) return pendingProgress
75
+ return (
76
+ finalProgress ??
77
+ (settled ? 1 : duration() ? Math.min(1, (clock?.time() ?? 0) / duration()) : 0)
78
+ )
79
+ },
80
+ set progress(value) {
81
+ seek(value)
82
+ },
83
+ pause() {
84
+ if (!settled && !paused) {
85
+ paused = true
86
+ clock?.pause()
87
+ children.forEach((child) => child.pause())
88
+ }
89
+ },
90
+ play() {
91
+ if (!settled && paused) {
92
+ paused = false
93
+ clock?.play()
94
+ children.forEach((child) => child.play())
95
+ finishIfIdle()
96
+ }
97
+ },
98
+ stop() {
99
+ finalProgress = duration() ? Math.min(1, (clock?.time() ?? 0) / duration()) : 0
100
+ settled = true
101
+ clock?.cancel()
102
+ children.forEach((child) => child.stop())
103
+ },
104
+ then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
105
+ }
106
+ setArrival(animation, Promise.all(children.map(arrivalOf)))
107
+ ensureClock()
108
+ finishIfIdle()
109
+ return animation
110
+ }
@@ -0,0 +1,62 @@
1
+ import type { Animation } from './engine.js'
2
+
3
+ /** Controls returned by animate() and decay(). Effects expose the base Animation. */
4
+ export interface PlaybackAnimation extends Animation {
5
+ /** Pauses only the properties owned by this animation. */
6
+ pause(): void
7
+ /** Continues from the current position. Completed/stopped animations remain terminal. */
8
+ play(): void
9
+ readonly paused: boolean
10
+ /** Total milliseconds, including delays and repeats. Infinity for endless loops. */
11
+ readonly duration: number
12
+ /** Finite timeline position, from 0 to 1. Seeking preserves the playing/paused state. */
13
+ progress: number
14
+ }
15
+
16
+ export type PlaybackCommand = 'pause' | 'play' | 'seek'
17
+
18
+ export interface PlaybackClock {
19
+ time(): number
20
+ pause(): void
21
+ play(): void
22
+ seek(ms: number): void
23
+ cancel(): void
24
+ }
25
+
26
+ /** Read only on user commands; there is no frame loop in JavaScript. */
27
+ export function createClock(now: () => number, initial = 0): PlaybackClock {
28
+ let offset = initial
29
+ let anchor = now()
30
+ let paused = false
31
+ const time = () => offset + (paused ? 0 : now() - anchor)
32
+ return {
33
+ time,
34
+ pause() {
35
+ offset = time()
36
+ paused = true
37
+ },
38
+ play() {
39
+ if (paused) {
40
+ anchor = now()
41
+ paused = false
42
+ }
43
+ },
44
+ seek(ms) {
45
+ offset = ms
46
+ anchor = now()
47
+ },
48
+ cancel() {
49
+ offset = time()
50
+ paused = true
51
+ },
52
+ }
53
+ }
54
+
55
+ export function validateProgress(value: number, duration: number) {
56
+ if (!Number.isFinite(value) || value < 0 || value > 1)
57
+ throw new RangeError('Animation progress must be between 0 and 1.')
58
+ if (!Number.isFinite(duration))
59
+ throw new RangeError(
60
+ 'Progress requires a finite animation; endless loops can pause and play.',
61
+ )
62
+ }
package/src/properties.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import { Color, type View } from '@nativescript/core'
2
+ import {
3
+ isDecorationProperty,
4
+ readDecoration,
5
+ writeDecoration,
6
+ type DecorationProperty,
7
+ } from './decoration.js'
2
8
  import { isColorProperty, readColor, type ColorProperty } from './color.js'
3
9
 
4
10
  export type NumericProperty =
5
11
  'opacity' | 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotate' | 'rotateX' | 'rotateY'
6
- export type Property = NumericProperty | ColorProperty
12
+ export type Property = NumericProperty | ColorProperty | DecorationProperty
7
13
 
8
14
  const viewKeys = {
9
15
  opacity: 'opacity',
@@ -29,6 +35,14 @@ const defaults: Record<NumericProperty, number> = {
29
35
 
30
36
  /** How close to its target a spring or decay has to be to count as settled. */
31
37
  export const precision: Record<Property, number> = {
38
+ borderRadius: 0.1,
39
+ borderWidth: 0.1,
40
+ shadowOpacity: 0.002,
41
+ shadowRadius: 0.1,
42
+ shadowOffsetX: 0.1,
43
+ shadowOffsetY: 0.1,
44
+ elevation: 0.1,
45
+ translationZ: 0.1,
32
46
  backgroundColor: 0.002,
33
47
  borderColor: 0.002,
34
48
  color: 0.002,
@@ -43,11 +57,12 @@ export const precision: Record<Property, number> = {
43
57
  }
44
58
 
45
59
  export function isProperty(key: string): key is Property {
46
- return key in viewKeys || isColorProperty(key)
60
+ return Object.hasOwn(viewKeys, key) || isColorProperty(key) || isDecorationProperty(key)
47
61
  }
48
62
 
49
63
  export function readValue(view: View, property: Property): number {
50
64
  if (isColorProperty(property)) return readColor(view, property)
65
+ if (isDecorationProperty(property)) return readDecoration(view, property)
51
66
  const value = view[viewKeys[property]]
52
67
  return typeof value === 'number' && !Number.isNaN(value) ? value : defaults[property]
53
68
  }
@@ -57,5 +72,6 @@ export function writeValue(view: View, property: Property, value: number) {
57
72
  view[property] = new Color(value)
58
73
  return
59
74
  }
75
+ if (isDecorationProperty(property)) return writeDecoration(view, property, value)
60
76
  view[viewKeys[property]] = value
61
77
  }
package/src/sequence.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { getDriver } from './driver/index.js'
2
+ import { isDecorationProperty, validateDecorationTarget } from './decoration.js'
1
3
  import type { View } from '@nativescript/core'
2
4
  import { animate, startingValues, toViews } from './animate.js'
3
5
  import { arrivalOf, setArrival, stopView, type Animation } from './engine.js'
@@ -69,6 +71,8 @@ function applyStartingValues(steps: readonly Step[]) {
69
71
  const starts = startingValues(animation, options)
70
72
 
71
73
  for (const view of toViews(target)) {
74
+ for (const property of starts.keys())
75
+ if (isDecorationProperty(property)) validateDecorationTarget(view, property)
72
76
  let properties = claimed.get(view)
73
77
  if (!properties) claimed.set(view, (properties = new Set()))
74
78
 
@@ -82,6 +86,7 @@ function applyStartingValues(steps: readonly Step[]) {
82
86
  for (const [property, value] of fresh) {
83
87
  if (value !== null) writeValue(view, property, value)
84
88
  }
89
+ getDriver().sync?.(view)
85
90
  for (const property of starts.keys()) properties.add(property)
86
91
  }
87
92
  }
package/src/track.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { clampDecoration, isDecorationProperty } from './decoration.js'
1
2
  import { linear } from './easing.js'
2
3
  import { solveDecay } from './decay.js'
3
4
  import { precision, type Property } from './properties.js'
@@ -72,7 +73,7 @@ export function createTrack(
72
73
  return reverse && iteration % 2 === 1 ? [duration - local, -1] : [local, 1]
73
74
  }
74
75
 
75
- return {
76
+ const track: Track = {
76
77
  property,
77
78
  delay,
78
79
  duration,
@@ -94,6 +95,13 @@ export function createTrack(
94
95
  return motion.velocity(time) * direction
95
96
  },
96
97
  }
98
+ if (!isDecorationProperty(property)) return track
99
+ return {
100
+ ...track,
101
+ values: track.values.map((value) => clampDecoration(property, value)),
102
+ to: clampDecoration(property, track.to),
103
+ valueAt: (elapsed) => clampDecoration(property, track.valueAt(elapsed)),
104
+ }
97
105
  }
98
106
 
99
107
  function colorTrack(