@rafaelaugusto/jolt 0.1.0-beta.7 → 0.1.0-beta.9

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 (90) hide show
  1. package/README.md +12 -2
  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/android.d.ts.map +1 -1
  19. package/dist/driver/android.js +40 -0
  20. package/dist/driver/android.js.map +1 -1
  21. package/dist/driver/index.d.ts +10 -0
  22. package/dist/driver/index.d.ts.map +1 -1
  23. package/dist/driver/index.js.map +1 -1
  24. package/dist/driver/ios-layout.d.ts +9 -0
  25. package/dist/driver/ios-layout.d.ts.map +1 -0
  26. package/dist/driver/ios-layout.js +52 -0
  27. package/dist/driver/ios-layout.js.map +1 -0
  28. package/dist/driver/ios.d.ts.map +1 -1
  29. package/dist/driver/ios.js +85 -6
  30. package/dist/driver/ios.js.map +1 -1
  31. package/dist/engine.d.ts +6 -0
  32. package/dist/engine.d.ts.map +1 -1
  33. package/dist/engine.js +26 -0
  34. package/dist/engine.js.map +1 -1
  35. package/dist/index.d.ts +10 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +5 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/interaction-surface.d.ts +12 -0
  40. package/dist/interaction-surface.d.ts.map +1 -0
  41. package/dist/interaction-surface.js +36 -0
  42. package/dist/interaction-surface.js.map +1 -0
  43. package/dist/layout.d.ts +13 -0
  44. package/dist/layout.d.ts.map +1 -0
  45. package/dist/layout.js +348 -0
  46. package/dist/layout.js.map +1 -0
  47. package/dist/motion-scope.d.ts +39 -0
  48. package/dist/motion-scope.d.ts.map +1 -0
  49. package/dist/motion-scope.js +238 -0
  50. package/dist/motion-scope.js.map +1 -0
  51. package/dist/motion-states.d.ts +36 -0
  52. package/dist/motion-states.d.ts.map +1 -0
  53. package/dist/motion-states.js +165 -0
  54. package/dist/motion-states.js.map +1 -0
  55. package/dist/press.d.ts.map +1 -1
  56. package/dist/press.js +14 -3
  57. package/dist/press.js.map +1 -1
  58. package/dist/track.d.ts.map +1 -1
  59. package/dist/track.js +21 -4
  60. package/dist/track.js.map +1 -1
  61. package/dist/transition.d.ts +2 -1
  62. package/dist/transition.d.ts.map +1 -1
  63. package/dist/transition.js.map +1 -1
  64. package/dist/vue/index.d.ts +3 -1
  65. package/dist/vue/index.d.ts.map +1 -1
  66. package/dist/vue/index.js +17 -4
  67. package/dist/vue/index.js.map +1 -1
  68. package/package.json +1 -1
  69. package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
  70. package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
  71. package/platforms/ios/src/module.modulemap +1 -0
  72. package/src/animate.ts +15 -8
  73. package/src/drag-input.ts +74 -0
  74. package/src/drag-physics.ts +110 -0
  75. package/src/drag.ts +395 -0
  76. package/src/driver/android.ts +38 -0
  77. package/src/driver/index.ts +11 -0
  78. package/src/driver/ios-layout.ts +63 -0
  79. package/src/driver/ios.ts +100 -8
  80. package/src/engine.ts +29 -0
  81. package/src/index.ts +17 -0
  82. package/src/interaction-surface.ts +41 -0
  83. package/src/ios-plugin.d.ts +22 -0
  84. package/src/layout.ts +408 -0
  85. package/src/motion-scope.ts +279 -0
  86. package/src/motion-states.ts +215 -0
  87. package/src/press.ts +15 -3
  88. package/src/track.ts +25 -4
  89. package/src/transition.ts +2 -0
  90. package/src/vue/index.ts +20 -3
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
+ }
@@ -178,6 +178,44 @@ export function createAndroidDriver(): Driver {
178
178
  return nativeView ? accessors[property].get(nativeView) / unit(property) : undefined
179
179
  },
180
180
 
