@rafaelaugusto/jolt 0.1.0-beta.1 → 0.1.0-beta.10

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 (302) hide show
  1. package/README.md +367 -18
  2. package/dist/animate.d.ts +19 -3
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +186 -14
  5. package/dist/animate.js.map +1 -1
  6. package/dist/color.d.ts +11 -0
  7. package/dist/color.d.ts.map +1 -0
  8. package/dist/color.js +73 -0
  9. package/dist/color.js.map +1 -0
  10. package/dist/config.d.ts +31 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +73 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/decay.d.ts +11 -0
  15. package/dist/decay.d.ts.map +1 -0
  16. package/dist/decay.js +22 -0
  17. package/dist/decay.js.map +1 -0
  18. package/dist/decoration.d.ts +18 -0
  19. package/dist/decoration.d.ts.map +1 -0
  20. package/dist/decoration.js +155 -0
  21. package/dist/decoration.js.map +1 -0
  22. package/dist/drag-input.d.ts +15 -0
  23. package/dist/drag-input.d.ts.map +1 -0
  24. package/dist/drag-input.js +66 -0
  25. package/dist/drag-input.js.map +1 -0
  26. package/dist/drag-physics.d.ts +20 -0
  27. package/dist/drag-physics.d.ts.map +1 -0
  28. package/dist/drag-physics.js +75 -0
  29. package/dist/drag-physics.js.map +1 -0
  30. package/dist/drag.d.ts +58 -0
  31. package/dist/drag.d.ts.map +1 -0
  32. package/dist/drag.js +324 -0
  33. package/dist/drag.js.map +1 -0
  34. package/dist/driver/android-color.d.ts +20 -0
  35. package/dist/driver/android-color.d.ts.map +1 -0
  36. package/dist/driver/android-color.js +136 -0
  37. package/dist/driver/android-color.js.map +1 -0
  38. package/dist/driver/android-decoration.d.ts +22 -0
  39. package/dist/driver/android-decoration.d.ts.map +1 -0
  40. package/dist/driver/android-decoration.js +139 -0
  41. package/dist/driver/android-decoration.js.map +1 -0
  42. package/dist/driver/android-playback.d.ts +13 -0
  43. package/dist/driver/android-playback.d.ts.map +1 -0
  44. package/dist/driver/android-playback.js +141 -0
  45. package/dist/driver/android-playback.js.map +1 -0
  46. package/dist/driver/android.d.ts +1 -1
  47. package/dist/driver/android.d.ts.map +1 -1
  48. package/dist/driver/android.js +165 -13
  49. package/dist/driver/android.js.map +1 -1
  50. package/dist/driver/index.d.ts +25 -2
  51. package/dist/driver/index.d.ts.map +1 -1
  52. package/dist/driver/index.js.map +1 -1
  53. package/dist/driver/ios-color.d.ts +16 -0
  54. package/dist/driver/ios-color.d.ts.map +1 -0
  55. package/dist/driver/ios-color.js +392 -0
  56. package/dist/driver/ios-color.js.map +1 -0
  57. package/dist/driver/ios-decoration.d.ts +17 -0
  58. package/dist/driver/ios-decoration.d.ts.map +1 -0
  59. package/dist/driver/ios-decoration.js +271 -0
  60. package/dist/driver/ios-decoration.js.map +1 -0
  61. package/dist/driver/ios-layout.d.ts +9 -0
  62. package/dist/driver/ios-layout.d.ts.map +1 -0
  63. package/dist/driver/ios-layout.js +52 -0
  64. package/dist/driver/ios-layout.js.map +1 -0
  65. package/dist/driver/ios-playback.d.ts +10 -0
  66. package/dist/driver/ios-playback.d.ts.map +1 -0
  67. package/dist/driver/ios-playback.js +47 -0
  68. package/dist/driver/ios-playback.js.map +1 -0
  69. package/dist/driver/ios-shadow.d.ts +6 -0
  70. package/dist/driver/ios-shadow.d.ts.map +1 -0
  71. package/dist/driver/ios-shadow.js +52 -0
  72. package/dist/driver/ios-shadow.js.map +1 -0
  73. package/dist/driver/ios.d.ts.map +1 -1
  74. package/dist/driver/ios.js +163 -28
  75. package/dist/driver/ios.js.map +1 -1
  76. package/dist/engine.d.ts +75 -5
  77. package/dist/engine.d.ts.map +1 -1
  78. package/dist/engine.js +293 -23
  79. package/dist/engine.js.map +1 -1
  80. package/dist/haptics.d.ts +21 -0
  81. package/dist/haptics.d.ts.map +1 -0
  82. package/dist/haptics.js +120 -0
  83. package/dist/haptics.js.map +1 -0
  84. package/dist/image-effect-native.d.ts +26 -0
  85. package/dist/image-effect-native.d.ts.map +1 -0
  86. package/dist/image-effect-native.js +73 -0
  87. package/dist/image-effect-native.js.map +1 -0
  88. package/dist/image-effect.d.ts +50 -0
  89. package/dist/image-effect.d.ts.map +1 -0
  90. package/dist/image-effect.js +290 -0
  91. package/dist/image-effect.js.map +1 -0
  92. package/dist/image-owner.d.ts +3 -0
  93. package/dist/image-owner.d.ts.map +1 -0
  94. package/dist/image-owner.js +11 -0
  95. package/dist/image-owner.js.map +1 -0
  96. package/dist/index.d.ts +43 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +20 -1
  99. package/dist/index.js.map +1 -1
  100. package/dist/interaction-surface.d.ts +12 -0
  101. package/dist/interaction-surface.d.ts.map +1 -0
  102. package/dist/interaction-surface.js +36 -0
  103. package/dist/interaction-surface.js.map +1 -0
  104. package/dist/items.d.ts +20 -0
  105. package/dist/items.d.ts.map +1 -0
  106. package/dist/items.js +109 -0
  107. package/dist/items.js.map +1 -0
  108. package/dist/keyboard.d.ts +41 -0
  109. package/dist/keyboard.d.ts.map +1 -0
  110. package/dist/keyboard.js +256 -0
  111. package/dist/keyboard.js.map +1 -0
  112. package/dist/label.d.ts +7 -0
  113. package/dist/label.d.ts.map +1 -0
  114. package/dist/label.js +60 -0
  115. package/dist/label.js.map +1 -0
  116. package/dist/layout.d.ts +13 -0
  117. package/dist/layout.d.ts.map +1 -0
  118. package/dist/layout.js +348 -0
  119. package/dist/layout.js.map +1 -0
  120. package/dist/liquid-grid.d.ts +3 -0
  121. package/dist/liquid-grid.d.ts.map +1 -0
  122. package/dist/liquid-grid.js +24 -0
  123. package/dist/liquid-grid.js.map +1 -0
  124. package/dist/liquid.d.ts +18 -0
  125. package/dist/liquid.d.ts.map +1 -0
  126. package/dist/liquid.js +234 -0
  127. package/dist/liquid.js.map +1 -0
  128. package/dist/loader.d.ts +19 -0
  129. package/dist/loader.d.ts.map +1 -0
  130. package/dist/loader.js +173 -0
  131. package/dist/loader.js.map +1 -0
  132. package/dist/motion-scope.d.ts +39 -0
  133. package/dist/motion-scope.d.ts.map +1 -0
  134. package/dist/motion-scope.js +238 -0
  135. package/dist/motion-scope.js.map +1 -0
  136. package/dist/motion-states.d.ts +36 -0
  137. package/dist/motion-states.d.ts.map +1 -0
  138. package/dist/motion-states.js +165 -0
  139. package/dist/motion-states.js.map +1 -0
  140. package/dist/particles.d.ts +39 -0
  141. package/dist/particles.d.ts.map +1 -0
  142. package/dist/particles.js +265 -0
  143. package/dist/particles.js.map +1 -0
  144. package/dist/path.d.ts +12 -0
  145. package/dist/path.d.ts.map +1 -0
  146. package/dist/path.js +90 -0
  147. package/dist/path.js.map +1 -0
  148. package/dist/playback-group.d.ts +6 -0
  149. package/dist/playback-group.d.ts.map +1 -0
  150. package/dist/playback-group.js +141 -0
  151. package/dist/playback-group.js.map +1 -0
  152. package/dist/playback.d.ts +25 -0
  153. package/dist/playback.d.ts.map +1 -0
  154. package/dist/playback.js +35 -0
  155. package/dist/playback.js.map +1 -0
  156. package/dist/press.d.ts.map +1 -1
  157. package/dist/press.js +23 -7
  158. package/dist/press.js.map +1 -1
  159. package/dist/properties.d.ts +6 -3
  160. package/dist/properties.d.ts.map +1 -1
  161. package/dist/properties.js +26 -2
  162. package/dist/properties.js.map +1 -1
  163. package/dist/reveal.d.ts +25 -0
  164. package/dist/reveal.d.ts.map +1 -0
  165. package/dist/reveal.js +294 -0
  166. package/dist/reveal.js.map +1 -0
  167. package/dist/roll-diff.d.ts +22 -0
  168. package/dist/roll-diff.d.ts.map +1 -0
  169. package/dist/roll-diff.js +36 -0
  170. package/dist/roll-diff.js.map +1 -0
  171. package/dist/roll.d.ts +29 -0
  172. package/dist/roll.d.ts.map +1 -0
  173. package/dist/roll.js +442 -0
  174. package/dist/roll.js.map +1 -0
  175. package/dist/scroll-linked.d.ts +10 -0
  176. package/dist/scroll-linked.d.ts.map +1 -0
  177. package/dist/scroll-linked.js +58 -0
  178. package/dist/scroll-linked.js.map +1 -0
  179. package/dist/sequence.d.ts +10 -5
  180. package/dist/sequence.d.ts.map +1 -1
  181. package/dist/sequence.js +208 -14
  182. package/dist/sequence.js.map +1 -1
  183. package/dist/shimmer.d.ts +12 -0
  184. package/dist/shimmer.d.ts.map +1 -0
  185. package/dist/shimmer.js +161 -0
  186. package/dist/shimmer.js.map +1 -0
  187. package/dist/svg-path.d.ts +14 -0
  188. package/dist/svg-path.d.ts.map +1 -0
  189. package/dist/svg-path.js +281 -0
  190. package/dist/svg-path.js.map +1 -0
  191. package/dist/symbol-effect.d.ts +23 -0
  192. package/dist/symbol-effect.d.ts.map +1 -0
  193. package/dist/symbol-effect.js +187 -0
  194. package/dist/symbol-effect.js.map +1 -0
  195. package/dist/text-layout.d.ts +30 -0
  196. package/dist/text-layout.d.ts.map +1 -0
  197. package/dist/text-layout.js +185 -0
  198. package/dist/text-layout.js.map +1 -0
  199. package/dist/text.d.ts +17 -0
  200. package/dist/text.d.ts.map +1 -0
  201. package/dist/text.js +263 -0
  202. package/dist/text.js.map +1 -0
  203. package/dist/track.d.ts +3 -1
  204. package/dist/track.d.ts.map +1 -1
  205. package/dist/track.js +266 -13
  206. package/dist/track.js.map +1 -1
  207. package/dist/transition.d.ts +17 -1
  208. package/dist/transition.d.ts.map +1 -1
  209. package/dist/transition.js +41 -6
  210. package/dist/transition.js.map +1 -1
  211. package/dist/types.d.ts +48 -1
  212. package/dist/types.d.ts.map +1 -1
  213. package/dist/vue/index.d.ts +95 -0
  214. package/dist/vue/index.d.ts.map +1 -0
  215. package/dist/vue/index.js +163 -0
  216. package/dist/vue/index.js.map +1 -0
  217. package/dist/zoom-native.d.ts +30 -0
  218. package/dist/zoom-native.d.ts.map +1 -0
  219. package/dist/zoom-native.js +56 -0
  220. package/dist/zoom-native.js.map +1 -0
  221. package/dist/zoomable.d.ts +48 -0
  222. package/dist/zoomable.d.ts.map +1 -0
  223. package/dist/zoomable.js +249 -0
  224. package/dist/zoomable.js.map +1 -0
  225. package/package.json +29 -5
  226. package/platforms/android/include.gradle +7 -0
  227. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  228. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  229. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  230. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  231. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
  232. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  233. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  234. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  235. package/platforms/android/proguard-rules.pro +19 -0
  236. package/platforms/ios/build.xcconfig +1 -0
  237. package/platforms/ios/src/JoltImageEffect.h +25 -0
  238. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  239. package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
  240. package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
  241. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  242. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  243. package/platforms/ios/src/module.modulemap +6 -0
  244. package/src/android-plugin.d.ts +155 -0
  245. package/src/animate.ts +249 -36
  246. package/src/color.ts +101 -0
  247. package/src/config.ts +84 -0
  248. package/src/decay.ts +45 -0
  249. package/src/decoration.ts +180 -0
  250. package/src/drag-input.ts +74 -0
  251. package/src/drag-physics.ts +110 -0
  252. package/src/drag.ts +395 -0
  253. package/src/driver/android-color.ts +170 -0
  254. package/src/driver/android-decoration.ts +182 -0
  255. package/src/driver/android-playback.ts +157 -0
  256. package/src/driver/android.ts +208 -20
  257. package/src/driver/index.ts +37 -2
  258. package/src/driver/ios-color.ts +441 -0
  259. package/src/driver/ios-decoration.ts +332 -0
  260. package/src/driver/ios-layout.ts +63 -0
  261. package/src/driver/ios-playback.ts +51 -0
  262. package/src/driver/ios-shadow.ts +50 -0
  263. package/src/driver/ios.ts +193 -33
  264. package/src/engine.ts +321 -27
  265. package/src/haptics.ts +148 -0
  266. package/src/image-effect-native.ts +129 -0
  267. package/src/image-effect.ts +336 -0
  268. package/src/image-owner.ts +12 -0
  269. package/src/index.ts +59 -2
  270. package/src/interaction-surface.ts +41 -0
  271. package/src/ios-plugin.d.ts +81 -0
  272. package/src/items.ts +140 -0
  273. package/src/keyboard.ts +291 -0
  274. package/src/label.ts +76 -0
  275. package/src/layout.ts +408 -0
  276. package/src/liquid-grid.ts +28 -0
  277. package/src/liquid.ts +287 -0
  278. package/src/loader.ts +202 -0
  279. package/src/motion-scope.ts +279 -0
  280. package/src/motion-states.ts +215 -0
  281. package/src/particles.ts +323 -0
  282. package/src/path.ts +124 -0
  283. package/src/playback-group.ts +143 -0
  284. package/src/playback.ts +62 -0
  285. package/src/press.ts +26 -10
  286. package/src/properties.ts +32 -6
  287. package/src/reveal.ts +374 -0
  288. package/src/roll-diff.ts +57 -0
  289. package/src/roll.ts +515 -0
  290. package/src/scroll-linked.ts +73 -0
  291. package/src/sequence.ts +204 -14
  292. package/src/shimmer.ts +180 -0
  293. package/src/svg-path.ts +355 -0
  294. package/src/symbol-effect.ts +226 -0
  295. package/src/text-layout.ts +228 -0
  296. package/src/text.ts +279 -0
  297. package/src/track.ts +347 -12
  298. package/src/transition.ts +63 -6
  299. package/src/types.ts +49 -1
  300. package/src/vue/index.ts +231 -0
  301. package/src/zoom-native.ts +124 -0
  302. package/src/zoomable.ts +330 -0
