@rafaelaugusto/jolt 0.1.0-beta.6 → 0.1.0-beta.8

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 (110) hide show
  1. package/README.md +21 -4
  2. package/dist/animate.d.ts +2 -0
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +7 -2
  5. package/dist/animate.js.map +1 -1
  6. package/dist/drag-input.d.ts +15 -0
  7. package/dist/drag-input.d.ts.map +1 -0
  8. package/dist/drag-input.js +66 -0
  9. package/dist/drag-input.js.map +1 -0
  10. package/dist/drag-physics.d.ts +20 -0
  11. package/dist/drag-physics.d.ts.map +1 -0
  12. package/dist/drag-physics.js +75 -0
  13. package/dist/drag-physics.js.map +1 -0
  14. package/dist/drag.d.ts +58 -0
  15. package/dist/drag.d.ts.map +1 -0
  16. package/dist/drag.js +324 -0
  17. package/dist/drag.js.map +1 -0
  18. package/dist/driver/ios.d.ts.map +1 -1
  19. package/dist/driver/ios.js +38 -6
  20. package/dist/driver/ios.js.map +1 -1
  21. package/dist/engine.d.ts +2 -0
  22. package/dist/engine.d.ts.map +1 -1
  23. package/dist/engine.js +9 -0
  24. package/dist/engine.js.map +1 -1
  25. package/dist/image-effect-native.d.ts +26 -0
  26. package/dist/image-effect-native.d.ts.map +1 -0
  27. package/dist/image-effect-native.js +73 -0
  28. package/dist/image-effect-native.js.map +1 -0
  29. package/dist/image-effect.d.ts +50 -0
  30. package/dist/image-effect.d.ts.map +1 -0
  31. package/dist/image-effect.js +290 -0
  32. package/dist/image-effect.js.map +1 -0
  33. package/dist/image-owner.d.ts +3 -0
  34. package/dist/image-owner.d.ts.map +1 -0
  35. package/dist/image-owner.js +11 -0
  36. package/dist/image-owner.js.map +1 -0
  37. package/dist/index.d.ts +12 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +6 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/interaction-surface.d.ts +12 -0
  42. package/dist/interaction-surface.d.ts.map +1 -0
  43. package/dist/interaction-surface.js +36 -0
  44. package/dist/interaction-surface.js.map +1 -0
  45. package/dist/liquid.d.ts.map +1 -1
  46. package/dist/liquid.js +4 -0
  47. package/dist/liquid.js.map +1 -1
  48. package/dist/motion-scope.d.ts +37 -0
  49. package/dist/motion-scope.d.ts.map +1 -0
  50. package/dist/motion-scope.js +232 -0
  51. package/dist/motion-scope.js.map +1 -0
  52. package/dist/motion-states.d.ts +36 -0
  53. package/dist/motion-states.d.ts.map +1 -0
  54. package/dist/motion-states.js +165 -0
  55. package/dist/motion-states.js.map +1 -0
  56. package/dist/press.d.ts.map +1 -1
  57. package/dist/press.js +14 -3
  58. package/dist/press.js.map +1 -1
  59. package/dist/track.d.ts.map +1 -1
  60. package/dist/track.js +21 -4
  61. package/dist/track.js.map +1 -1
  62. package/dist/transition.d.ts +2 -1
  63. package/dist/transition.d.ts.map +1 -1
  64. package/dist/transition.js.map +1 -1
  65. package/dist/vue/index.d.ts +3 -1
  66. package/dist/vue/index.d.ts.map +1 -1
  67. package/dist/vue/index.js +17 -4
  68. package/dist/vue/index.js.map +1 -1
  69. package/dist/zoom-native.d.ts +30 -0
  70. package/dist/zoom-native.d.ts.map +1 -0
  71. package/dist/zoom-native.js +56 -0
  72. package/dist/zoom-native.js.map +1 -0
  73. package/dist/zoomable.d.ts +48 -0
  74. package/dist/zoomable.d.ts.map +1 -0
  75. package/dist/zoomable.js +249 -0
  76. package/dist/zoomable.js.map +1 -0
  77. package/package.json +2 -1
  78. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  79. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  80. package/platforms/android/proguard-rules.pro +4 -0
  81. package/platforms/ios/build.xcconfig +1 -0
  82. package/platforms/ios/src/JoltImageEffect.h +25 -0
  83. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  84. package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
  85. package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
  86. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  87. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  88. package/platforms/ios/src/module.modulemap +6 -0
  89. package/src/android-plugin.d.ts +78 -0
  90. package/src/animate.ts +15 -8
  91. package/src/drag-input.ts +74 -0
  92. package/src/drag-physics.ts +110 -0
  93. package/src/drag.ts +395 -0
  94. package/src/driver/ios.ts +57 -8
  95. package/src/engine.ts +11 -0
  96. package/src/image-effect-native.ts +129 -0
  97. package/src/image-effect.ts +336 -0
  98. package/src/image-owner.ts +12 -0
  99. package/src/index.ts +23 -0
  100. package/src/interaction-surface.ts +41 -0
  101. package/src/ios-plugin.d.ts +81 -0
  102. package/src/liquid.ts +4 -0
  103. package/src/motion-scope.ts +265 -0
  104. package/src/motion-states.ts +215 -0
  105. package/src/press.ts +15 -3
  106. package/src/track.ts +25 -4
  107. package/src/transition.ts +2 -0
  108. package/src/vue/index.ts +20 -3
  109. package/src/zoom-native.ts +124 -0
  110. package/src/zoomable.ts +330 -0