181
+ layoutFrame(view) {
182
+ const native = view.nativeViewProtected as android.view.View | undefined
183
+ if (!native) return undefined
184
+ const dip = Utils.layout.toDeviceIndependentPixels
185
+ return {
186
+ x: dip(native.getLeft()),
187
+ y: dip(native.getTop()),
188
+ width: dip(native.getWidth()),
189
+ height: dip(native.getHeight()),
190
+ }
191
+ },
192
+
193
+ afterLayout(view, callback) {
194
+ const native = view.nativeViewProtected as android.view.View
195
+ const observer = native.getViewTreeObserver()
196
+ let active = true
197
+ const detach = () => {
198
+ if (!active) return
199
+ active = false
200
+ if (observer.isAlive()) observer.removeOnPreDrawListener(listener)
201
+ else {
202
+ const current = native.getViewTreeObserver()
203
+ if (current.isAlive()) current.removeOnPreDrawListener(listener)
204
+ }
205
+ }
206
+ const listener = new android.view.ViewTreeObserver.OnPreDrawListener({
207
+ onPreDraw() {
208
+ if (active) {
209
+ detach()
210
+ callback()
211
+ }
212
+ return true
213
+ },
214
+ })
215
+ observer.addOnPreDrawListener(listener)
216
+ return detach
217
+ },
218
+
181
219
  after(ms, callback) {
182
220
  // A native marker follows animator duration scale, including changes during playback.
183
221
  // Only completion crosses the bridge; there is no JavaScript update listener.
@@ -5,6 +5,13 @@ import type { Track } from '../track.js'
5
5
  import { createAndroidDriver } from './android.js'
6
6
  import { createIOSDriver } from './ios.js'
7
7
 
8
+ export interface LayoutFrame {
9
+ x: number
10
+ y: number
11
+ width: number
12
+ height: number
13
+ }
14
+
8
15
  export interface Driver {
9
16
  /** Milliseconds, on the clock the platform's animations run on. */
10
17
  now(): number
@@ -34,6 +41,10 @@ export interface Driver {
34
41
  presented(view: View, property: Property): number | undefined
35
42
  /** Calls back after `ms` of animation time, respecting platform speed settings. */
36
43
  after(ms: number, callback: () => void): () => void
44
+ /** Untransformed native layout bounds in parent DIPs. */
45
+ layoutFrame?(view: View): LayoutFrame | undefined
46
+ /** One callback after layout, before drawing; caller requests layout and owns cancellation. */
47
+ afterLayout?(view: View, callback: () => void): () => void
37
48
  /** The platform's accessibility setting: Reduce Motion on iOS, Remove animations on Android. */
38
49
  reducedMotion?(): boolean
39
50
  }
@@ -0,0 +1,63 @@
1
+ import type { View } from '@nativescript/core'
2
+
3
+ type LayoutView = View & {
4
+ _isTransformed: boolean
5
+ _modifyNativeViewFrame(native: UIView, frame: CGRect): void
6
+ }
7
+ const retained = new WeakMap<View, { count: number; restore(): void }>()
8
+
9
+ /**
10
+ * NativeScript caches whether its JS transform is nonidentity. Interactive native
11
+ * playback can move the actual model while that cached destination is identity.
12
+ * Let Core reset/restore the real transform when it next assigns a layout frame.
13
+ * This hook belongs only to the opted-in instance and only while tracks own it.
14
+ */
15
+ export function retainInteractiveLayout(view: View): () => void {
16
+ const host = view as LayoutView
17
+ let entry = retained.get(view)
18
+ if (!entry) {
19
+ const original = host._modifyNativeViewFrame
20
+ if (typeof original !== 'function') return () => {}
21
+ const descriptor = Object.getOwnPropertyDescriptor(host, '_modifyNativeViewFrame')
22
+ let active = true
23
+ const wrapper: LayoutView['_modifyNativeViewFrame'] = function (
24
+ this: LayoutView,
25
+ native,
26
+ frame,
27
+ ) {
28
+ if (active)
29
+ this._isTransformed = !CATransform3DEqualToTransform(
30
+ native.layer.transform,
31
+ CATransform3DIdentity,
32
+ )
33
+ original.call(this, native, frame)
34
+ }
35
+ host._modifyNativeViewFrame = wrapper
36
+ entry = {
37
+ count: 0,
38
+ restore() {
39
+ active = false
40
+ const native = host.nativeViewProtected as UIView | undefined
41
+ if (native)
42
+ host._isTransformed = !CATransform3DEqualToTransform(
43
+ native.layer.transform,
44
+ CATransform3DIdentity,
45
+ )
46
+ if (host._modifyNativeViewFrame !== wrapper) return
47
+ if (descriptor) Object.defineProperty(host, '_modifyNativeViewFrame', descriptor)
48
+ else delete (host as Partial<LayoutView>)._modifyNativeViewFrame
49
+ },
50
+ }
51
+ retained.set(view, entry)
52
+ }
53
+ entry.count++
54
+ let released = false
55
+ return () => {
56
+ if (released) return
57
+ released = true
58
+ if (--entry.count === 0) {
59
+ entry.restore()
60
+ retained.delete(view)
61
+ }
62
+ }
63
+ }
package/src/driver/ios.ts CHANGED
@@ -5,11 +5,14 @@ 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'
15
+ import { retainInteractiveLayout } from './ios-layout.js'
13
16
 
14
17
  interface Handle {
15
18
  stop(): void
@@ -65,6 +68,48 @@ export function createIOSDriver(): Driver {
65
68
  const layers = layersOf(view)
66
69
  const key = `jolt:${track.property}`
67
70
  const rotation = track.property.startsWith('rotate')
71
+ if (hasInteractionSurface(view)) {
72
+ if (typeof JoltInteractiveAnimation === 'undefined')
73
+ throw new Error('Rebuild the iOS app to enable Jolt interaction surfaces.')
74
+ if (track.property === 'rotateX' || track.property === 'rotateY')
75
+ ensurePerspective(view, layers)
76
+ const releaseLayout = retainInteractiveLayout(view)
77
+ const suspend = () => stopView(view, [track.property])
78
+ const completed = (finished: boolean) => {
79
+ Application.off(Application.suspendEvent, suspend)
80
+ releaseLayout()
81
+ onEnd(finished)
82
+ }
83
+ let native: JoltInteractiveAnimation
84
+ try {
85
+ native =
86
+ JoltInteractiveAnimation.startViewLayersKeyPathValuesTimesDurationDelayRepeatReverseFinalValueStartedAtCompletion(
87
+ view.nativeViewProtected,
88
+ NSArray.arrayWithArray(layers),
89
+ keyPaths[track.property as NumericProperty],
90
+ NSArray.arrayWithArray(
91
+ track.values.map((value) => (rotation ? radians(value) : value)),
92
+ ),
93
+ NSArray.arrayWithArray(track.times as number[]),
94
+ track.duration,
95
+ track.delay,
96
+ track.repeat,
97
+ track.reverse,
98
+ rotation ? radians(track.to) : track.to,
99
+ startedAt,
100
+ completed,
101
+ )
102
+ } catch (error) {
103
+ releaseLayout()
104
+ throw error
105
+ }
106
+ Application.on(Application.suspendEvent, suspend)
107
+ return {
108
+ elapsed: () => native.elapsed,
109
+ control: (time, paused) => native.controlPaused(time, paused),
110
+ stop: () => native.cancel(),
111
+ }
112
+ }
68
113
  const infinite = track.repeat === Infinity
69
114
  if (track.property === 'rotateX' || track.property === 'rotateY') {
70
115
  ensurePerspective(view, layers)
@@ -143,14 +188,61 @@ export function createIOSDriver(): Driver {
143
188
  },
144
189
  elapsed: (handle) => (handle as Handle).elapsed(),
145
190
 
146
- sync: syncIOSDecoration,
191
+ sync(view, excluding) {
192
+ syncIOSDecoration(view, excluding)
193
+ if (view.nativeViewProtected && typeof JoltInteractiveAnimation !== 'undefined')
194
+ JoltInteractiveAnimation.syncViewExcluding(
195
+ view.nativeViewProtected,
196
+ excluding ? (keyPaths[excluding as NumericProperty] ?? null) : null,
197
+ )
198
+ },
199
+
200
+ presented(view, property) {
201
+ if (isDecorationProperty(property)) return presentedIOSDecoration(view, property)
202
+ if (isColorProperty(property)) return presentedIOSColor(view, property)
203
+ if (!view.nativeViewProtected || typeof JoltInteractiveAnimation === 'undefined')
204
+ return undefined
205
+ const value = JoltInteractiveAnimation.valueForViewKeyPath(
206
+ view.nativeViewProtected,
207
+ keyPaths[property],
208
+ )
209
+ if (value === null || value === undefined) return undefined
210
+ return property.startsWith('rotate') ? (value * 180) / Math.PI : Number(value)
211
+ },
212
+
213
+ layoutFrame(view) {
214
+ const native = view.nativeViewProtected as UIView | undefined
215
+ if (!native) return undefined
216
+ const { width, height } = native.bounds.size
217
+ const { position, anchorPoint } = native.layer
218
+ return {
219
+ x: position.x - width * anchorPoint.x,
220
+ y: position.y - height * anchorPoint.y,
221
+ width,
222
+ height,
223
+ }
224
+ },
147
225
 
148
- presented: (view, property) =>
149
- isDecorationProperty(property)
150
- ? presentedIOSDecoration(view, property)
151
- : isColorProperty(property)
152
- ? presentedIOSColor(view, property)
153
- : undefined,
226
+ afterLayout(view, callback) {
227
+ const original = view.layout
228
+ const descriptor = Object.getOwnPropertyDescriptor(view, 'layout')
229
+ let active = true
230
+ const detach = () => {
231
+ active = false
232
+ if (view.layout !== wrapper) return
233
+ if (descriptor) Object.defineProperty(view, 'layout', descriptor)
234
+ else delete (view as Partial<View>).layout
235
+ }
236
+ const wrapper: View['layout'] = function (this: View, ...args) {
237
+ original.apply(this, args)
238
+ if (active) {
239
+ detach()
240
+ callback()
241
+ }
242
+ }
243
+ view.layout = wrapper
244
+ return detach
245
+ },
154
246
 
155
247
  after,
156
248
 
package/src/engine.ts CHANGED
@@ -436,6 +436,35 @@ 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
+
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
465
+ }
466
+ }
467
+
439
468
  /** Stops whatever is animating these properties of the view, or all of them. */
440
469
  export function stopView(view: View, properties?: readonly Property[]) {
441
470
  const tokens = revisions.get(view)