@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
@@ -0,0 +1,279 @@
1
+ import { animate, decay } from './animate.js'
2
+ import { press } from './press.js'
3
+ import { drag } from './drag.js'
4
+ import { animateLayout, type LayoutOptions } from './layout.js'
5
+ import type { SpringName } from './transition.js'
6
+ import { sequence } from './sequence.js'
7
+ import {
8
+ motionStates,
9
+ type MotionState,
10
+ type MotionDefinitions,
11
+ type MotionStates,
12
+ type MotionStatesOptions,
13
+ } from './motion-states.js'
14
+ import type { Target } from './types.js'
15
+
16
+ /** A cleanup function or a handle owned exclusively by one scope. */
17
+ export type MotionResource = (() => void) | { stop(): void } | { dispose(): void }
18
+ type Controlled = {
19
+ pause?: () => void
20
+ play?: () => void
21
+ readonly paused?: boolean
22
+ readonly finished?: Promise<unknown>
23
+ }
24
+ interface Entry {
25
+ resource?: MotionResource
26
+ setup?: () => MotionResource
27
+ resume: boolean
28
+ }
29
+ const owners = new WeakMap<object, object>()
30
+
31
+ export interface MotionScope {
32
+ readonly disposed: boolean
33
+ readonly suspended: boolean
34
+ /** Owned handles/subscriptions, including suspended subscription factories. */
35
+ readonly size: number
36
+ animateLayout: typeof animateLayout
37
+ animate: typeof animate
38
+ decay: typeof decay
39
+ sequence: typeof sequence
40
+ press: typeof press
41
+ drag: typeof drag
42
+ states<
43
+ T extends Record<string, Target>,
44
+ const S extends Record<string, MotionState<keyof T>>,
45
+ >(
46
+ targets: T,
47
+ definitions: MotionDefinitions<T, S>,
48
+ options?: Omit<MotionStatesOptions, 'scope'>,
49
+ ): MotionStates<Extract<keyof S, string>>
50
+ /** Adopt an existing handle. Completed animations are released automatically. */
51
+ add<T extends MotionResource>(resource: T): T
52
+ /** Attach a subscription, detach on suspension, and recreate on resume. */
53
+ use(setup: () => MotionResource): () => void
54
+ /** Pause playable handles; cancel non-playable ones; detach subscriptions. */
55
+ suspend(): void
56
+ resume(): void
57
+ /** Cancel all owned work without destroying the scope, e.g. when recycling a cell. */
58
+ reset(): void
59
+ dispose(): void
60
+ }
61
+
62
+ /** Framework-independent ownership. Native unload never permanently destroys a scope. */
63
+ export function motionScope(): MotionScope {
64
+ const entries = new Set<Entry>()
65
+ const adopted = new Map<MotionResource, Entry>()
66
+ const identity = {}
67
+ let disposed = false
68
+ let suspended = false
69
+ let clearing = false
70
+ const assertAlive = () => {
71
+ if (disposed) throw new Error('Motion scope is disposed.')
72
+ if (clearing) throw new Error('Motion scope is resetting.')
73
+ }
74
+ const assertActive = () => {
75
+ assertAlive()
76
+ if (suspended)
77
+ throw new Error('Motion scope is suspended; resume it before starting work.')
78
+ }
79
+ const validate = (resource: MotionResource) => {
80
+ if (
81
+ typeof resource !== 'function' &&
82
+ (!resource ||
83
+ (typeof (resource as { dispose?: unknown }).dispose !== 'function' &&
84
+ typeof (resource as { stop?: unknown }).stop !== 'function'))
85
+ )
86
+ throw new TypeError(
87
+ 'A motion resource must be a cleanup function or expose stop()/dispose().',
88
+ )
89
+ const finished = (resource as Controlled).finished
90
+ if (finished && typeof finished.then !== 'function')
91
+ throw new TypeError('Resource finished must be a promise.')
92
+ if (owners.has(resource) && owners.get(resource) !== identity)
93
+ throw new Error('Motion resource already belongs to another scope.')
94
+ }
95
+ const forget = (entry: Entry) => {
96
+ const resource = entry.resource
97
+ if (resource) {
98
+ owners.delete(resource)
99
+ adopted.delete(resource)
100
+ entry.resource = undefined
101
+ }
102
+ entry.resume = false
103
+ }
104
+ const disposeResource = (resource: MotionResource) => {
105
+ if (typeof resource === 'function') resource()
106
+ else if (typeof (resource as { dispose?: unknown }).dispose === 'function')
107
+ (resource as { dispose(): void }).dispose()
108
+ else (resource as { stop(): void }).stop()
109
+ }
110
+ const release = (entry: Entry) => {
111
+ const resource = entry.resource
112
+ forget(entry)
113
+ if (!resource) return
114
+ disposeResource(resource)
115
+ }
116
+ const remove = (entry: Entry) => {
117
+ entries.delete(entry)
118
+ release(entry)
119
+ }
120
+ const attach = (entry: Entry, resource: MotionResource) => {
121
+ validate(resource)
122
+ if (adopted.has(resource))
123
+ throw new Error('Subscription factory returned an already owned resource.')
124
+ entry.resource = resource
125
+ adopted.set(resource, entry)
126
+ owners.set(resource, identity)
127
+ const finished = (resource as Controlled).finished
128
+ if (!entry.setup && finished) {
129
+ const settled = () => {
130
+ if (entry.resource !== resource) return
131
+ forget(entry)
132
+ entries.delete(entry)
133
+ }
134
+ void finished.then(settled, settled)
135
+ }
136
+ }
137
+ const startSubscription = (entry: Entry) => {
138
+ const resource = entry.setup!()
139
+ validate(resource)
140
+ // Setup may synchronously navigate away, suspend or remove its own scope.
141
+ if (disposed || suspended || !entries.has(entry)) {
142
+ if (!owners.has(resource)) disposeResource(resource)
143
+ return
144
+ }
145
+ attach(entry, resource)
146
+ }
147
+ // One bad cleanup must not strand the other subscriptions.
148
+ const visit = (action: (entry: Entry) => void) => {
149
+ const errors: unknown[] = []
150
+ for (const entry of [...entries]) {
151
+ if (!entries.has(entry)) continue
152
+ try {
153
+ action(entry)
154
+ } catch (error) {
155
+ errors.push(error)
156
+ }
157
+ }
158
+ if (errors.length)
159
+ throw new AggregateError(errors, 'Motion scope cleanup/lifecycle failed.')
160
+ }
161
+ const startOwned = <T extends MotionResource>(factory: () => T): T => {
162
+ assertActive()
163
+ const resource = factory()
164
+ try {
165
+ return scope.add(resource)
166
+ } catch (error) {
167
+ if (!owners.has(resource)) disposeResource(resource)
168
+ throw error
169
+ }
170
+ }
171
+ const scope: MotionScope = {
172
+ get disposed() {
173
+ return disposed
174
+ },
175
+ get suspended() {
176
+ return suspended
177
+ },
178
+ get size() {
179
+ return entries.size
180
+ },
181
+ animateLayout(
182
+ container: Target,
183
+ changeOrOptions?: (() => void) | LayoutOptions | SpringName,
184
+ options?: LayoutOptions | SpringName,
185
+ ) {
186
+ return startOwned(() =>
187
+ typeof changeOrOptions === 'function'
188
+ ? animateLayout(container, changeOrOptions, options)
189
+ : animateLayout(container, changeOrOptions),
190
+ )
191
+ },
192
+ animate(...args) {
193
+ return startOwned(() => animate(...args))
194
+ },
195
+ decay(...args) {
196
+ return startOwned(() => decay(...args))
197
+ },
198
+ sequence(...args) {
199
+ return startOwned(() => sequence(...args))
200
+ },
201
+ press(...args) {
202
+ return scope.use(() => press(...args))
203
+ },
204
+ drag(...args) {
205
+ return startOwned(() => drag(...args))
206
+ },
207
+ states(targets, definitions, options) {
208
+ return startOwned(() => motionStates(targets, definitions, options))
209
+ },
210
+ add(resource) {
211
+ assertActive()
212
+ validate(resource)
213
+ if (adopted.has(resource)) return resource
214
+ const entry: Entry = { resume: false }
215
+ attach(entry, resource)
216
+ entries.add(entry)
217
+ return resource
218
+ },
219
+ use(setup) {
220
+ assertAlive()
221
+ if (typeof setup !== 'function')
222
+ throw new TypeError('Motion subscription needs a factory.')
223
+ const entry: Entry = { setup, resume: false }
224
+ entries.add(entry)
225
+ try {
226
+ if (!suspended) startSubscription(entry)
227
+ } catch (error) {
228
+ remove(entry)
229
+ throw error
230
+ }
231
+ return () => remove(entry)
232
+ },
233
+ suspend() {
234
+ if (disposed || suspended) return
235
+ suspended = true
236
+ visit((entry) => {
237
+ if (entry.setup) {
238
+ release(entry)
239
+ return
240
+ }
241
+ const resource = entry.resource as Controlled | undefined
242
+ if (
243
+ typeof resource?.pause === 'function' &&
244
+ typeof resource.play === 'function'
245
+ ) {
246
+ entry.resume = resource.paused !== true
247
+ if (entry.resume) resource.pause()
248
+ } else remove(entry)
249
+ })
250
+ },
251
+ resume() {
252
+ if (disposed || !suspended) return
253
+ suspended = false
254
+ visit((entry) => {
255
+ if (disposed || suspended) return
256
+ if (entry.setup) startSubscription(entry)
257
+ else if (entry.resume) {
258
+ entry.resume = false
259
+ ;(entry.resource as Controlled).play?.()
260
+ }
261
+ })
262
+ },
263
+ reset() {
264
+ if (disposed || clearing) return
265
+ clearing = true
266
+ try {
267
+ visit(remove)
268
+ } finally {
269
+ clearing = false
270
+ }
271
+ },
272
+ dispose() {
273
+ if (disposed) return
274
+ disposed = true
275
+ visit(remove)
276
+ },
277
+ }
278
+ return scope
279
+ }
@@ -0,0 +1,215 @@
1
+ import { prepareAnimation, toView } from './animate.js'
2
+ import { isColorProperty, type ColorValue } from './color.js'
3
+ import { isProperty } from './properties.js'
4
+ import type { Animation } from './engine.js'
5
+ import type { PlaybackAnimation } from './playback.js'
6
+ import type { KeyframeProperty, Keyframes, Target } from './types.js'
7
+ import type { Spring, Tween, SpringName } from './transition.js'
8
+ import type { MotionScope } from './motion-scope.js'
9
+
10
+ /** Absolute endpoints only; keyframe arrays, paths and relative offsets are not states. */
11
+ export type MotionValues = {
12
+ [K in KeyframeProperty]?: K extends 'color' | 'backgroundColor' | 'borderColor'
13
+ ? ColorValue
14
+ : number
15
+ }
16
+ export type MotionState<K extends PropertyKey = string> = Partial<Record<K, MotionValues>>
17
+ /** Preserve inference while rejecting unknown target/property keys. */
18
+ export type MotionDefinitions<T, S> = S & {
19
+ [N in keyof S]: Record<Exclude<keyof S[N], keyof T>, never> & {
20
+ [K in keyof S[N]]: Record<Exclude<keyof S[N][K], KeyframeProperty>, never>
21
+ }
22
+ }
23
+ export type StateTransition =
24
+ | SpringName
25
+ | Omit<Spring, 'repeat' | 'repeatType'>
26
+ | Omit<Tween, 'repeat' | 'repeatType'>
27
+ export interface MotionStatesOptions {
28
+ transition?: StateTransition
29
+ scope?: MotionScope
30
+ }
31
+ export interface MotionStates<S extends string = string> {
32
+ /** Latest requested state, not a claim that playback has finished. */
33
+ readonly current: S | undefined
34
+ readonly settled: S | undefined
35
+ readonly disposed: boolean
36
+ readonly paused: boolean
37
+ to(state: S, transition?: StateTransition): Animation
38
+ pause(): void
39
+ play(): void
40
+ stop(): void
41
+ dispose(): void
42
+ }
43
+
44
+ /** Coordinated named targets. Omitted properties freeze when leaving their previous state. */
45
+ export function motionStates<
46
+ T extends Record<string, Target>,
47
+ const S extends Record<string, MotionState<keyof T>>,
48
+ >(
49
+ targets: T,
50
+ definitions: MotionDefinitions<T, S>,
51
+ options: MotionStatesOptions = {},
52
+ ): MotionStates<Extract<keyof S, string>> {
53
+ type Name = Extract<keyof S, string>
54
+ const views = new Map(
55
+ Object.entries(targets).map(([name, target]) => [name, toView(target)]),
56
+ )
57
+ if (new Set(views.values()).size !== views.size)
58
+ throw new TypeError('Motion state targets must refer to distinct views.')
59
+ const states = new Map<string, [Target, Keyframes][]>()
60
+ const defaults = normalize(options.transition)
61
+ for (const [name, state] of Object.entries(definitions)) {
62
+ if (!state || typeof state !== 'object' || Array.isArray(state))
63
+ throw new TypeError(`Invalid motion state "${name}".`)
64
+ const rows: [Target, Keyframes][] = []
65
+ for (const [target, values] of Object.entries(state)) {
66
+ const view = views.get(target)
67
+ if (!view) throw new TypeError(`Unknown motion target "${target}".`)
68
+ if (!values || typeof values !== 'object' || Array.isArray(values))
69
+ throw new TypeError(`Invalid motion values for "${target}".`)
70
+ const frames: Keyframes = {}
71
+ for (const [property, value] of Object.entries(values)) {
72
+ if (value === undefined) continue
73
+ if (property !== 'scale' && !isProperty(property))
74
+ throw new TypeError(`Invalid motion state property "${property}".`)
75
+ if (
76
+ !isColorProperty(property) &&
77
+ (typeof value !== 'number' || !Number.isFinite(value))
78
+ )
79
+ throw new TypeError(`Motion state "${property}" requires a finite endpoint.`)
80
+ if (Array.isArray(value) || value === null)
81
+ throw new TypeError(`Motion state "${property}" requires a single endpoint.`)
82
+ Object.assign(frames, { [property]: value })
83
+ }
84
+ // Validate every definition up front without claiming any native property.
85
+ prepareAnimation(view, frames, defaults)
86
+ rows.push([view, frames])
87
+ }
88
+ states.set(name, rows)
89
+ }
90
+ let current: Name | undefined
91
+ let settled: Name | undefined
92
+ let disposed = false
93
+ let paused = false
94
+ let active: { handle: Animation; children: PlaybackAnimation[] } | undefined
95
+ const controller: MotionStates<Name> = {
96
+ get current() {
97
+ return current
98
+ },
99
+ get settled() {
100
+ return settled
101
+ },
102
+ get disposed() {
103
+ return disposed
104
+ },
105
+ get paused() {
106
+ return paused
107
+ },
108
+ to(name, transition) {
109
+ if (disposed) throw new Error('Motion states controller is disposed.')
110
+ const rows = states.get(name)
111
+ if (!rows) throw new TypeError(`Unknown motion state "${name}".`)
112
+ const timing = transition === undefined ? defaults : normalize(transition)
113
+ const prepared = rows.map(([view, frames]) =>
114
+ prepareAnimation(view, frames, timing),
115
+ )
116
+ const previous = active
117
+ const children: PlaybackAnimation[] = []
118
+ // Start replacements first: the engine transfers visible position AND velocity.
119
+ try {
120
+ for (const start of prepared) {
121
+ const child = start()
122
+ children.push(child)
123
+ if (paused) child.pause()
124
+ }
125
+ } catch (error) {
126
+ children.forEach((child) => child.stop())
127
+ previous?.handle.stop()
128
+ active = undefined
129
+ throw error
130
+ }
131
+ let cancelled = false
132
+ const finished = Promise.all(children.map((child) => child.finished)).then(
133
+ (results) => {
134
+ const completed =
135
+ !cancelled && !disposed && active?.handle === handle && results.every(Boolean)
136
+ if (active?.handle === handle) {
137
+ active = undefined
138
+ if (completed) settled = name
139
+ }
140
+ return completed
141
+ },
142
+ )
143
+ const handle: Animation = {
144
+ finished,
145
+ stop() {
146
+ cancelled = true
147
+ children.forEach((child) => child.stop())
148
+ },
149
+ then: (resolve, reject) => finished.then(resolve, reject),
150
+ }
151
+ active = { handle, children }
152
+ current = name
153
+ // Also cancels old, omitted properties, without touching the new owners.
154
+ previous?.handle.stop()
155
+ return handle
156
+ },
157
+ pause() {
158
+ if (disposed || paused) return
159
+ paused = true
160
+ active?.children.forEach((child) => child.pause())
161
+ },
162
+ play() {
163
+ if (disposed || !paused) return
164
+ paused = false
165
+ active?.children.forEach((child) => child.play())
166
+ },
167
+ stop() {
168
+ active?.handle.stop()
169
+ active = undefined
170
+ },
171
+ dispose() {
172
+ if (disposed) return
173
+ disposed = true
174
+ controller.stop()
175
+ },
176
+ }
177
+ return options.scope ? options.scope.add(controller) : controller
178
+ }
179
+
180
+ function normalize(transition: StateTransition | undefined): StateTransition {
181
+ if (transition === undefined) return 'snappy'
182
+ if (typeof transition === 'string') {
183
+ if (!['smooth', 'snappy', 'bouncy'].includes(transition))
184
+ throw new TypeError('Unknown state spring.')
185
+ return transition
186
+ }
187
+ if (!transition || typeof transition !== 'object')
188
+ throw new TypeError('Invalid state transition.')
189
+ const allowed = new Set([
190
+ 'type',
191
+ 'delay',
192
+ 'duration',
193
+ 'ease',
194
+ 'times',
195
+ 'stiffness',
196
+ 'damping',
197
+ 'mass',
198
+ 'bounce',
199
+ 'velocity',
200
+ ])
201
+ for (const key of Object.keys(transition))
202
+ if (!allowed.has(key))
203
+ throw new TypeError(`State transitions do not support "${key}".`)
204
+ if (
205
+ transition.duration !== undefined &&
206
+ (!Number.isFinite(transition.duration) || transition.duration < 0)
207
+ )
208
+ throw new RangeError('State transition duration must be finite and nonnegative.')
209
+ if (
210
+ transition.delay !== undefined &&
211
+ (!Number.isFinite(transition.delay) || transition.delay < 0)
212
+ )
213
+ throw new RangeError('State transition delay must be finite and nonnegative.')
214
+ return { ...transition }
215
+ }
package/src/press.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Color, type EventData, type TouchGestureEventData } from '@nativescript/core'
2
2
  import { isColorProperty } from './color.js'