@@ -0,0 +1,110 @@
1
+ import { solveDecay } from './decay.js'
2
+ import { solveSpring, springPhysics } from './spring.js'
3
+
4
+ export interface DragCurve {
5
+ type: 'drag'
6
+ velocity: number
7
+ friction: number
8
+ bounds: readonly [number, number]
9
+ snap?: readonly number[]
10
+ rubberband: boolean
11
+ }
12
+
13
+ export const clampDrag = (value: number, [min, max]: readonly [number, number]) =>
14
+ Math.max(min, Math.min(max, value))
15
+
16
+ /** Bounded resistance; coordinates and the resistance length are DIPs. */
17
+ export function resistDrag(
18
+ value: number,
19
+ bounds: readonly [number, number],
20
+ length: number,
21
+ ) {
22
+ const edge = clampDrag(value, bounds)
23
+ const distance = value - edge
24
+ return edge + (distance / (1 + Math.abs(distance) / length)) * 0.55
25
+ }
26
+
27
+ /** Recover the finger-space origin when grabbing an already rubberbanded pose. */
28
+ export function resumeDrag(
29
+ value: number,
30
+ bounds: readonly [number, number],
31
+ length: number,
32
+ ) {
33
+ const edge = clampDrag(value, bounds)
34
+ const distance = value - edge
35
+ // A native edge spring or external owner can exceed the usual resistance range.
36
+ // Expand it for this gesture so every finite visible pose has a stable inverse.
37
+ length = Math.max(length, Math.abs(distance) * 4)
38
+ return { origin: edge + distance / (0.55 - Math.abs(distance) / length), length }
39
+ }
40
+
41
+ export function dragDestination(from: number, curve: DragCurve) {
42
+ const projected = solveDecay(from, curve.velocity, curve.friction, 0.25).to
43
+ if (curve.snap)
44
+ return curve.snap.reduce((best, point) =>
45
+ Math.abs(point - projected) < Math.abs(best - projected) ? point : best,
46
+ )
47
+ return clampDrag(projected, curve.bounds)
48
+ }
49
+
50
+ /** One precomputed motion. Native keyframes play both the coast and edge recovery. */
51
+ export function solveDragRelease(from: number, curve: DragCurve) {
52
+ const to = dragDestination(from, curve)
53
+ const coast = solveDecay(from, curve.velocity, curve.friction, 0.25)
54
+ const edge = clampDrag(from, curve.bounds)
55
+ const springTo = (start: number, target: number, velocity: number) => {
56
+ const motion = solveSpring(
57
+ springPhysics({ duration: 500, bounce: 0 }),
58
+ start,
59
+ target,
60
+ velocity,
61
+ 0.25,
62
+ )
63
+ const position = (time: number) => {
64
+ const value = time >= motion.duration ? target : motion.position(time)
65
+ return curve.rubberband ? value : clampDrag(value, curve.bounds)
66
+ }
67
+ return {
68
+ ...motion,
69
+ position,
70
+ velocity: (time: number) => {
71
+ const raw = motion.position(time)
72
+ return time >= motion.duration ||
73
+ (!curve.rubberband && raw !== clampDrag(raw, curve.bounds))
74
+ ? 0
75
+ : motion.velocity(time)
76
+ },
77
+ }
78
+ }
79
+ if (curve.snap || from !== edge)
80
+ return {
81
+ ...springTo(from, curve.snap ? to : edge, curve.velocity),
82
+ to: curve.snap ? to : edge,
83
+ }
84
+ if (to === coast.to) return coast
85
+
86
+ // Reach the boundary with the decay's actual velocity, then absorb that velocity.
87
+ const rate = -Math.log(curve.friction)
88
+ const collision = Math.max(
89
+ 0,
90
+ -Math.log1p((-(to - from) * rate * 1000) / curve.velocity) / rate,
91
+ )
92
+ const recovery = springTo(to, to, coast.velocity(collision))
93
+ const duration = collision + (curve.rubberband ? recovery.duration : 0)
94
+ return {
95
+ to,
96
+ duration,
97
+ position: (time: number) =>
98
+ time >= duration
99
+ ? to
100
+ : time < collision
101
+ ? coast.position(time)
102
+ : recovery.position(time - collision),
103
+ velocity: (time: number) =>
104
+ time >= duration
105
+ ? 0
106
+ : time < collision
107
+ ? coast.velocity(time)
108
+ : recovery.velocity(time - collision),
109
+ }
110
+ }
package/src/drag.ts ADDED
@@ -0,0 +1,395 @@
1
+ import {
2
+ Application,
3
+ type EventData,
4
+ type PanGestureEventData,
5
+ type TouchGestureEventData,
6
+ type View,
7
+ } from '@nativescript/core'
8
+ import { stop, toView } from './animate.js'
9
+ import { DEFAULT_FRICTION } from './decay.js'
10
+ import { DragInput } from './drag-input.js'
11
+ import {
12
+ clampDrag,
13
+ resistDrag,
14
+ resumeDrag,
15
+ solveDragRelease,
16
+ type DragCurve,
17
+ } from './drag-physics.js'
18
+ import { currentValue, ViewAnimation, type Plan } from './engine.js'
19
+ import type { PlaybackAnimation } from './playback.js'
20
+ import { readValue, writeValue } from './properties.js'
21
+ import type { Target } from './types.js'
22
+
23
+ export interface DragPoint {
24
+ readonly x: number
25
+ readonly y: number
26
+ }
27
+ export interface DragEvent extends DragPoint {
28
+ /** DIPs per second, in the same coordinates as the translation. */
29
+ readonly velocityX: number
30
+ readonly velocityY: number
31
+ }
32
+ export interface DragRelease extends DragEvent {
33
+ readonly target: DragPoint
34
+ /** False on interruption/cancellation. Check before applying completion effects. */
35
+ readonly animation: PlaybackAnimation
36
+ }
37
+ export interface DragOptions {
38
+ /** Defaults to both. One finger only. */
39
+ axis?: 'x' | 'y' | 'both'
40
+ /** Absolute translateX/translateY limits in DIPs; omitted edges are unbounded. */
41
+ bounds?: { left?: number; right?: number; top?: number; bottom?: number }
42
+ /** Closest stop to the projected landing. Arrays apply to every enabled axis. */
43
+ snap?: readonly number[] | { x?: readonly number[]; y?: readonly number[] }
44
+ /** Resist beyond bounds and spring back. Defaults to false (hard limits). */
45
+ rubberband?: boolean
46
+ /** Decay retention per millisecond. Defaults to 0.998. */
47
+ friction?: number
48
+ onStart?: (event: DragEvent) => void
49
+ onMove?: (event: DragEvent) => void
50
+ onRelease?: (event: DragRelease) => void
51
+ onCancel?: (event: DragEvent) => void
52
+ onSettle?: (position: DragPoint) => void
53
+ }
54
+ export interface Drag {
55
+ readonly dragging: boolean
56
+ readonly settling: boolean
57
+ readonly paused: boolean
58
+ readonly disposed: boolean
59
+ /** Current presentation, including a native release animation in flight. */
60
+ readonly position: DragPoint
61
+ /** Cancel contact/release at its visible position, keeping the subscription. */
62
+ cancel(): void
63
+ /** Cancel and detach input. play() accepts new gestures; cancelled work stays cancelled. */
64
+ pause(): void
65
+ play(): void
66
+ dispose(): void
67
+ }
68
+
69
+ type Axis = 'x' | 'y'
70
+ interface Settings {
71
+ axes: Axis[]
72
+ curves: Record<Axis, DragCurve>
73
+ callbacks: Pick<
74
+ DragOptions,
75
+ 'onStart' | 'onMove' | 'onRelease' | 'onCancel' | 'onSettle'
76
+ >
77
+ }
78
+ const owners = new WeakMap<View, Drag>()
79
+
80
+ function settings(options: DragOptions): Settings {
81
+ if (!options || typeof options !== 'object')
82
+ throw new TypeError('Drag options must be an object.')
83
+ const axis = options.axis ?? 'both'
84
+ if (!['x', 'y', 'both'].includes(axis))
85
+ throw new TypeError('Drag axis must be x, y or both.')
86
+ const friction = options.friction ?? DEFAULT_FRICTION
87
+ if (!Number.isFinite(friction) || friction <= 0 || friction >= 1)
88
+ throw new RangeError('Drag friction must be between 0 and 1 exclusively.')
89
+ if (options.rubberband !== undefined && typeof options.rubberband !== 'boolean')
90
+ throw new TypeError('Drag rubberband must be boolean.')
91
+ const bounds = options.bounds ?? {}
92
+ if (
93
+ typeof bounds !== 'object' ||
94
+ Array.isArray(bounds) ||
95
+ Object.entries(bounds).some(
96
+ ([key, value]) =>
97
+ !['left', 'right', 'top', 'bottom'].includes(key) ||
98
+ (value !== undefined && !Number.isFinite(value)),
99
+ )
100
+ )
101
+ throw new RangeError('Drag bounds must have finite left/right/top/bottom edges.')
102
+ const x: readonly [number, number] = [
103
+ bounds.left ?? -Infinity,
104
+ bounds.right ?? Infinity,
105
+ ]
106
+ const y: readonly [number, number] = [
107
+ bounds.top ?? -Infinity,
108
+ bounds.bottom ?? Infinity,
109
+ ]
110
+ if (x[0] > x[1] || y[0] > y[1]) throw new RangeError('Drag bounds must be ordered.')
111
+ const snap = options.snap
112
+ const shared = Array.isArray(snap)
113
+ if (
114
+ snap !== undefined &&
115
+ !shared &&
116
+ (!snap ||
117
+ typeof snap !== 'object' ||
118
+ Object.keys(snap).some((key) => key !== 'x' && key !== 'y'))
119
+ )
120
+ throw new TypeError('Drag snap must be an array or x/y arrays.')
121
+ const stops = (
122
+ value: readonly number[] | undefined,
123
+ range: readonly [number, number],
124
+ ) => {
125
+ if (value === undefined) return undefined
126
+ if (
127
+ !Array.isArray(value) ||
128
+ !value.length ||
129
+ ![...value].every((n) => Number.isFinite(n) && n >= range[0] && n <= range[1])
130
+ )
131
+ throw new RangeError('Drag snap needs nonempty finite positions within bounds.')
132
+ return [...value]
133
+ }
134
+ const perAxis = snap as { x?: readonly number[]; y?: readonly number[] } | undefined
135
+ const axes: Axis[] = axis === 'both' ? ['x', 'y'] : [axis]
136
+ const curves = Object.fromEntries(
137
+ (['x', 'y'] as const).map((key) => [
138
+ key,
139
+ {
140
+ type: 'drag',
141
+ velocity: 0,
142
+ friction,
143
+ bounds: key === 'x' ? x : y,
144
+ snap: stops(
145
+ shared && axes.includes(key) ? snap : perAxis?.[key],
146
+ key === 'x' ? x : y,
147
+ ),
148
+ rubberband: options.rubberband ?? false,
149
+ },
150
+ ]),
151
+ ) as Record<Axis, DragCurve>
152
+ const callbacks = {
153
+ onStart: options.onStart,
154
+ onMove: options.onMove,
155
+ onRelease: options.onRelease,
156
+ onCancel: options.onCancel,
157
+ onSettle: options.onSettle,
158
+ }
159
+ for (const callback of Object.values(callbacks))
160
+ if (callback !== undefined && typeof callback !== 'function')
161
+ throw new TypeError('Drag callbacks must be functions.')
162
+ return { axes, curves, callbacks }
163
+ }
164
+
165
+ /** Main-thread pan updates; release physics play through Jolt's native animation drivers. */
166
+ export function drag(target: Target, options: DragOptions = {}): Drag {
167
+ const view = toView(target)
168
+ const config = settings(options) // Invalid replacements leave the existing owner intact.
169
+ const previous = owners.get(view)
170
+ const controller = new DragController(view, config)
171
+ owners.set(view, controller)
172
+ // onCancel may synchronously install another drag. The most recent owner wins.
173
+ try {
174
+ previous?.dispose()
175
+ } catch (error) {
176
+ controller.dispose()
177
+ throw error
178
+ }
179
+ return controller
180
+ }
181
+
182
+ class DragController implements Drag {
183
+ private active = false
184
+ private disabled = false
185
+ private destroyed = false
186
+ private background = false
187
+ private attached = false
188
+ private blocked = false
189
+ private generation = 0
190
+ private release: PlaybackAnimation | undefined
191
+ private input = new DragInput()
192
+ private origin = { x: 0, y: 0 }
193
+ private last: DragEvent = { x: 0, y: 0, velocityX: 0, velocityY: 0 }
194
+ private lengths = { x: 200, y: 200 }
195
+ constructor(
196
+ private view: View,
197
+ private config: Settings,
198
+ ) {
199
+ view.on('loaded', this.attach)
200
+ view.on('unloaded', this.unload)
201
+ Application.on(Application.suspendEvent, this.suspend)
202
+ Application.on(Application.resumeEvent, this.resume)
203
+ this.attach()
204
+ }
205
+ get dragging() {
206
+ return this.active
207
+ }
208
+ get settling() {
209
+ return !!this.release
210
+ }
211
+ get paused() {
212
+ return this.disabled
213
+ }
214
+ get disposed() {
215
+ return this.destroyed
216
+ }
217
+ get position(): DragPoint {
218
+ return { x: currentValue(this.view, 'x'), y: currentValue(this.view, 'y') }
219
+ }
220
+ cancel = () => {
221
+ const wasActive = this.active
222
+ this.active = false
223
+ this.blocked = true
224
+ this.generation++
225
+ const release = this.release
226
+ this.release = undefined
227
+ release?.stop()
228
+ this.input.reset()
229
+ if (wasActive)
230
+ this.config.callbacks.onCancel?.({ ...this.last, velocityX: 0, velocityY: 0 })
231
+ }
232
+ pause = () => {
233
+ if (this.destroyed || this.disabled) return
234
+ this.disabled = true
235
+ this.detach()
236
+ }
237
+ play = () => {
238
+ if (this.destroyed) return
239
+ this.disabled = false
240
+ this.attach()
241
+ }
242
+ dispose = () => {
243
+ if (this.destroyed) return
244
+ this.destroyed = true
245
+ this.view.off('loaded', this.attach)
246
+ this.view.off('unloaded', this.unload)
247
+ Application.off(Application.suspendEvent, this.suspend)
248
+ Application.off(Application.resumeEvent, this.resume)
249
+ if (owners.get(this.view) === this) owners.delete(this.view)
250
+ this.detach()
251
+ }
252
+ private attach = () => {
253
+ if (
254
+ this.attached ||
255
+ this.destroyed ||
256
+ this.disabled ||
257
+ this.background ||
258
+ !this.view.isLoaded
259
+ )
260
+ return
261
+ this.blocked = false
262
+ this.attached = true
263
+ this.view.on('touch', this.touch)
264
+ this.view.on('pan', this.pan)
265
+ }
266
+ private detach = () => {
267
+ this.attached = false
268
+ this.view.off('touch', this.touch)
269
+ this.view.off('pan', this.pan)
270
+ this.cancel()
271
+ }
272
+ private unload = () => this.detach()
273
+ private suspend = () => {
274
+ this.background = true
275
+ this.detach()
276
+ }
277
+ private resume = () => {
278
+ this.background = false
279
+ this.attach()
280
+ }
281
+ private touch = (data: EventData) => {
282
+ const event = data as TouchGestureEventData
283
+ if (event.action === 'down') {
284
+ this.cancel()
285
+ this.blocked = false
286
+ } else if (event.action === 'cancel') this.cancel()
287
+ }
288
+ private pan = (data: EventData) => {
289
+ const event = data as PanGestureEventData
290
+ if (!this.attached || this.destroyed || this.disabled || this.background) return
291
+ if (this.view.isEnabled === false) {
292
+ this.cancel()
293
+ return
294
+ }
295
+ if (event.state === 1) this.input.reset()
296
+ const input = this.input.read(event, this.view)
297
+ if (input.cancelled) {
298
+ this.cancel()
299
+ return
300
+ }
301
+ if (this.blocked) return
302
+ if (event.state === 1) {
303
+ this.cancel()
304
+ if (!this.attached || this.destroyed || this.disabled) return
305
+ this.blocked = false
306
+ // Claim just the enabled axes, preserving the native presentation on takeover.
307
+ stop(this.view, this.config.axes)
308
+ this.origin = { x: readValue(this.view, 'x'), y: readValue(this.view, 'y') }
309
+ const size =
310
+ (this.view.parent as View | undefined)?.getActualSize() ??
311
+ this.view.getActualSize()
312
+ this.lengths = {
313
+ x: Math.max(1, size.width || 200),
314
+ y: Math.max(1, size.height || 200),
315
+ }
316
+ this.active = true
317
+ this.last = { ...this.origin, velocityX: 0, velocityY: 0 }
318
+ for (const axis of this.config.axes) {
319
+ const curve = this.config.curves[axis]
320
+ if (!curve.rubberband) continue
321
+ const resumed = resumeDrag(this.origin[axis], curve.bounds, this.lengths[axis])
322
+ this.origin[axis] = resumed.origin
323
+ this.lengths[axis] = resumed.length
324
+ }
325
+ this.input.read(event, this.view)
326
+ this.emit(() => this.config.callbacks.onStart?.({ ...this.last }))
327
+ }
328
+ if (!this.active) return
329
+ const generation = this.generation
330
+ if (Number.isFinite(input.x) && Number.isFinite(input.y)) {
331
+ const position = { x: this.last.x, y: this.last.y }
332
+ let vx = 0,
333
+ vy = 0
334
+ for (const axis of this.config.axes) {
335
+ const curve = this.config.curves[axis]
336
+ const raw = this.origin[axis] + input[axis]
337
+ const distance = raw - clampDrag(raw, curve.bounds)
338
+ const resistance =
339
+ distance === 0
340
+ ? 1
341
+ : curve.rubberband
342
+ ? 0.55 / (1 + Math.abs(distance) / this.lengths[axis]) ** 2
343
+ : 0
344
+ position[axis] = curve.rubberband
345
+ ? resistDrag(raw, curve.bounds, this.lengths[axis])
346
+ : clampDrag(raw, curve.bounds)
347
+ if (axis === 'x') vx = input.velocityX * resistance
348
+ else vy = input.velocityY * resistance
349
+ stop(this.view, [axis])
350
+ writeValue(this.view, axis, position[axis])
351
+ }
352
+ this.last = { ...position, velocityX: vx, velocityY: vy }
353
+ if (event.state !== 3)
354
+ this.emit(() => this.config.callbacks.onMove?.({ ...this.last }))
355
+ }
356
+ if (!this.active || generation !== this.generation) return
357
+ if (event.state === 3) this.emit(() => this.finish())
358
+ }
359
+ private emit(callback: () => void) {
360
+ try {
361
+ callback()
362
+ } catch (error) {
363
+ // A failing application callback must not leave live input or an orphaned fling.
364
+ this.active = false
365
+ this.dispose()
366
+ throw error
367
+ }
368
+ }
369
+ private finish() {
370
+ this.active = false
371
+ const generation = ++this.generation
372
+ const event = { ...this.last }
373
+ const target = { x: event.x, y: event.y }
374
+ const plans: Plan[] = this.config.axes.map((axis) => {
375
+ const curve = {
376
+ ...this.config.curves[axis],
377
+ velocity: axis === 'x' ? event.velocityX : event.velocityY,
378
+ }
379
+ target[axis] = solveDragRelease(event[axis], curve).to
380
+ return {
381
+ property: axis,
382
+ keyframes: [null, null],
383
+ transition: { curve, delay: 0, repeat: 0, reverse: false },
384
+ }
385
+ })
386
+ const animation = new ViewAnimation(this.view, plans)
387
+ this.release = animation
388
+ void animation.finished.then((finished) => {
389
+ if (generation !== this.generation || this.destroyed) return
390
+ this.release = undefined
391
+ if (finished) this.config.callbacks.onSettle?.({ ...target })
392
+ })
393
+ this.emit(() => this.config.callbacks.onRelease?.({ ...event, target, animation }))
394
+ }
395
+ }
package/src/driver/ios.ts CHANGED
@@ -5,11 +5,13 @@ import {
5
5
  presentedIOSDecoration,
6
6
  syncIOSDecoration,
7
7
  } from './ios-decoration.js'
