@rafaelaugusto/jolt 0.1.0-beta.4 → 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 (201) hide show
  1. package/README.md +134 -4
  2. package/dist/animate.d.ts +7 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +58 -13
  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/decoration.d.ts +18 -0
  11. package/dist/decoration.d.ts.map +1 -0
  12. package/dist/decoration.js +155 -0
  13. package/dist/decoration.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +3 -0
  15. package/dist/driver/android-color.d.ts.map +1 -1
  16. package/dist/driver/android-color.js +20 -18
  17. package/dist/driver/android-color.js.map +1 -1
  18. package/dist/driver/android-decoration.d.ts +22 -0
  19. package/dist/driver/android-decoration.d.ts.map +1 -0
  20. package/dist/driver/android-decoration.js +139 -0
  21. package/dist/driver/android-decoration.js.map +1 -0
  22. package/dist/driver/android-playback.d.ts +13 -0
  23. package/dist/driver/android-playback.d.ts.map +1 -0
  24. package/dist/driver/android-playback.js +141 -0
  25. package/dist/driver/android-playback.js.map +1 -0
  26. package/dist/driver/android.d.ts.map +1 -1
  27. package/dist/driver/android.js +30 -36
  28. package/dist/driver/android.js.map +1 -1
  29. package/dist/driver/index.d.ts +9 -0
  30. package/dist/driver/index.d.ts.map +1 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/ios-color.d.ts +2 -0
  33. package/dist/driver/ios-color.d.ts.map +1 -1
  34. package/dist/driver/ios-color.js +19 -8
  35. package/dist/driver/ios-color.js.map +1 -1
  36. package/dist/driver/ios-decoration.d.ts +17 -0
  37. package/dist/driver/ios-decoration.d.ts.map +1 -0
  38. package/dist/driver/ios-decoration.js +271 -0
  39. package/dist/driver/ios-decoration.js.map +1 -0
  40. package/dist/driver/ios-playback.d.ts +10 -0
  41. package/dist/driver/ios-playback.d.ts.map +1 -0
  42. package/dist/driver/ios-playback.js +47 -0
  43. package/dist/driver/ios-playback.js.map +1 -0
  44. package/dist/driver/ios-shadow.d.ts +6 -0
  45. package/dist/driver/ios-shadow.d.ts.map +1 -0
  46. package/dist/driver/ios-shadow.js +52 -0
  47. package/dist/driver/ios-shadow.js.map +1 -0
  48. package/dist/driver/ios.d.ts.map +1 -1
  49. package/dist/driver/ios.js +76 -40
  50. package/dist/driver/ios.js.map +1 -1
  51. package/dist/engine.d.ts +64 -3
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +243 -19
  54. package/dist/engine.js.map +1 -1
  55. package/dist/haptics.d.ts +4 -1
  56. package/dist/haptics.d.ts.map +1 -1
  57. package/dist/haptics.js +27 -3
  58. package/dist/haptics.js.map +1 -1
  59. package/dist/index.d.ts +12 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +5 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/items.d.ts +20 -0
  64. package/dist/items.d.ts.map +1 -0
  65. package/dist/items.js +109 -0
  66. package/dist/items.js.map +1 -0
  67. package/dist/keyboard.d.ts +17 -0
  68. package/dist/keyboard.d.ts.map +1 -0
  69. package/dist/keyboard.js +116 -0
  70. package/dist/keyboard.js.map +1 -0
  71. package/dist/label.d.ts +7 -0
  72. package/dist/label.d.ts.map +1 -0
  73. package/dist/label.js +60 -0
  74. package/dist/label.js.map +1 -0
  75. package/dist/liquid.d.ts.map +1 -1
  76. package/dist/liquid.js +2 -1
  77. package/dist/liquid.js.map +1 -1
  78. package/dist/loader.d.ts.map +1 -1
  79. package/dist/loader.js +2 -1
  80. package/dist/loader.js.map +1 -1
  81. package/dist/particles.d.ts.map +1 -1
  82. package/dist/particles.js +4 -3
  83. package/dist/particles.js.map +1 -1
  84. package/dist/path.d.ts +12 -0
  85. package/dist/path.d.ts.map +1 -0
  86. package/dist/path.js +90 -0
  87. package/dist/path.js.map +1 -0
  88. package/dist/playback-group.d.ts +6 -0
  89. package/dist/playback-group.d.ts.map +1 -0
  90. package/dist/playback-group.js +141 -0
  91. package/dist/playback-group.js.map +1 -0
  92. package/dist/playback.d.ts +25 -0
  93. package/dist/playback.d.ts.map +1 -0
  94. package/dist/playback.js +35 -0
  95. package/dist/playback.js.map +1 -0
  96. package/dist/press.d.ts.map +1 -1
  97. package/dist/press.js +3 -3
  98. package/dist/press.js.map +1 -1
  99. package/dist/properties.d.ts +2 -1
  100. package/dist/properties.d.ts.map +1 -1
  101. package/dist/properties.js +14 -1
  102. package/dist/properties.js.map +1 -1
  103. package/dist/reveal.d.ts.map +1 -1
  104. package/dist/reveal.js +3 -2
  105. package/dist/reveal.js.map +1 -1
  106. package/dist/roll-diff.d.ts +22 -0
  107. package/dist/roll-diff.d.ts.map +1 -0
  108. package/dist/roll-diff.js +36 -0
  109. package/dist/roll-diff.js.map +1 -0
  110. package/dist/roll.d.ts +29 -0
  111. package/dist/roll.d.ts.map +1 -0
  112. package/dist/roll.js +442 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/scroll-linked.d.ts +10 -0
  115. package/dist/scroll-linked.d.ts.map +1 -0
  116. package/dist/scroll-linked.js +58 -0
  117. package/dist/scroll-linked.js.map +1 -0
  118. package/dist/sequence.d.ts +10 -5
  119. package/dist/sequence.d.ts.map +1 -1
  120. package/dist/sequence.js +208 -14
  121. package/dist/sequence.js.map +1 -1
  122. package/dist/shimmer.d.ts.map +1 -1
  123. package/dist/shimmer.js +2 -1
  124. package/dist/shimmer.js.map +1 -1
  125. package/dist/svg-path.d.ts +14 -0
  126. package/dist/svg-path.d.ts.map +1 -0
  127. package/dist/svg-path.js +281 -0
  128. package/dist/svg-path.js.map +1 -0
  129. package/dist/symbol-effect.d.ts.map +1 -1
  130. package/dist/symbol-effect.js +4 -3
  131. package/dist/symbol-effect.js.map +1 -1
  132. package/dist/text-layout.d.ts +8 -0
  133. package/dist/text-layout.d.ts.map +1 -1
  134. package/dist/text-layout.js +8 -2
  135. package/dist/text-layout.js.map +1 -1
  136. package/dist/text.d.ts +1 -1
  137. package/dist/text.d.ts.map +1 -1
  138. package/dist/text.js +60 -64
  139. package/dist/text.js.map +1 -1
  140. package/dist/track.d.ts +2 -0
  141. package/dist/track.d.ts.map +1 -1
  142. package/dist/track.js +72 -16
  143. package/dist/track.js.map +1 -1
  144. package/dist/transition.d.ts.map +1 -1
  145. package/dist/transition.js +4 -1
  146. package/dist/transition.js.map +1 -1
  147. package/dist/types.d.ts +22 -1
  148. package/dist/types.d.ts.map +1 -1
  149. package/dist/vue/index.d.ts +89 -0
  150. package/dist/vue/index.d.ts.map +1 -0
  151. package/dist/vue/index.js +134 -0
  152. package/dist/vue/index.js.map +1 -0
  153. package/package.json +15 -3
  154. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +2 -2
  155. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  156. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  157. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  158. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  159. package/platforms/android/proguard-rules.pro +7 -0
  160. package/src/android-plugin.d.ts +64 -0
  161. package/src/animate.ts +96 -33
  162. package/src/config.ts +46 -0
  163. package/src/decoration.ts +180 -0
  164. package/src/driver/android-color.ts +22 -20
  165. package/src/driver/android-decoration.ts +182 -0
  166. package/src/driver/android-playback.ts +157 -0
  167. package/src/driver/android.ts +57 -43
  168. package/src/driver/index.ts +15 -0
  169. package/src/driver/ios-color.ts +19 -10
  170. package/src/driver/ios-decoration.ts +332 -0
  171. package/src/driver/ios-playback.ts +51 -0
  172. package/src/driver/ios-shadow.ts +50 -0
  173. package/src/driver/ios.ts +87 -46
  174. package/src/engine.ts +267 -22
  175. package/src/haptics.ts +25 -2
  176. package/src/index.ts +12 -1
  177. package/src/items.ts +140 -0
  178. package/src/keyboard.ts +132 -0
  179. package/src/label.ts +76 -0
  180. package/src/liquid.ts +2 -1
  181. package/src/loader.ts +2 -1
  182. package/src/particles.ts +4 -4
  183. package/src/path.ts +124 -0
  184. package/src/playback-group.ts +143 -0
  185. package/src/playback.ts +62 -0
  186. package/src/press.ts +3 -5
  187. package/src/properties.ts +18 -2
  188. package/src/reveal.ts +3 -3
  189. package/src/roll-diff.ts +57 -0
  190. package/src/roll.ts +515 -0
  191. package/src/scroll-linked.ts +73 -0
  192. package/src/sequence.ts +204 -14
  193. package/src/shimmer.ts +2 -1
  194. package/src/svg-path.ts +355 -0
  195. package/src/symbol-effect.ts +4 -3
  196. package/src/text-layout.ts +14 -2
  197. package/src/text.ts +62 -81
  198. package/src/track.ts +98 -25
  199. package/src/transition.ts +4 -1
  200. package/src/types.ts +23 -1
  201. package/src/vue/index.ts +189 -0