package/src/engine.ts CHANGED
@@ -1,17 +1,27 @@
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'
10
+ import { playHaptics, type HapticOptions } from './haptics.js'
3
11
  import { readValue, writeValue, type Property } from './properties.js'
4
12
  import { createTrack, type Track } from './track.js'
5
13
  import type { ResolvedTransition } from './transition.js'
14
+ import { isColorProperty } from './color.js'
15
+ import type { PreparedPath, PathProperty } from './path.js'
6
16
 
7
17
  export interface Animation extends PromiseLike<boolean> {
8
18
  /**
9
19
  * Resolves with `true` once the animation played to the end, or with `false`
10
- * if it was stopped or another animation took over one of its properties.
11
- * Never rejects.
20
+ * if it was stopped, superseded or skipped.
21
+ * Playback cancellation never rejects; sequences can reject setup/callback errors.
12
22
  */
13
23
  readonly finished: Promise<boolean>
14
- /** Stops where it is. The view keeps the values it had at that moment. */
24
+ /** Stops playback. Each effect documents whether it preserves or restores its visual state. */
15
25
  stop(): void
16
26
  }
17
27
 
@@ -21,6 +31,7 @@ export interface Plan {
21
31
  keyframes: readonly (number | null)[]
22
32
  transition: ResolvedTransition
23
33
  relative?: boolean
34
+ path?: PreparedPath
24
35
  }
