@rafaelaugusto/jolt 0.1.0-beta.0 → 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 (307) hide show
  1. package/README.md +441 -25
  2. package/dist/animate.d.ts +21 -5
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +205 -25
  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 +164 -20
  75. package/dist/driver/ios.js.map +1 -1
  76. package/dist/engine.d.ts +76 -5
  77. package/dist/engine.d.ts.map +1 -1
  78. package/dist/engine.js +303 -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 +45 -4
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +21 -2
  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/presets.d.ts +16 -1
  157. package/dist/presets.d.ts.map +1 -1
  158. package/dist/presets.js +24 -6
  159. package/dist/presets.js.map +1 -1
  160. package/dist/press.d.ts.map +1 -1
  161. package/dist/press.js +25 -7
  162. package/dist/press.js.map +1 -1
  163. package/dist/properties.d.ts +6 -3
  164. package/dist/properties.d.ts.map +1 -1
  165. package/dist/properties.js +26 -2
  166. package/dist/properties.js.map +1 -1
  167. package/dist/reveal.d.ts +25 -0
  168. package/dist/reveal.d.ts.map +1 -0
  169. package/dist/reveal.js +294 -0
  170. package/dist/reveal.js.map +1 -0
  171. package/dist/roll-diff.d.ts +22 -0
  172. package/dist/roll-diff.d.ts.map +1 -0
  173. package/dist/roll-diff.js +36 -0
  174. package/dist/roll-diff.js.map +1 -0
  175. package/dist/roll.d.ts +29 -0
  176. package/dist/roll.d.ts.map +1 -0
  177. package/dist/roll.js +442 -0
  178. package/dist/roll.js.map +1 -0
  179. package/dist/scroll-linked.d.ts +10 -0
  180. package/dist/scroll-linked.d.ts.map +1 -0
  181. package/dist/scroll-linked.js +58 -0
  182. package/dist/scroll-linked.js.map +1 -0
  183. package/dist/sequence.d.ts +13 -7
  184. package/dist/sequence.d.ts.map +1 -1
  185. package/dist/sequence.js +214 -19
  186. package/dist/sequence.js.map +1 -1
  187. package/dist/shimmer.d.ts +12 -0
  188. package/dist/shimmer.d.ts.map +1 -0
  189. package/dist/shimmer.js +161 -0
  190. package/dist/shimmer.js.map +1 -0
  191. package/dist/svg-path.d.ts +14 -0
  192. package/dist/svg-path.d.ts.map +1 -0
  193. package/dist/svg-path.js +281 -0
  194. package/dist/svg-path.js.map +1 -0
  195. package/dist/symbol-effect.d.ts +23 -0
  196. package/dist/symbol-effect.d.ts.map +1 -0
  197. package/dist/symbol-effect.js +187 -0
  198. package/dist/symbol-effect.js.map +1 -0
  199. package/dist/text-layout.d.ts +30 -0
  200. package/dist/text-layout.d.ts.map +1 -0
  201. package/dist/text-layout.js +185 -0
  202. package/dist/text-layout.js.map +1 -0
  203. package/dist/text.d.ts +17 -0
  204. package/dist/text.d.ts.map +1 -0
  205. package/dist/text.js +263 -0
  206. package/dist/text.js.map +1 -0
  207. package/dist/track.d.ts +3 -1
  208. package/dist/track.d.ts.map +1 -1
  209. package/dist/track.js +266 -13
  210. package/dist/track.js.map +1 -1
  211. package/dist/transition.d.ts +17 -1
  212. package/dist/transition.d.ts.map +1 -1
  213. package/dist/transition.js +41 -6
  214. package/dist/transition.js.map +1 -1
  215. package/dist/types.d.ts +50 -1
  216. package/dist/types.d.ts.map +1 -1
  217. package/dist/vue/index.d.ts +95 -0
  218. package/dist/vue/index.d.ts.map +1 -0
  219. package/dist/vue/index.js +163 -0
  220. package/dist/vue/index.js.map +1 -0
  221. package/dist/zoom-native.d.ts +30 -0
  222. package/dist/zoom-native.d.ts.map +1 -0
  223. package/dist/zoom-native.js +56 -0
  224. package/dist/zoom-native.js.map +1 -0
  225. package/dist/zoomable.d.ts +48 -0
  226. package/dist/zoomable.d.ts.map +1 -0
  227. package/dist/zoomable.js +249 -0
  228. package/dist/zoomable.js.map +1 -0
  229. package/package.json +33 -5
  230. package/platforms/android/include.gradle +7 -0
  231. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  232. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  233. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  234. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  235. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
  236. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  237. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  238. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  239. package/platforms/android/proguard-rules.pro +19 -0
  240. package/platforms/ios/build.xcconfig +1 -0
  241. package/platforms/ios/src/JoltImageEffect.h +25 -0
  242. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  243. package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
  244. package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
  245. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  246. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  247. package/platforms/ios/src/module.modulemap +6 -0
  248. package/src/android-plugin.d.ts +155 -0
  249. package/src/animate.ts +278 -38
  250. package/src/color.ts +101 -0
  251. package/src/config.ts +84 -0
  252. package/src/decay.ts +45 -0
  253. package/src/decoration.ts +180 -0
  254. package/src/drag-input.ts +74 -0
  255. package/src/drag-physics.ts +110 -0
  256. package/src/drag.ts +395 -0
  257. package/src/driver/android-color.ts +170 -0
  258. package/src/driver/android-decoration.ts +182 -0
  259. package/src/driver/android-playback.ts +157 -0
  260. package/src/driver/android.ts +208 -20
  261. package/src/driver/index.ts +37 -2
  262. package/src/driver/ios-color.ts +441 -0
  263. package/src/driver/ios-decoration.ts +332 -0
  264. package/src/driver/ios-layout.ts +63 -0
  265. package/src/driver/ios-playback.ts +51 -0
  266. package/src/driver/ios-shadow.ts +50 -0
  267. package/src/driver/ios.ts +194 -25
  268. package/src/engine.ts +329 -27
  269. package/src/haptics.ts +148 -0
  270. package/src/image-effect-native.ts +129 -0
  271. package/src/image-effect.ts +336 -0
  272. package/src/image-owner.ts +12 -0
  273. package/src/index.ts +61 -4
  274. package/src/interaction-surface.ts +41 -0
  275. package/src/ios-plugin.d.ts +81 -0
  276. package/src/items.ts +140 -0
  277. package/src/keyboard.ts +291 -0
  278. package/src/label.ts +76 -0
  279. package/src/layout.ts +408 -0
  280. package/src/liquid-grid.ts +28 -0
  281. package/src/liquid.ts +287 -0
  282. package/src/loader.ts +202 -0
  283. package/src/motion-scope.ts +279 -0
  284. package/src/motion-states.ts +215 -0
  285. package/src/particles.ts +323 -0
  286. package/src/path.ts +124 -0
  287. package/src/playback-group.ts +143 -0
  288. package/src/playback.ts +62 -0
  289. package/src/presets.ts +31 -7
  290. package/src/press.ts +32 -10
  291. package/src/properties.ts +32 -6
  292. package/src/reveal.ts +374 -0
  293. package/src/roll-diff.ts +57 -0
  294. package/src/roll.ts +515 -0
  295. package/src/scroll-linked.ts +73 -0
  296. package/src/sequence.ts +212 -19
  297. package/src/shimmer.ts +180 -0
  298. package/src/svg-path.ts +355 -0
  299. package/src/symbol-effect.ts +226 -0
  300. package/src/text-layout.ts +228 -0
  301. package/src/text.ts +279 -0
  302. package/src/track.ts +347 -12
  303. package/src/transition.ts +63 -6
  304. package/src/types.ts +51 -1
  305. package/src/vue/index.ts +231 -0
  306. package/src/zoom-native.ts +124 -0
  307. 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
 