8
- import type { View } from '@nativescript/core'
8
+ import { Application, type View } from '@nativescript/core'
9
+ import { stopView } from '../engine.js'
9
10
  import type { NumericProperty } from '../properties.js'
10
11
  import { isColorProperty } from '../color.js'
11
12
  import { playIOSColor, presentedIOSColor } from './ios-color.js'
12
13
  import { after, type Driver } from './index.js'
14
+ import { hasInteractionSurface } from '../interaction-surface.js'
13
15
 
14
16
  interface Handle {
15
17
  stop(): void
@@ -65,6 +67,40 @@ export function createIOSDriver(): Driver {
65
67
  const layers = layersOf(view)
66
68
  const key = `jolt:${track.property}`
67
69
  const rotation = track.property.startsWith('rotate')
70
+ if (hasInteractionSurface(view)) {
71
+ if (typeof JoltInteractiveAnimation === 'undefined')
72
+ throw new Error('Rebuild the iOS app to enable Jolt interaction surfaces.')
73
+ if (track.property === 'rotateX' || track.property === 'rotateY')
74
+ ensurePerspective(view, layers)
75
+ const suspend = () => stopView(view, [track.property])
76
+ const completed = (finished: boolean) => {
77
+ Application.off(Application.suspendEvent, suspend)
78
+ onEnd(finished)
79
+ }
80
+ const native =
81
+ JoltInteractiveAnimation.startViewLayersKeyPathValuesTimesDurationDelayRepeatReverseFinalValueStartedAtCompletion(
82
+ view.nativeViewProtected,
83
+ NSArray.arrayWithArray(layers),
84
+ keyPaths[track.property as NumericProperty],
85
+ NSArray.arrayWithArray(
86
+ track.values.map((value) => (rotation ? radians(value) : value)),
87
+ ),
88
+ NSArray.arrayWithArray(track.times as number[]),
89
+ track.duration,
90
+ track.delay,
91
+ track.repeat,
92
+ track.reverse,
93
+ rotation ? radians(track.to) : track.to,
94
+ startedAt,
95
+ completed,
96
+ )
97
+ Application.on(Application.suspendEvent, suspend)
98
+ return {
99
+ elapsed: () => native.elapsed,
100
+ control: (time, paused) => native.controlPaused(time, paused),
101
+ stop: () => native.cancel(),
102
+ }
103
+ }
68
104
  const infinite = track.repeat === Infinity
69
105
  if (track.property === 'rotateX' || track.property === 'rotateY') {
70
106
  ensurePerspective(view, layers)
@@ -143,14 +179,27 @@ export function createIOSDriver(): Driver {
143
179
  },
144
180
  elapsed: (handle) => (handle as Handle).elapsed(),
145
181
 
146
- sync: syncIOSDecoration,
182
+ sync(view, excluding) {
183
+ syncIOSDecoration(view, excluding)
184
+ if (view.nativeViewProtected && typeof JoltInteractiveAnimation !== 'undefined')
185
+ JoltInteractiveAnimation.syncViewExcluding(
186
+ view.nativeViewProtected,
187
+ excluding ? (keyPaths[excluding as NumericProperty] ?? null) : null,
188
+ )
189
+ },
147
190
 
148
- presented: (view, property) =>
149
- isDecorationProperty(property)
150
- ? presentedIOSDecoration(view, property)
151
- : isColorProperty(property)
152
- ? presentedIOSColor(view, property)
153
- : undefined,
191
+ presented(view, property) {
192
+ if (isDecorationProperty(property)) return presentedIOSDecoration(view, property)
193
+ if (isColorProperty(property)) return presentedIOSColor(view, property)
194
+ if (!view.nativeViewProtected || typeof JoltInteractiveAnimation === 'undefined')
195
+ return undefined
196
+ const value = JoltInteractiveAnimation.valueForViewKeyPath(
197
+ view.nativeViewProtected,
198
+ keyPaths[property],
199
+ )
200
+ if (value === null || value === undefined) return undefined
201
+ return property.startsWith('rotate') ? (value * 180) / Math.PI : Number(value)
202
+ },
154
203
 
155
204
  after,
156
205
 
package/src/engine.ts CHANGED
@@ -436,6 +436,17 @@ export class ViewAnimation implements PlaybackAnimation {
436
436
  }
437
437
  }
438
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
+
439
450
  /** Stops whatever is animating these properties of the view, or all of them. */
440
451
  export function stopView(view: View, properties?: readonly Property[]) {
441
452
  const tokens = revisions.get(view)