25
36
 
26
37
  interface Entry {
@@ -29,11 +40,32 @@ interface Entry {
29
40
  readonly owner: ViewAnimation
30
41
  handle: unknown
31
42
  done: boolean
43
+ finished: boolean
44
+ generation: number
45
+ token: symbol
46
+ completedValue?: number
32
47
  }
33
48
 
49
+ const revisions = new WeakMap<View, Map<Property, symbol>>()
50
+
34
51
  const running = new WeakMap<View, Map<Property, Entry>>()
35
52
  const arrivals = new WeakMap<Animation, Promise<unknown>>()
36
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
+
37
69
  /**
38
70
  * Resolves once the animation looks done, which for a spring is well before
39
71
  * `finished`. Sequences move on to the next step then.
@@ -46,7 +78,7 @@ export function setArrival(animation: Animation, arrival: Promise<unknown>) {
46
78
  arrivals.set(animation, arrival)
47
79
  }
48
80
 
49
- export class ViewAnimation implements Animation {
81
+ export class ViewAnimation implements PlaybackAnimation {
50
82
  readonly finished: Promise<boolean>
51
83
  private resolve!: (finished: boolean) => void
52
84
  private readonly entries: Entry[] = []
@@ -56,13 +88,203 @@ export class ViewAnimation implements Animation {
56
88
  private cancelWait: (() => void) | undefined
57
89
  private arrive!: () => void
58
90
  private cancelArrival: (() => void) | undefined
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()
59
262
 
60
263
  constructor(
61
264
  readonly view: View,
62
265
  plans: readonly Plan[],
266
+ private readonly haptic?: HapticOptions,
267
+ private held = false,
63
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
+ )
64
275
  this.finished = new Promise((resolve) => (this.resolve = resolve))
65
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
+ })
66
288
  if (view.isLoaded && view.nativeViewProtected) this.start(plans)
67
289
  else this.waitForLoad(plans)
68
290
  }
@@ -77,6 +299,7 @@ export class ViewAnimation implements Animation {
77
299
  stop() {
78
300
  if (this.settled) return
79
301
  this.cancelWait?.()
302
+ this.haptics?.cancel()
80
303
  const now = getDriver().now()
81
304
  for (const entry of this.entries) {
82
305
  if (!entry.done) release(entry, now, true)
@@ -86,32 +309,45 @@ export class ViewAnimation implements Animation {
86
309
  }
87
310
 
88
311
  ended(finished: boolean) {
89
- if (!finished) this.complete = false
90
- if (--this.pending === 0) this.settle()
312
+ if (!finished) {
313
+ this.complete = false
314
+ this.haptics?.cancel()
315
+ }
316
+ if (--this.pending === 0) this.becameIdle()
91
317
  }
92
318
 
93
319
  private start(plans: readonly Plan[]) {
320
+ this.started = true
94
321
  const driver = getDriver()
95
322
  const now = driver.now()
96
323
  const entries = entriesOf(this.view)
97
324
 
98
- const tracks = plans.map(({ property, keyframes, transition, relative }) => {
325
+ const tracks = plans.map(({ property, keyframes, transition, relative, path }) => {
99
326
  const previous = entries.get(property)
100
327
  let current: number
101
328
  let velocity = 0
102
329
  if (previous) {
103
- const elapsed = now - previous.startedAt
330
+ const elapsed = elapsedOf(previous, now)
104
331
  current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
105
- velocity = previous.track.velocityAt(elapsed)
332
+ velocity = previous.owner.paused ? 0 : previous.track.velocityAt(elapsed)
106
333
  release(previous, now, false)
107
334
  } else {
108
- current = readValue(this.view, property)
335
+ current =
336
+ (isColorProperty(property)
337
+ ? driver.presented(this.view, property)
338
+ : undefined) ?? readValue(this.view, property)
109
339
  }
340
+ if (path)
341
+ return path.track(
342
+ property as PathProperty,
343
+ relative ? current : 0,
344
+ transition.delay,
345
+ )
110
346
  return createTrack(
111
347
  property,
112
348
  keyframes.map((value) => {
113
349
  if (value === null) return current
114
- if (!relative) return value
350
+ if (!relative || isColorProperty(property)) return value
115
351
  return property === 'opacity' || property.startsWith('scale')
116
352
  ? current * value
117
353
  : current + value
@@ -122,8 +358,13 @@ export class ViewAnimation implements Animation {
122
358
  })
123
359
 
124
360
  for (const track of tracks) writeValue(this.view, track.property, track.to)
361
+ driver.sync?.(this.view)
125
362
 
126
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)
127
368
  if (track.end === 0) continue
128
369
  const entry: Entry = {
129
370
  track,
@@ -131,31 +372,41 @@ export class ViewAnimation implements Animation {
131
372
  owner: this,
132
373
  handle: undefined,
133
374
  done: false,
375
+ finished: false,
376
+ generation: 0,
377
+ token,
134
378
  }
135
379
  entries.set(track.property, entry)
136
380
  this.entries.push(entry)
137
381
  this.pending++
138
382
  }
139
383
 
140
- for (const entry of this.entries) {
141
- entry.handle = driver.play(this.view, entry.track, now, () => {
142
- if (entry.done) return
143
- entry.done = true
144
- forget(entry)
145
- this.ended(true)
146
- })
147
- }
148
-
149
- const arrival = Math.max(...this.entries.map((entry) => entry.track.arrival))
150
- if (this.pending === 0) this.settle()
151
- else if (Number.isFinite(arrival)) {
152
- this.cancelArrival = driver.after(arrival, this.arrive)
153
- }
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()
154
405
  }
155
406
 
156
407
  private waitForLoad(plans: readonly Plan[]) {
157
408
  for (const { property, keyframes, relative } of plans) {
158
- if (relative) continue
409
+ if (relative && !isColorProperty(property)) continue
159
410
  const first = keyframes[0]
160
411
  if (first !== null && first !== undefined) writeValue(this.view, property, first)
161
412
  }
@@ -174,13 +425,51 @@ export class ViewAnimation implements Animation {
174
425
  if (this.settled) return
175
426
  this.settled = true
176
427
  this.cancelArrival?.()
428
+ this.clock?.cancel()
429
+ this.view.off('unloaded', this.unloaded)
430
+ if (this.complete) this.haptics?.finish()
431
+ else this.haptics?.cancel()
432
+ this.haptics = undefined
177
433
  this.arrive()
178
434
  this.resolve(this.complete)
435
+ this.timingChanged()
436
+ }
437
+ }
438
+
439
+ /** Current native presentation when available, otherwise the active curve or model. */
440
+ export function currentValue(view: View, property: Property): number {
441
+ const entry = running.get(view)?.get(property)
442
+ if (!entry) return readValue(view, property)
443
+ const driver = getDriver()
444
+ return (
445
+ driver.presented(view, property) ??
446
+ entry.track.valueAt(elapsedOf(entry, driver.now()))
447
+ )
448
+ }
449
+
450
+ /** Internal property ownership marker; layout cleanup must preserve newer animations. */
451
+ export function propertyRevision(view: View, property: Property) {
452
+ return revisions.get(view)?.get(property)
453
+ }
454
+
455
+ /** Sample an existing owner at layout time, without stealing an animation started meanwhile. */
456
+ export function captureValue(view: View, property: Property): () => number | undefined {
457
+ const token = propertyRevision(view, property)
458
+ const entry = running.get(view)?.get(property)
459
+ const initial = currentValue(view, property)
460
+ return () => {
461
+ if (propertyRevision(view, property) !== token) return undefined
462
+ if (entry && running.get(view)?.get(property) === entry)
463
+ return currentValue(view, property)
464
+ return entry?.finished ? entry.track.to : initial
179
465
  }
180
466
  }
181
467
 
182
468
  /** Stops whatever is animating these properties of the view, or all of them. */
183
469
  export function stopView(view: View, properties?: readonly Property[]) {
470
+ const tokens = revisions.get(view)
471
+ for (const property of properties ?? tokens?.keys() ?? [])
472
+ tokens?.set(property, Symbol(property))
184
473
  const entries = running.get(view)
185
474
  if (!entries) return
186
475
  const now = getDriver().now()
@@ -208,9 +497,14 @@ function release(entry: Entry, now: number, commit: boolean) {
208
497
  forget(entry)
209
498
  if (commit) {
210
499
  const value =
211
- driver.presented(view, property) ?? entry.track.valueAt(now - entry.startedAt)
500
+ driver.presented(view, property) ?? entry.track.valueAt(elapsedOf(entry, now))
212
501
  writeValue(view, property, value)
502
+ driver.sync?.(view, property)
213
503
  }
214
504
  driver.stop(view, entry.handle)
215
505
  entry.owner.ended(false)
216
506
  }
507
+
508
+ function elapsedOf(entry: Entry, now: number) {
509
+ return entry.owner.elapsed(entry, now)
510
+ }
package/src/haptics.ts ADDED
@@ -0,0 +1,148 @@
1
+ import { isIOS, type View } from '@nativescript/core'
2
+ import type { Driver } from './driver/index.js'
3
+ import type { Track } from './track.js'
4
+
5
+ export type HapticStyle = 'light' | 'medium' | 'heavy'
6
+
7
+ export interface HapticOptions {
8
+ style: HapticStyle
9
+ /** Points from 0 to 1 over the finite animation, excluding its initial delay. Defaults to arrival. */
10
+ times?: readonly number[]
11
+ }
12
+
13
+ export type Haptic = HapticStyle | HapticOptions | false
14
+
15
+ /** Validate before any running animation is interrupted. */
16
+ export function resolveHaptic(haptic: Haptic | undefined): HapticOptions | undefined {
17
+ if (haptic === undefined || haptic === false) return undefined
18
+ const options = typeof haptic === 'string' ? { style: haptic } : haptic
19
+ if (!options || !['light', 'medium', 'heavy'].includes(options.style))
20
+ throw new TypeError('Haptic style must be light, medium or heavy.')
21
+ const times = options.times
22
+ if (
23
+ times !== undefined &&
24
+ (!Array.isArray(times) ||
25
+ times.length === 0 ||
26
+ Array.from(times).some(
27
+ (time, i) =>
28
+ !Number.isFinite(time) ||
29
+ time <= 0 ||
30
+ time > 1 ||
31
+ (i > 0 && time <= times[i - 1]!),
32
+ ))
33
+ )
34
+ throw new RangeError(
35
+ 'Haptic times must be increasing points greater than 0 and at most 1.',
36
+ )
37
+ return { style: options.style, times: times && [...times] }
38
+ }
39
+
40
+ const PREPARE_AHEAD = 100
41
+ const MAX_LATENESS = 100
42
+ const COMPLETION_TOLERANCE = 20
43
+
44
+ /** Discrete feedback only: animation playback remains entirely native. */
45
+ export function playHaptics(
46
+ view: View,
47
+ tracks: readonly Track[],
48
+ options: HapticOptions | undefined,
49
+ driver: Driver,
50
+ startedAt: number,
51
+ elapsedTime = () => driver.now() - startedAt,
52
+ ) {
53
+ if (!isIOS || !options || typeof UIImpactFeedbackGenerator === 'undefined') return
54
+ const moving = tracks.filter(
55
+ (track) =>
56
+ track.duration > 0 && track.values.some((value) => value !== track.values[0]),
57
+ )
58
+ if (!moving.length) return
59
+ const end = Math.max(...tracks.map((track) => track.end))
60
+ if (!Number.isFinite(end)) return
61
+ const delay = Math.min(...moving.map((track) => track.delay))
62
+ const offsets = options.times
63
+ ? options.times.map((time) => delay + (end - delay) * time)
64
+ : [Math.max(...tracks.map((track) => track.arrival))]
65
+ const styles = {
66
+ light: 0,
67
+ medium: 1,
68
+ heavy: 2,
69
+ } satisfies Record<HapticStyle, UIImpactFeedbackStyle>
70
+ let generator: UIImpactFeedbackGenerator | undefined =
71
+ UIImpactFeedbackGenerator.alloc().initWithStyle(styles[options.style])
72
+ const native = view.nativeViewProtected as UIView
73
+ let paused = false
74
+ let next = 0
75
+ let cancelPrepare: (() => void) | undefined
76
+ let cancelImpact: (() => void) | undefined
77
+
78
+ const active = () =>
79
+ view.isLoaded &&
80
+ view.nativeViewProtected === native &&
81
+ !!native.window &&
82
+ UIApplication.sharedApplication.applicationState === 0 // UIApplicationState.Active
83
+
84
+ const cancelTimers = () => {
85
+ cancelPrepare?.()
86
+ cancelImpact?.()
87
+ cancelPrepare = cancelImpact = undefined
88
+ }
89
+ const cancel = () => {
90
+ cancelPrepare?.()
91
+ cancelImpact?.()
92
+ cancelPrepare = cancelImpact = undefined
93
+ generator = undefined
94
+ view.off('unloaded', cancel)
95
+ }
96
+
97
+ const fire = (tolerance = 0) => {
98
+ const elapsed = elapsedTime()
99
+ // A busy UI thread can delay timers. Drop stale impacts instead of replaying
100
+ // several old taps at once when it becomes responsive again.
101
+ while (next < offsets.length && offsets[next]! <= elapsed + tolerance) {
102
+ const offset = offsets[next++]!
103
+ if (active() && elapsed - offset <= MAX_LATENESS) generator?.impactOccurred()
104
+ }
105
+ }
106
+
107
+ const schedule = () => {
108
+ if (paused) return
109
+ if (!generator || next === offsets.length) return cancel()
110
+ const remaining = offsets[next]! - elapsedTime()
111
+ if (active()) generator.prepare()
112
+ if (remaining > PREPARE_AHEAD) {
113
+ cancelPrepare = driver.after(remaining - PREPARE_AHEAD, () => {
114
+ if (active()) generator?.prepare()
115
+ })
116
+ }
117
+ cancelImpact = driver.after(Math.max(0, remaining), () => {
118
+ fire()
119
+ schedule()
120
+ })
121
+ }
122
+
123
+ view.on('unloaded', cancel)
124
+ schedule()
125
+ return {
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
+ },
142
+ finish() {
143
+ // Native completion may beat the timer at the final keyframe by a frame.
144
+ fire(COMPLETION_TOLERANCE)
145
+ cancel()
146
+ },
147
+ }
148
+ }