@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,265 @@
1
+ import { animate, decay } from './animate.js'
2
+ import { press } from './press.js'
3
+ import { drag } from './drag.js'
4
+ import { sequence } from './sequence.js'
5
+ import {
6
+ motionStates,
7
+ type MotionState,
8
+ type MotionDefinitions,
9
+ type MotionStates,
10
+ type MotionStatesOptions,
11
+ } from './motion-states.js'
12
+ import type { Target } from './types.js'
13
+
14
+ /** A cleanup function or a handle owned exclusively by one scope. */
15
+ export type MotionResource = (() => void) | { stop(): void } | { dispose(): void }
16
+ type Controlled = {
17
+ pause?: () => void
18
+ play?: () => void
19
+ readonly paused?: boolean
20
+ readonly finished?: Promise<unknown>
21
+ }
22
+ interface Entry {
23
+ resource?: MotionResource
24
+ setup?: () => MotionResource
25
+ resume: boolean
26
+ }
27
+ const owners = new WeakMap<object, object>()
28
+
29
+ export interface MotionScope {
30
+ readonly disposed: boolean
31
+ readonly suspended: boolean
32
+ /** Owned handles/subscriptions, including suspended subscription factories. */
33
+ readonly size: number
34
+ animate: typeof animate
35
+ decay: typeof decay
36
+ sequence: typeof sequence
37
+ press: typeof press
38
+ drag: typeof drag
39
+ states<
40
+ T extends Record<string, Target>,
41
+ const S extends Record<string, MotionState<keyof T>>,
42
+ >(
43
+ targets: T,
44
+ definitions: MotionDefinitions<T, S>,
45
+ options?: Omit<MotionStatesOptions, 'scope'>,
46
+ ): MotionStates<Extract<keyof S, string>>
47
+ /** Adopt an existing handle. Completed animations are released automatically. */
48
+ add<T extends MotionResource>(resource: T): T
49
+ /** Attach a subscription, detach on suspension, and recreate on resume. */
50
+ use(setup: () => MotionResource): () => void
51
+ /** Pause playable handles; cancel non-playable ones; detach subscriptions. */
52
+ suspend(): void
53
+ resume(): void
54
+ /** Cancel all owned work without destroying the scope, e.g. when recycling a cell. */
55
+ reset(): void
56
+ dispose(): void
57
+ }
58
+
59
+ /** Framework-independent ownership. Native unload never permanently destroys a scope. */
60
+ export function motionScope(): MotionScope {
61
+ const entries = new Set<Entry>()
62
+ const adopted = new Map<MotionResource, Entry>()
63
+ const identity = {}
64
+ let disposed = false
65
+ let suspended = false
66
+ let clearing = false
67
+ const assertAlive = () => {
68
+ if (disposed) throw new Error('Motion scope is disposed.')
69
+ if (clearing) throw new Error('Motion scope is resetting.')
70
+ }
71
+ const assertActive = () => {
72
+ assertAlive()
73
+ if (suspended)
74
+ throw new Error('Motion scope is suspended; resume it before starting work.')
75
+ }
76
+ const validate = (resource: MotionResource) => {
77
+ if (
78
+ typeof resource !== 'function' &&
79
+ (!resource ||
80
+ (typeof (resource as { dispose?: unknown }).dispose !== 'function' &&
81
+ typeof (resource as { stop?: unknown }).stop !== 'function'))
82
+ )
83
+ throw new TypeError(
84
+ 'A motion resource must be a cleanup function or expose stop()/dispose().',
85
+ )
86
+ const finished = (resource as Controlled).finished
87
+ if (finished && typeof finished.then !== 'function')
88
+ throw new TypeError('Resource finished must be a promise.')
89
+ if (owners.has(resource) && owners.get(resource) !== identity)
90
+ throw new Error('Motion resource already belongs to another scope.')
91
+ }
92
+ const forget = (entry: Entry) => {
93
+ const resource = entry.resource
94
+ if (resource) {
95
+ owners.delete(resource)
96
+ adopted.delete(resource)
97
+ entry.resource = undefined
98
+ }
99
+ entry.resume = false
100
+ }
101
+ const disposeResource = (resource: MotionResource) => {
102
+ if (typeof resource === 'function') resource()
103
+ else if (typeof (resource as { dispose?: unknown }).dispose === 'function')
104
+ (resource as { dispose(): void }).dispose()
105
+ else (resource as { stop(): void }).stop()
106
+ }
107
+ const release = (entry: Entry) => {
108
+ const resource = entry.resource
109
+ forget(entry)
110
+ if (!resource) return
111
+ disposeResource(resource)
112
+ }
113
+ const remove = (entry: Entry) => {
114
+ entries.delete(entry)
115
+ release(entry)
116
+ }
117
+ const attach = (entry: Entry, resource: MotionResource) => {
118
+ validate(resource)
119
+ if (adopted.has(resource))
120
+ throw new Error('Subscription factory returned an already owned resource.')
121
+ entry.resource = resource
122
+ adopted.set(resource, entry)
123
+ owners.set(resource, identity)
124
+ const finished = (resource as Controlled).finished
125
+ if (!entry.setup && finished) {
126
+ const settled = () => {
127
+ if (entry.resource !== resource) return
128
+ forget(entry)
129
+ entries.delete(entry)
130
+ }
131
+ void finished.then(settled, settled)
132
+ }
133
+ }
134
+ const startSubscription = (entry: Entry) => {
135
+ const resource = entry.setup!()
136
+ validate(resource)
137
+ // Setup may synchronously navigate away, suspend or remove its own scope.
138
+ if (disposed || suspended || !entries.has(entry)) {
139
+ if (!owners.has(resource)) disposeResource(resource)
140
+ return
141
+ }
142
+ attach(entry, resource)
143
+ }
144
+ // One bad cleanup must not strand the other subscriptions.
145
+ const visit = (action: (entry: Entry) => void) => {
146
+ const errors: unknown[] = []
147
+ for (const entry of [...entries]) {
148
+ if (!entries.has(entry)) continue
149
+ try {
150
+ action(entry)
151
+ } catch (error) {
152
+ errors.push(error)
153
+ }
154
+ }
155
+ if (errors.length)
156
+ throw new AggregateError(errors, 'Motion scope cleanup/lifecycle failed.')
157
+ }
158
+ const startOwned = <T extends MotionResource>(factory: () => T): T => {
159
+ assertActive()
160
+ const resource = factory()
161
+ try {
162
+ return scope.add(resource)
163
+ } catch (error) {
164
+ if (!owners.has(resource)) disposeResource(resource)
165
+ throw error
166
+ }
167
+ }
168
+ const scope: MotionScope = {
169
+ get disposed() {
170
+ return disposed
171
+ },
172
+ get suspended() {
173
+ return suspended
174
+ },
175
+ get size() {
176
+ return entries.size
177
+ },
178
+ animate(...args) {
179
+ return startOwned(() => animate(...args))
180
+ },
181
+ decay(...args) {
182
+ return startOwned(() => decay(...args))
183
+ },
184
+ sequence(...args) {
185
+ return startOwned(() => sequence(...args))
186
+ },
187
+ press(...args) {
188
+ return scope.use(() => press(...args))
189
+ },
190
+ drag(...args) {
191
+ return startOwned(() => drag(...args))
192
+ },
193
+ states(targets, definitions, options) {
194
+ return startOwned(() => motionStates(targets, definitions, options))
195
+ },
196
+ add(resource) {
197
+ assertActive()
198
+ validate(resource)
199
+ if (adopted.has(resource)) return resource
200
+ const entry: Entry = { resume: false }
201
+ attach(entry, resource)
202
+ entries.add(entry)
203
+ return resource
204
+ },
205
+ use(setup) {
206
+ assertAlive()
207
+ if (typeof setup !== 'function')
208
+ throw new TypeError('Motion subscription needs a factory.')
209
+ const entry: Entry = { setup, resume: false }
210
+ entries.add(entry)
211
+ try {
212
+ if (!suspended) startSubscription(entry)
213
+ } catch (error) {
214
+ remove(entry)
215
+ throw error
216
+ }
217
+ return () => remove(entry)
218
+ },
219
+ suspend() {
220
+ if (disposed || suspended) return
221
+ suspended = true
222
+ visit((entry) => {
223
+ if (entry.setup) {
224
+ release(entry)
225
+ return
226
+ }
227
+ const resource = entry.resource as Controlled | undefined
228
+ if (
229
+ typeof resource?.pause === 'function' &&
230
+ typeof resource.play === 'function'
231
+ ) {
232
+ entry.resume = resource.paused !== true
233
+ if (entry.resume) resource.pause()
234
+ } else remove(entry)
235
+ })
236
+ },
237
+ resume() {
238
+ if (disposed || !suspended) return
239
+ suspended = false
240
+ visit((entry) => {
241
+ if (disposed || suspended) return
242
+ if (entry.setup) startSubscription(entry)
243
+ else if (entry.resume) {
244
+ entry.resume = false
245
+ ;(entry.resource as Controlled).play?.()
246
+ }
247
+ })
248
+ },
249
+ reset() {
250
+ if (disposed || clearing) return
251
+ clearing = true
252
+ try {
253
+ visit(remove)
254
+ } finally {
255
+ clearing = false
256
+ }
257
+ },
258
+ dispose() {
259
+ if (disposed) return
260
+ disposed = true
261
+ visit(remove)
262
+ },
263
+ }
264
+ return scope
265
+ }
@@ -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
+ }