@@ -0,0 +1,50 @@
1
+ import type { View } from '@nativescript/core'
2
+
3
+ type BackgroundView = { _redrawNativeBackground?: (...args: unknown[]) => void }
4
+ const repairs = new WeakSet<View>()
5
+
6
+ /** Core treats alpha 0 as 1. Repair its model layer after background redraws,
7
+ * not on animation frames. The wrapper belongs only to this view and is
8
+ * removed when the shadow color changes. */
9
+ export function preserveZeroShadow(view: View) {
10
+ const target = view as unknown as BackgroundView
11
+ const hasZero = () =>
12
+ Array.isArray(view.boxShadow) &&
13
+ view.boxShadow.some((shadow) => shadow.color.argb >>> 24 === 0)
14
+ if (!hasZero() || repairs.has(view)) return
15
+ const descriptor = Object.getOwnPropertyDescriptor(view, '_redrawNativeBackground')
16
+ const original = target._redrawNativeBackground
17
+ const apply = () => {
18
+ if (!hasZero()) return dispose()
19
+ if (!view.isLoaded) return
20
+ const layers = view.nativeViewProtected?.outerShadowContainerLayer?.sublayers as
21
+ NSArray<CALayer> | undefined
22
+ if (!layers || !Array.isArray(view.boxShadow)) return
23
+ CATransaction.begin()
24
+ CATransaction.setDisableActions(true)
25
+ for (let i = 0; i < Math.min(layers.count, view.boxShadow.length); i++)
26
+ if (view.boxShadow[i]!.color.argb >>> 24 === 0)
27
+ layers.objectAtIndex(i).shadowOpacity = 0
28
+ CATransaction.commit()
29
+ }
30
+ const redraw = function (this: BackgroundView, ...args: unknown[]) {
31
+ original!.apply(this, args)
32
+ apply()
33
+ }
34
+ const dispose = () => {
35
+ view.style.off('boxShadowChange', apply)
36
+ view.off('loaded', apply)
37
+ view.off('layoutChanged', apply)
38
+ if (original && target._redrawNativeBackground === redraw) {
39
+ if (descriptor) Object.defineProperty(view, '_redrawNativeBackground', descriptor)
40
+ else delete target._redrawNativeBackground
41
+ }
42
+ repairs.delete(view)
43
+ }
44
+ repairs.add(view)
45
+ if (original) target._redrawNativeBackground = redraw
46
+ view.style.on('boxShadowChange', apply)
47
+ view.on('loaded', apply)
48
+ view.on('layoutChanged', apply)
49
+ apply()
50
+ }
package/src/driver/ios.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import { iosPlayback } from './ios-playback.js'
2
+ import { isDecorationProperty } from '../decoration.js'
3
+ import {
4
+ playIOSDecoration,
5
+ presentedIOSDecoration,
6
+ syncIOSDecoration,
7
+ } from './ios-decoration.js'
1
8
  import type { View } from '@nativescript/core'