3
3
  import { animate, startingValues, toView } from './animate.js'
4
+ import type { PlaybackAnimation } from './playback.js'
4
5
  import { readValue } from './properties.js'
5
6
  import type { SpringName, Transition } from './transition.js'
6
7
  import type { AnimateOptions, Keyframes, Target } from './types.js'
@@ -25,6 +26,7 @@ export function press(
25
26
  const view = toView(target)
26
27
  const properties = [...startingValues(pressed, options).keys()]
27
28
  let rest: Keyframes | undefined
29
+ let active: PlaybackAnimation | undefined
28
30
 
29
31
  const onTouch = (event: EventData) => {
30
32
  const { action } = event as TouchGestureEventData
@@ -35,9 +37,9 @@ export function press(
35
37
  isColorProperty(p) ? new Color(readValue(view, p)) : readValue(view, p),
36
38
  ]),
37
39
  )
38
- animate(view, pressed, options ?? pressIn)
40
+ active = animate(view, pressed, options ?? pressIn)
39
41
  } else if ((action === 'up' || action === 'cancel') && rest) {
40
- animate(
42
+ active = animate(
41
43
  view,
42
44
  rest,
43
45
  typeof options === 'object'
@@ -48,6 +50,16 @@ export function press(
48
50
  }
49
51
  }
50
52
 
53
+ const cancel = () => {
54
+ active?.stop()
55
+ active = undefined
56
+ rest = undefined
57
+ }
51
58
  view.on('touch', onTouch)
52
- return () => view.off('touch', onTouch)
59
+ view.on('unloaded', cancel)
60
+ return () => {
61
+ view.off('touch', onTouch)
62
+ view.off('unloaded', cancel)
63
+ cancel()
64
+ }
53
65
  }
package/src/track.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { clampDecoration, isDecorationProperty } from './decoration.js'
2
2
  import { linear } from './easing.js'
3
3
  import { solveDecay } from './decay.js'
4
+ import { solveDragRelease } from './drag-physics.js'
4
5
  import { precision, type Property } from './properties.js'
5
6
  import { solveSpring, springPhysics } from './spring.js'
6
7
  import type { Curve, ResolvedTransition } from './transition.js'
@@ -62,9 +63,11 @@ export function createTrack(
62
63
  const motion = slow(
63
64
  curve.type === 'spring'
64
65
  ? spring(property, keyframes, curve, velocity * slowdown, slowdown)
65
- : curve.type === 'decay'
66
- ? decay(property, keyframes[0]!, curve, slowdown)
67
- : tween(keyframes, curve, slowdown),
66
+ : curve.type === 'drag'
67
+ ? dragMotion(keyframes[0]!, curve, slowdown)
68
+ : curve.type === 'decay'
69
+ ? decay(property, keyframes[0]!, curve, slowdown)
70
+ : tween(keyframes, curve, slowdown),
68
71
  slowdown,
69
72
  )
70
73
  const { duration } = motion
@@ -94,7 +97,9 @@ export function createTrack(
94
97
  to: !finite || endsBackwards ? first : last,
95
98
  end,
96
99
  arrival:
97
- (curve.type === 'spring' || (curve.type === 'decay' && curve.snap !== undefined)) &&
100
+ (curve.type === 'spring' ||
101
+ curve.type === 'drag' ||
102
+ (curve.type === 'decay' && curve.snap !== undefined)) &&
98
103
  repeat === 0
99
104
  ? delay + arrival(motion)
100
105
  : end,
@@ -142,6 +147,7 @@ function restingValue(
142
147
  ) {
143
148
  const first = keyframes[0]!
144
149
  if (!Number.isFinite(repeat) || (reverse && repeat % 2 === 1)) return first
150
+ if (curve.type === 'drag') return solveDragRelease(first, curve).to
145
151
  if (curve.type !== 'decay') return keyframes.at(-1)!
146
152
  const landing = solveDecay(
147
153
  first,
@@ -232,6 +238,21 @@ function samples(duration: number, density: number) {
232
238
  )
233
239
  }
234
240
 
241
+ function dragMotion(
242
+ from: number,
243
+ curve: Extract<Curve, { type: 'drag' }>,
244
+ density: number,
245
+ ): Motion {
246
+ const motion = solveDragRelease(from, curve)
247
+ const steps = samples(motion.duration, density)
248
+ const times = Array.from({ length: steps + 1 }, (_, i) => i / steps)
249
+ return {
250
+ ...motion,
251
+ times,
252
+ values: times.map((time) => motion.position(time * motion.duration)),
253
+ }
254
+ }
255
+
235
256
  function decay(
236
257
  property: Property,
237
258
  from: number,
package/src/transition.ts CHANGED
@@ -3,6 +3,7 @@ import type { Property } from './properties.js'
3
3
  import { isDecorationProperty } from './decoration.js'
4
4
  import { isColorProperty } from './color.js'
5
5
  import { springPhysics, type SpringPhysics } from './spring.js'
6
+ import type { DragCurve } from './drag-physics.js'
6
7
 
7
8
  export interface Timing {
8
9
  /** Milliseconds to wait before starting. The first keyframe is applied right away. */
@@ -47,6 +48,7 @@ export const springs: Record<SpringName, Spring> = {
47
48
  }
48
49
 
49
50
  export type Curve =
51
+ | DragCurve
50
52
  | {
51
53
  type: 'tween'
52
54
  duration: number
package/src/vue/index.ts CHANGED
@@ -2,6 +2,9 @@ import {
2
2
  BaseTransition,
3
3
  defineComponent,
4
4
  h,
5
+ onUnmounted,
6
+ onActivated,
7
+ onDeactivated,
5
8
  type App,
6
9
  type BaseTransitionProps,
7
10
  type NSVElement,
@@ -9,8 +12,9 @@ import {
9
12
  type PropType,
10
13
  } from 'nativescript-vue'
11
14
  import {
12
- animate,
13
15
  press,
16
+ motionScope,
17
+ type MotionScope,
14
18
  type AnimateOptions,
15
19
  type Keyframes,
16
20
  type PlaybackAnimation,
@@ -39,6 +43,7 @@ export interface TransitionProps {
39
43
  }
40
44
 
41
45
  const animations = new WeakMap<object, PlaybackAnimation>()
46
+ const scopes = new WeakMap<object, MotionScope>()
42
47
  const removers = new WeakMap<object, () => void>()
43
48
 
44
49
  function split<T>(
@@ -65,13 +70,16 @@ function same(a: unknown, b: unknown): boolean {
65
70
 
66
71
  function play(el: Host, value: AnimateValue) {
67
72
  const [animation, options] = split<PresetName | Keyframes>(value)
68
- const handle = animate(el, animation, options)
73
+ let scope = scopes.get(el)
74
+ if (!scope) scopes.set(el, (scope = motionScope()))
75
+ const handle = scope.animate(el, animation, options)
69
76
  animations.set(el, handle)
70
77
  return handle
71
78
  }
72
79
 
73
80
  function cancel(el: Host) {
74
- animations.get(el)?.stop()
81
+ scopes.get(el)?.dispose()
82
+ scopes.delete(el)
75
83
  animations.delete(el)
76
84
  }
77
85
 
@@ -187,3 +195,12 @@ export function install(app: App) {
187
195
  }
188
196
 
189
197
  export default { install }
198
+
199
+ /** Call in setup(): suspend cached components and dispose only on actual unmount. */
200
+ export function useMotionScope(): MotionScope {
201
+ const scope = motionScope()
202
+ onUnmounted(() => scope.dispose())
203
+ onDeactivated(() => scope.suspend())
204
+ onActivated(() => scope.resume())
205
+ return scope
206
+ }