@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.
- package/README.md +12 -2
- package/dist/animate.d.ts +2 -0
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +7 -2
- package/dist/animate.js.map +1 -1
- package/dist/drag-input.d.ts +15 -0
- package/dist/drag-input.d.ts.map +1 -0
- package/dist/drag-input.js +66 -0
- package/dist/drag-input.js.map +1 -0
- package/dist/drag-physics.d.ts +20 -0
- package/dist/drag-physics.d.ts.map +1 -0
- package/dist/drag-physics.js +75 -0
- package/dist/drag-physics.js.map +1 -0
- package/dist/drag.d.ts +58 -0
- package/dist/drag.d.ts.map +1 -0
- package/dist/drag.js +324 -0
- package/dist/drag.js.map +1 -0
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +40 -0
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +10 -0
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-layout.d.ts +9 -0
- package/dist/driver/ios-layout.d.ts.map +1 -0
- package/dist/driver/ios-layout.js +52 -0
- package/dist/driver/ios-layout.js.map +1 -0
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +85 -6
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +6 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +26 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/interaction-surface.d.ts +12 -0
- package/dist/interaction-surface.d.ts.map +1 -0
- package/dist/interaction-surface.js +36 -0
- package/dist/interaction-surface.js.map +1 -0
- package/dist/layout.d.ts +13 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +348 -0
- package/dist/layout.js.map +1 -0
- package/dist/motion-scope.d.ts +39 -0
- package/dist/motion-scope.d.ts.map +1 -0
- package/dist/motion-scope.js +238 -0
- package/dist/motion-scope.js.map +1 -0
- package/dist/motion-states.d.ts +36 -0
- package/dist/motion-states.d.ts.map +1 -0
- package/dist/motion-states.js +165 -0
- package/dist/motion-states.js.map +1 -0
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +14 -3
- package/dist/press.js.map +1 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +21 -4
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts +2 -1
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js.map +1 -1
- package/dist/vue/index.d.ts +3 -1
- package/dist/vue/index.d.ts.map +1 -1
- package/dist/vue/index.js +17 -4
- package/dist/vue/index.js.map +1 -1
- package/package.json +1 -1
- package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
- package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
- package/platforms/ios/src/module.modulemap +1 -0
- package/src/animate.ts +15 -8
- package/src/drag-input.ts +74 -0
- package/src/drag-physics.ts +110 -0
- package/src/drag.ts +395 -0
- package/src/driver/android.ts +38 -0
- package/src/driver/index.ts +11 -0
- package/src/driver/ios-layout.ts +63 -0
- package/src/driver/ios.ts +100 -8
- package/src/engine.ts +29 -0
- package/src/index.ts +17 -0
- package/src/interaction-surface.ts +41 -0
- package/src/ios-plugin.d.ts +22 -0
- package/src/layout.ts +408 -0
- package/src/motion-scope.ts +279 -0
- package/src/motion-states.ts +215 -0
- package/src/press.ts +15 -3
- package/src/track.ts +25 -4
- package/src/transition.ts +2 -0
- 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
|
+
}
|
package/src/driver/android.ts
CHANGED
|
@@ -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.
|
package/src/driver/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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)
|