2
9
  import type { NumericProperty } from '../properties.js'
3
10
  import { isColorProperty } from '../color.js'
@@ -5,10 +12,9 @@ import { playIOSColor, presentedIOSColor } from './ios-color.js'
5
12
  import { after, type Driver } from './index.js'
6
13
 
7
14
  interface Handle {
8
- layers: CALayer[]
9
- key: string
10
- onStop?: () => void
11
- color?: ReturnType<typeof playIOSColor>
15
+ stop(): void
16
+ elapsed(): number
17
+ control(elapsed: number, paused: boolean): void
12
18
  }
13
19
 
14
20
  const keyPaths: Record<NumericProperty, string> = {
@@ -52,8 +58,10 @@ export function createIOSDriver(): Driver {
52
58
  now: () => CACurrentMediaTime() * 1000,
53
59
 
54
60
  play(view, track, startedAt, onEnd): Handle {
61
+ if (isDecorationProperty(track.property))
62
+ return playIOSDecoration(view, track, startedAt, onEnd)
55
63
  if (isColorProperty(track.property))
56
- return { layers: [], key: '', color: playIOSColor(view, track, startedAt, onEnd) }
64
+ return playIOSColor(view, track, startedAt, onEnd)
57
65
  const layers = layersOf(view)
58
66
  const key = `jolt:${track.property}`
59
67
  const rotation = track.property.startsWith('rotate')
@@ -62,57 +70,90 @@ export function createIOSDriver(): Driver {
62
70
  ensurePerspective(view, layers)
63
71
  }
64
72
 
65
- const animation = CAKeyframeAnimation.animationWithKeyPath(keyPaths[track.property])
66
- // iOS 15+. The app must opt in through CADisableMinimumFrameDurationOnPhone.
67
- if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
68
- const nativeView = view.nativeViewProtected as UIView
69
- const screen = nativeView.window?.screen ?? UIScreen.mainScreen
70
- const maximum = Math.min(120, screen.maximumFramesPerSecond)
71
- animation.preferredFrameRateRange = CAFrameRateRangeMake(
72
- Math.min(80, maximum),
73
- maximum,
74
- maximum,
73
+ const timing = iosPlayback(track, startedAt)
74
+ let stopped = false,
75
+ generation = 0
76
+ const attach = () => {
77
+ const instance = ++generation
78
+ const animation = CAKeyframeAnimation.animationWithKeyPath(
79
+ keyPaths[track.property as NumericProperty],
75
80
  )
81
+ // iOS 15+. The app must opt in through CADisableMinimumFrameDurationOnPhone.
82
+ if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
83
+ const nativeView = view.nativeViewProtected as UIView
84
+ const screen = nativeView.window?.screen ?? UIScreen.mainScreen
85
+ const maximum = Math.min(120, screen.maximumFramesPerSecond)
86
+ animation.preferredFrameRateRange = CAFrameRateRangeMake(
87
+ Math.min(80, maximum),
88
+ maximum,
89
+ maximum,
90
+ )
91
+ }
92
+ animation.values = NSArray.arrayWithArray(
93
+ track.values.map((value) => (rotation ? radians(value) : value)),
94
+ )
95
+ animation.keyTimes = NSArray.arrayWithArray(track.times as number[])
96
+ animation.duration = Math.max(track.duration, 1) / 1000
97
+ // Keep infinite loops when iOS removes animations on backgrounding.
98
+ // They are removed explicitly when stopped or replaced.
99
+ if (track.repeat > 0) {
100
+ animation.autoreverses = track.reverse
101
+ animation.repeatCount = track.reverse
102
+ ? (track.repeat + 1) / 2
103
+ : track.repeat + 1
104
+ }
105
+
106
+ CATransaction.begin()
107
+ if (!timing.paused && !infinite)
108
+ CATransaction.setCompletionBlock(() => {
109
+ if (stopped || instance !== generation) return
110
+ stopped = true
111
+ onEnd()
112
+ })
113
+ for (const layer of layers) {
114
+ timing.configure(animation, layer)
115
+ layer.addAnimationForKey(animation, key)
116
+ }
117
+ CATransaction.commit()
76
118
  }
77
- animation.values = NSArray.arrayWithArray(
78
- track.values.map((value) => (rotation ? radians(value) : value)),
79
- )
80
- animation.keyTimes = NSArray.arrayWithArray(track.times as number[])
81
- animation.duration = Math.max(track.duration, 1) / 1000
82
- animation.beginTime = (startedAt + track.delay) / 1000
83
- animation.fillMode = kCAFillModeBackwards
84
- // Keep infinite loops when iOS removes animations on backgrounding.
85
- // They are removed explicitly when stopped or replaced.
86
- animation.removedOnCompletion = !infinite
87
- if (track.repeat > 0) {
88
- animation.autoreverses = track.reverse
89
- animation.repeatCount = track.reverse ? (track.repeat + 1) / 2 : track.repeat + 1
119
+ attach()
120
+ return {
121
+ elapsed: timing.elapsed,
122
+ control(time, paused) {
123
+ if (!stopped) {
124
+ timing.control(time, paused)
125
+ attach()
126
+ }
127
+ },
128
+ stop() {
129
+ if (stopped) return
130
+ stopped = true
131
+ generation++
132
+ for (const layer of layers) layer.removeAnimationForKey(key)
133
+ onEnd(false)
134
+ },
90
135
  }
91
-
92
- CATransaction.begin()
93
- // iOS completes the transaction on backgrounding even if the loop is retained.
94
- if (!infinite) CATransaction.setCompletionBlock(onEnd)
95
- for (const layer of layers) layer.addAnimationForKey(animation, key)
96
- CATransaction.commit()
97
-
98
- return { layers, key, onStop: infinite ? onEnd : undefined }
99
136
  },
100
137
 
101
138
  stop(_view, handle) {
102
- const playing = handle as Handle
103
- const { layers, key, onStop } = playing
104
- if (playing.color) {
105
- playing.color.stop()
106
- return
107
- }
108
- playing.onStop = undefined
109
- for (const layer of layers) layer.removeAnimationForKey(key)
110
- onStop?.()
139
+ ;(handle as Handle).stop()
140
+ },
141
+ control(_view, handle, elapsed, paused) {
142
+ ;(handle as Handle).control(elapsed, paused)
111
143
  },
144
+ elapsed: (handle) => (handle as Handle).elapsed(),
145
+
146
+ sync: syncIOSDecoration,
112
147
 
113
148
  presented: (view, property) =>
114
- isColorProperty(property) ? presentedIOSColor(view, property) : undefined,
149
+ isDecorationProperty(property)
150
+ ? presentedIOSDecoration(view, property)
151
+ : isColorProperty(property)
152
+ ? presentedIOSColor(view, property)
153
+ : undefined,
115
154
 
116
155
  after,
156
+
157
+ reducedMotion: () => UIAccessibilityIsReduceMotionEnabled(),
117
158
  }
118
159
  }
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'
@@ -5,12 +12,13 @@ import { readValue, writeValue, type Property } from './properties.js'
5
12
  import { createTrack, type Track } from './track.js'
6
13
  import type { ResolvedTransition } from './transition.js'
7
14
  import { isColorProperty } from './color.js'
15
+ import type { PreparedPath, PathProperty } from './path.js'
8
16
 
9
17
  export interface Animation extends PromiseLike<boolean> {
10
18
  /**
11
19
  * Resolves with `true` once the animation played to the end, or with `false`
12
20
  * if it was stopped, superseded or skipped.
13
- * Never rejects.
21
+ * Playback cancellation never rejects; sequences can reject setup/callback errors.
14
22
  */
15
23
  readonly finished: Promise<boolean>
16
24
  /** Stops playback. Each effect documents whether it preserves or restores its visual state. */
@@ -23,6 +31,7 @@ export interface Plan {
23
31
  keyframes: readonly (number | null)[]
24
32
  transition: ResolvedTransition
25
33
  relative?: boolean
34
+ path?: PreparedPath
26
35
  }
27
36
 
28
37
  interface Entry {
@@ -31,11 +40,32 @@ interface Entry {
31
40
  readonly owner: ViewAnimation
32
41
  handle: unknown
33
42
  done: boolean
43
+ finished: boolean
44
+ generation: number
45
+ token: symbol
46
+ completedValue?: number
34
47
  }
35
48
 
49
+ const revisions = new WeakMap<View, Map<Property, symbol>>()
50
+
36
51
  const running = new WeakMap<View, Map<Property, Entry>>()
37
52
  const arrivals = new WeakMap<Animation, Promise<unknown>>()
38
53
 
54
+ /** Internal forecast for starting a timeline step before another one arrives. */
55
+ export interface ArrivalTiming {
56
+ read(): { remaining: number | undefined; paused: boolean; arrived: boolean }
57
+ subscribe(callback: () => void): () => void
58
+ }
59
+ const arrivalTimings = new WeakMap<Animation, ArrivalTiming>()
60
+
61
+ export function arrivalTimingOf(animation: Animation) {
62
+ return arrivalTimings.get(animation)
63
+ }
64
+
65
+ export function setArrivalTiming(animation: Animation, timing: ArrivalTiming) {
66
+ arrivalTimings.set(animation, timing)
67
+ }
68
+
39
69
  /**
40
70
  * Resolves once the animation looks done, which for a spring is well before
41
71
  * `finished`. Sequences move on to the next step then.
@@ -48,7 +78,7 @@ export function setArrival(animation: Animation, arrival: Promise<unknown>) {
48
78
  arrivals.set(animation, arrival)
49
79
  }
50
80
 
51
- export class ViewAnimation implements Animation {
81
+ export class ViewAnimation implements PlaybackAnimation {
52
82
  readonly finished: Promise<boolean>
53
83
  private resolve!: (finished: boolean) => void
54
84
  private readonly entries: Entry[] = []
@@ -59,14 +89,202 @@ export class ViewAnimation implements Animation {
59
89
  private arrive!: () => void
60
90
  private cancelArrival: (() => void) | undefined
61
91
  private haptics: ReturnType<typeof playHaptics>
92
+ private clock: PlaybackClock | undefined
93
+ private _paused = false
94
+ private controlled = false
95
+ private pendingProgress: number | undefined
96
+ private total = 0
97
+ private started = false
98
+ private arrival = 0
99
+ private arrived = false
100
+ private idleListener: (() => void) | undefined
101
+ private readyListener: (() => void) | undefined
102
+ private readonly endless: boolean
103
+ private readonly timingListeners = new Set<() => void>()
104
+ private timingChanged() {
105
+ for (const callback of [...this.timingListeners]) callback()
106
+ }
107
+
108
+ get paused() {
109
+ return this._paused
110
+ }
111
+ get duration() {
112
+ return this.started ? this.total : this.endless ? Infinity : 0
113
+ }
114
+ get currentTime() {
115
+ return this.time()
116
+ }
117
+ get ready() {
118
+ return this.started || this.settled
119
+ }
120
+ get idle() {
121
+ return this.settled || (this.started && this.pending === 0)
122
+ }
123
+ get progress() {
124
+ if (!Number.isFinite(this.duration))
125
+ throw new RangeError('Progress requires a finite animation.')
126
+ if (this.settled && this.complete) return 1
127
+ return (
128
+ this.pendingProgress ??
129
+ (this.duration ? Math.min(1, this.time() / this.duration) : this.settled ? 1 : 0)
130
+ )
131
+ }
132
+ set progress(value: number) {
133
+ validateProgress(value, this.duration)
134
+ if (this.settled) return
135
+ if (!this.started) {
136
+ this.pendingProgress = value
137
+ return
138
+ }
139
+ this.seekTime(this.duration * value)
140
+ }
141
+ onIdle(callback: () => void) {
142
+ this.idleListener = callback
143
+ }
144
+ onReady(callback: () => void) {
145
+ this.readyListener = callback
146
+ }
147
+ releaseGroup() {
148
+ this.held = false
149
+ if (this.idle && !this._paused) this.settle()
150
+ }
151
+
152
+ pause() {
153
+ if (this.settled || this._paused) return
154
+ this.controlled = true
155
+ this._paused = true
156
+ this.clock?.pause()
157
+ this.cancelArrival?.()
158
+ this.haptics?.pause()
159
+ if (this.started) this.controlTracks(this.time(), 'pause')
160
+ this.timingChanged()
161
+ }
162
+ play() {
163
+ if (this.settled || !this._paused) return
164
+ this.controlled = true
165
+ this._paused = false
166
+ this.clock?.play()
167
+ if (this.started) {
168
+ this.controlTracks(this.time(), 'play')
169
+ this.scheduleArrival()
170
+ this.haptics?.play()
171
+ if (this.pending === 0) this.becameIdle()
172
+ }
173
+ this.timingChanged()
174
+ }
175
+ seekTime(time: number) {
176
+ if (this.settled) return
177
+ this.controlled = true
178
+ const position = Math.max(0, Math.min(this.total, time))
179
+ this.clock?.seek(position)
180
+ if (!this._paused) this.clock?.play()
181
+ for (const entry of this.entries) {
182
+ if (
183
+ entry.done &&
184
+ entry.finished &&
185
+ revisions.get(this.view)?.get(entry.track.property) === entry.token &&
186
+ readValue(this.view, entry.track.property) === entry.completedValue
187
+ ) {
188
+ entry.done = false
189
+ entry.finished = false
190
+ this.pending++
191
+ entriesOf(this.view).set(entry.track.property, entry)
192
+ this.playEntry(entry, position)
193
+ }
194
+ }
195
+ this.controlTracks(position)
196
+ this.scheduleArrival()
197
+ this.haptics?.seek()
198
+ if (this.pending === 0) this.becameIdle()
199
+ this.timingChanged()
200
+ }
201
+ elapsed(entry: Entry, now: number) {
202
+ return this.controlled
203
+ ? this.time()
204
+ : (getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt)
205
+ }
206
+ private time() {
207
+ return this.clock?.time() ?? 0
208
+ }
209
+ private controlTracks(position: number, command: PlaybackCommand = 'seek') {
210
+ const driver = getDriver()
211
+ for (const entry of this.entries)
212
+ if (!entry.done)
213
+ driver.control?.(this.view, entry.handle, position, this._paused, command)
214
+ }
215
+ private scheduleArrival() {
216
+ this.cancelArrival?.()
217
+ this.cancelArrival = undefined
218
+ if (
219
+ this.pending === 0 ||
220
+ this.arrived ||
221
+ this._paused ||
222
+ !Number.isFinite(this.arrival) ||
223
+ this.total <= this.arrival
224
+ )
225
+ return
226
+ const remaining = this.arrival - this.time()
227
+ if (remaining <= 0) {
228
+ this.arrived = true
229
+ this.arrive()
230
+ this.timingChanged()
231
+ } else
232
+ this.cancelArrival = getDriver().after(remaining, () => {
233
+ this.arrived = true
234
+ this.arrive()
235
+ this.timingChanged()
236
+ })
237
+ }
238
+ private becameIdle() {
239
+ this.clock?.pause()
240
+ this.idleListener?.()
241
+ if (!this.complete || (!this.held && !this._paused)) this.settle()
242
+ }
243
+ private playEntry(entry: Entry, elapsed = 0) {
244
+ const initial = entry.generation === 0
245
+ const instance = ++entry.generation
246
+ const driver = getDriver()
247
+ entry.handle = driver.play(
248
+ this.view,
249
+ entry.track,
250
+ initial ? entry.startedAt : driver.now() - elapsed,
251
+ (finished = true) => {
252
+ if (entry.done || instance !== entry.generation) return
253
+ entry.done = true
254
+ entry.finished = finished
255
+ entry.completedValue = readValue(this.view, entry.track.property)
256
+ forget(entry)
257
+ this.ended(finished)
258
+ },
259
+ )
260
+ }
261
+ private unloaded = () => this.stop()
62
262
 
63
263
  constructor(
64
264
  readonly view: View,
65
265
  plans: readonly Plan[],
66
266
  private readonly haptic?: HapticOptions,
267
+ private held = false,
67
268
  ) {
269
+ this.endless = plans.some(
270
+ (plan) =>
271
+ !Number.isFinite(plan.transition.repeat) ||
272
+ (plan.transition.curve.type === 'tween' &&
273
+ !Number.isFinite(plan.transition.curve.duration)),
274
+ )
68
275
  this.finished = new Promise((resolve) => (this.resolve = resolve))
69
276
  setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
277
+ setArrivalTiming(this, {
278
+ read: () => ({
279
+ remaining: this.started ? Math.max(0, this.arrival - this.time()) : undefined,
280
+ paused: this._paused,
281
+ arrived: this.arrived || this.settled,
282
+ }),
283
+ subscribe: (callback) => {
284
+ this.timingListeners.add(callback)
285
+ return () => this.timingListeners.delete(callback)
286
+ },
287
+ })
70
288
  if (view.isLoaded && view.nativeViewProtected) this.start(plans)
71
289
  else this.waitForLoad(plans)
72
290
  }
@@ -95,22 +313,23 @@ export class ViewAnimation implements Animation {
95
313
  this.complete = false
96
314
  this.haptics?.cancel()
97
315
  }
98
- if (--this.pending === 0) this.settle()
316
+ if (--this.pending === 0) this.becameIdle()
99
317
  }
100
318
 
101
319
  private start(plans: readonly Plan[]) {
320
+ this.started = true
102
321
  const driver = getDriver()
103
322
  const now = driver.now()
104
323
  const entries = entriesOf(this.view)
105
324
 
106
- const tracks = plans.map(({ property, keyframes, transition, relative }) => {
325
+ const tracks = plans.map(({ property, keyframes, transition, relative, path }) => {
107
326
  const previous = entries.get(property)
108
327
  let current: number
109
328
  let velocity = 0
110
329
  if (previous) {
111
330
  const elapsed = elapsedOf(previous, now)
112
331
  current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
113
- velocity = previous.track.velocityAt(elapsed)
332
+ velocity = previous.owner.paused ? 0 : previous.track.velocityAt(elapsed)
114
333
  release(previous, now, false)
115
334
  } else {
116
335
  current =
@@ -118,6 +337,12 @@ export class ViewAnimation implements Animation {
118
337
  ? driver.presented(this.view, property)
119
338
  : undefined) ?? readValue(this.view, property)
120
339
  }
340
+ if (path)
341
+ return path.track(
342
+ property as PathProperty,
343
+ relative ? current : 0,
344
+ transition.delay,
345
+ )
121
346
  return createTrack(
122
347
  property,
123
348
  keyframes.map((value) => {
@@ -133,8 +358,13 @@ export class ViewAnimation implements Animation {
133
358
  })
134
359
 
135
360
  for (const track of tracks) writeValue(this.view, track.property, track.to)
361
+ driver.sync?.(this.view)
136
362
 
137
363
  for (const track of tracks) {
364
+ let tokens = revisions.get(this.view)
365
+ if (!tokens) revisions.set(this.view, (tokens = new Map()))
366
+ const token = Symbol(track.property)
367
+ tokens.set(track.property, token)
138
368
  if (track.end === 0) continue
139
369
  const entry: Entry = {
140
370
  track,
@@ -142,28 +372,36 @@ export class ViewAnimation implements Animation {
142
372
  owner: this,
143
373
  handle: undefined,
144
374
  done: false,
375
+ finished: false,
376
+ generation: 0,
377
+ token,
145
378
  }
146
379
  entries.set(track.property, entry)
147
380
  this.entries.push(entry)
148
381
  this.pending++
149
382
  }
150
383
 
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
- }
384
+ this.total = Math.max(0, ...tracks.map((track) => track.end))
385
+ this.arrival = Math.max(0, ...tracks.map((track) => track.arrival))
386
+ this.clock =
387
+ (this.pending ? driver.clock?.(this.total) : undefined) ??
388
+ createClock(() => driver.now())
389
+ if (this._paused) this.clock.pause()
390
+ this.view.on('unloaded', this.unloaded)
391
+ this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now, () =>
392
+ this.time(),
393
+ )
394
+ if (this._paused) this.haptics?.pause()
395
+ for (const entry of this.entries) this.playEntry(entry)
396
+ if (this.pendingProgress !== undefined) {
397
+ const progress = this.pendingProgress
398
+ this.pendingProgress = undefined
399
+ this.seekTime(progress * this.total)
400
+ } else if (this._paused) this.controlTracks(0, 'pause')
401
+ this.scheduleArrival()
402
+ this.readyListener?.()
403
+ this.timingChanged()
404
+ if (this.pending === 0) this.becameIdle()
167
405
  }
168
406
 
169
407
  private waitForLoad(plans: readonly Plan[]) {
@@ -187,16 +425,22 @@ export class ViewAnimation implements Animation {
187
425
  if (this.settled) return
188
426
  this.settled = true
189
427
  this.cancelArrival?.()
428
+ this.clock?.cancel()
429
+ this.view.off('unloaded', this.unloaded)
190
430
  if (this.complete) this.haptics?.finish()
191
431
  else this.haptics?.cancel()
192
432
  this.haptics = undefined
193
433
  this.arrive()
194
434
  this.resolve(this.complete)
435
+ this.timingChanged()
195
436
  }
196
437
  }
197
438
 
198
439
  /** Stops whatever is animating these properties of the view, or all of them. */
199
440
  export function stopView(view: View, properties?: readonly Property[]) {
441
+ const tokens = revisions.get(view)
442
+ for (const property of properties ?? tokens?.keys() ?? [])
443
+ tokens?.set(property, Symbol(property))
200
444
  const entries = running.get(view)
201
445
  if (!entries) return
202
446
  const now = getDriver().now()
@@ -226,11 +470,12 @@ function release(entry: Entry, now: number, commit: boolean) {
226
470
  const value =
227
471
  driver.presented(view, property) ?? entry.track.valueAt(elapsedOf(entry, now))
228
472
  writeValue(view, property, value)
473
+ driver.sync?.(view, property)
229
474
  }
230
475
  driver.stop(view, entry.handle)
231
476
  entry.owner.ended(false)
232
477
  }
233
478
 
234
479
  function elapsedOf(entry: Entry, now: number) {
235
- return getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt
480
+ return entry.owner.elapsed(entry, now)
236
481
  }