@@ -20,6 +30,8 @@ export interface Plan {
20
30
  /** `null` stands for the value the property has when the animation starts. */
21
31
  keyframes: readonly (number | null)[]
22
32
  transition: ResolvedTransition
33
+ relative?: boolean
34
+ path?: PreparedPath
23
35
  }
24
36
 
25
37
  interface Entry {
@@ -28,11 +40,32 @@ interface Entry {
28
40
  readonly owner: ViewAnimation
29
41
  handle: unknown
30
42
  done: boolean
43
+ finished: boolean
44
+ generation: number
45
+ token: symbol
46
+ completedValue?: number
31
47
  }
32
48
 
49
+ const revisions = new WeakMap<View, Map<Property, symbol>>()
50
+
33
51
  const running = new WeakMap<View, Map<Property, Entry>>()
34
52
  const arrivals = new WeakMap<Animation, Promise<unknown>>()
35
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
+
36
69
  /**
37
70
  * Resolves once the animation looks done, which for a spring is well before
38
71
  * `finished`. Sequences move on to the next step then.
@@ -45,7 +78,7 @@ export function setArrival(animation: Animation, arrival: Promise<unknown>) {
45
78
  arrivals.set(animation, arrival)
46
79
  }
47
80
 
48
- export class ViewAnimation implements Animation {
81
+ export class ViewAnimation implements PlaybackAnimation {
49
82
  readonly finished: Promise<boolean>
50
83
  private resolve!: (finished: boolean) => void
51
84
  private readonly entries: Entry[] = []
@@ -55,13 +88,203 @@ export class ViewAnimation implements Animation {
55
88
  private cancelWait: (() => void) | undefined
56
89
  private arrive!: () => void
57
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()
58
262
 
59
263
  constructor(
60
264
  readonly view: View,
61
265
  plans: readonly Plan[],
266
+ private readonly haptic?: HapticOptions,
267
+ private held = false,
62
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
+ )
63
275
  this.finished = new Promise((resolve) => (this.resolve = resolve))
64
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
+ })
65
288
  if (view.isLoaded && view.nativeViewProtected) this.start(plans)
66
289
  else this.waitForLoad(plans)
67
290
  }
@@ -76,6 +299,7 @@ export class ViewAnimation implements Animation {
76
299
  stop() {
77
300
  if (this.settled) return
78
301
  this.cancelWait?.()
302
+ this.haptics?.cancel()
79
303
  const now = getDriver().now()
80
304
  for (const entry of this.entries) {
81
305
  if (!entry.done) release(entry, now, true)
@@ -85,38 +309,62 @@ export class ViewAnimation implements Animation {
85
309
  }
86
310
 
87
311
  ended(finished: boolean) {
88
- if (!finished) this.complete = false
89
- 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()
90
317
  }
91
318
 
92
319
  private start(plans: readonly Plan[]) {
320
+ this.started = true
93
321
  const driver = getDriver()
94
322
  const now = driver.now()
95
323
  const entries = entriesOf(this.view)
96
324
 
97
- const tracks = plans.map(({ property, keyframes, transition }) => {
325
+ const tracks = plans.map(({ property, keyframes, transition, relative, path }) => {
98
326
  const previous = entries.get(property)
99
327
  let current: number
100
328
  let velocity = 0
101
329
  if (previous) {
102
- const elapsed = now - previous.startedAt
330
+ const elapsed = elapsedOf(previous, now)
103
331
  current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
104
- velocity = previous.track.velocityAt(elapsed)
332
+ velocity = previous.owner.paused ? 0 : previous.track.velocityAt(elapsed)
105
333
  release(previous, now, false)
106
334
  } else {
107
- current = readValue(this.view, property)
335
+ current =
336
+ (isColorProperty(property)
337
+ ? driver.presented(this.view, property)
338
+ : undefined) ?? readValue(this.view, property)
108
339
  }
340
+ if (path)
341
+ return path.track(
342
+ property as PathProperty,
343
+ relative ? current : 0,
344
+ transition.delay,
345
+ )
109
346
  return createTrack(
110
347
  property,
111
- keyframes.map((value) => value ?? current),
348
+ keyframes.map((value) => {
349
+ if (value === null) return current
350
+ if (!relative || isColorProperty(property)) return value
351
+ return property === 'opacity' || property.startsWith('scale')
352
+ ? current * value
353
+ : current + value
354
+ }),
112
355
  transition,
113
356
  velocity,
114
357
  )
115
358
  })
116
359
 
117
360
  for (const track of tracks) writeValue(this.view, track.property, track.to)
361
+ driver.sync?.(this.view)
118
362
 
119
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)
120
368
  if (track.end === 0) continue
121
369
  const entry: Entry = {
122
370
  track,
@@ -124,30 +372,41 @@ export class ViewAnimation implements Animation {
124
372
  owner: this,
125
373
  handle: undefined,
126
374
  done: false,
375
+ finished: false,
376
+ generation: 0,
377
+ token,
127
378
  }
128
379
  entries.set(track.property, entry)
129
380
  this.entries.push(entry)
130
381
  this.pending++
131
382
  }
132
383
 
133
- for (const entry of this.entries) {
134
- entry.handle = driver.play(this.view, entry.track, now, () => {
135
- if (entry.done) return
136
- entry.done = true
137
- forget(entry)
138
- this.ended(true)
139
- })
140
- }
141
-
142
- const arrival = Math.max(...this.entries.map((entry) => entry.track.arrival))
143
- if (this.pending === 0) this.settle()
144
- else if (Number.isFinite(arrival)) {
145
- this.cancelArrival = driver.after(arrival, this.arrive)
146
- }
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()
147
405
  }
148
406
 
149
407
  private waitForLoad(plans: readonly Plan[]) {
150
- for (const { property, keyframes } of plans) {
408
+ for (const { property, keyframes, relative } of plans) {
409
+ if (relative && !isColorProperty(property)) continue
151
410
  const first = keyframes[0]
152
411
  if (first !== null && first !== undefined) writeValue(this.view, property, first)
153
412
  }
@@ -166,13 +425,51 @@ export class ViewAnimation implements Animation {
166
425
  if (this.settled) return
167
426
  this.settled = true
168
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
169
433
  this.arrive()
170
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
171
465
  }
172
466
  }
173
467
 
174
468
  /** Stops whatever is animating these properties of the view, or all of them. */
175
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))
176
473
  const entries = running.get(view)
177
474
  if (!entries) return
178
475
  const now = getDriver().now()
@@ -200,9 +497,14 @@ function release(entry: Entry, now: number, commit: boolean) {
200
497
  forget(entry)
201
498
  if (commit) {
202
499
  const value =
203
- driver.presented(view, property) ?? entry.track.valueAt(now - entry.startedAt)
500
+ driver.presented(view, property) ?? entry.track.valueAt(elapsedOf(entry, now))
204
501
  writeValue(view, property, value)
502
+ driver.sync?.(view, property)
205
503
  }
206
504
  driver.stop(view, entry.handle)
207
505
  entry.owner.ended(false)
208
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
+ }