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

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 (184) hide show
  1. package/README.md +122 -5
  2. package/dist/animate.d.ts +4 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +48 -11
  5. package/dist/animate.js.map +1 -1
  6. package/dist/config.d.ts +22 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +34 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/driver/android.d.ts.map +1 -1
  11. package/dist/driver/android.js +7 -0
  12. package/dist/driver/android.js.map +1 -1
  13. package/dist/driver/index.d.ts +2 -0
  14. package/dist/driver/index.d.ts.map +1 -1
  15. package/dist/driver/index.js.map +1 -1
  16. package/dist/driver/ios-color.js +1 -1
  17. package/dist/driver/ios-color.js.map +1 -1
  18. package/dist/driver/ios-decoration.js +1 -1
  19. package/dist/driver/ios-decoration.js.map +1 -1
  20. package/dist/driver/ios-playback.d.ts +1 -1
  21. package/dist/driver/ios-playback.d.ts.map +1 -1
  22. package/dist/driver/ios-playback.js +14 -0
  23. package/dist/driver/ios-playback.js.map +1 -1
  24. package/dist/driver/ios.d.ts.map +1 -1
  25. package/dist/driver/ios.js +1 -0
  26. package/dist/driver/ios.js.map +1 -1
  27. package/dist/engine.d.ts +16 -1
  28. package/dist/engine.d.ts.map +1 -1
  29. package/dist/engine.js +33 -1
  30. package/dist/engine.js.map +1 -1
  31. package/dist/image-effect-native.d.ts +26 -0
  32. package/dist/image-effect-native.d.ts.map +1 -0
  33. package/dist/image-effect-native.js +73 -0
  34. package/dist/image-effect-native.js.map +1 -0
  35. package/dist/image-effect.d.ts +50 -0
  36. package/dist/image-effect.d.ts.map +1 -0
  37. package/dist/image-effect.js +290 -0
  38. package/dist/image-effect.js.map +1 -0
  39. package/dist/image-owner.d.ts +3 -0
  40. package/dist/image-owner.d.ts.map +1 -0
  41. package/dist/image-owner.js +11 -0
  42. package/dist/image-owner.js.map +1 -0
  43. package/dist/index.d.ts +15 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +7 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/items.d.ts +20 -0
  48. package/dist/items.d.ts.map +1 -0
  49. package/dist/items.js +109 -0
  50. package/dist/items.js.map +1 -0
  51. package/dist/keyboard.d.ts +17 -0
  52. package/dist/keyboard.d.ts.map +1 -0
  53. package/dist/keyboard.js +116 -0
  54. package/dist/keyboard.js.map +1 -0
  55. package/dist/label.d.ts +7 -0
  56. package/dist/label.d.ts.map +1 -0
  57. package/dist/label.js +60 -0
  58. package/dist/label.js.map +1 -0
  59. package/dist/liquid.d.ts.map +1 -1
  60. package/dist/liquid.js +6 -1
  61. package/dist/liquid.js.map +1 -1
  62. package/dist/loader.d.ts.map +1 -1
  63. package/dist/loader.js +2 -1
  64. package/dist/loader.js.map +1 -1
  65. package/dist/particles.d.ts.map +1 -1
  66. package/dist/particles.js +4 -3
  67. package/dist/particles.js.map +1 -1
  68. package/dist/path.d.ts +12 -0
  69. package/dist/path.d.ts.map +1 -0
  70. package/dist/path.js +90 -0
  71. package/dist/path.js.map +1 -0
  72. package/dist/playback-group.d.ts.map +1 -1
  73. package/dist/playback-group.js +29 -1
  74. package/dist/playback-group.js.map +1 -1
  75. package/dist/press.d.ts.map +1 -1
  76. package/dist/press.js +3 -3
  77. package/dist/press.js.map +1 -1
  78. package/dist/reveal.d.ts.map +1 -1
  79. package/dist/reveal.js +3 -2
  80. package/dist/reveal.js.map +1 -1
  81. package/dist/roll-diff.d.ts +22 -0
  82. package/dist/roll-diff.d.ts.map +1 -0
  83. package/dist/roll-diff.js +36 -0
  84. package/dist/roll-diff.js.map +1 -0
  85. package/dist/roll.d.ts +29 -0
  86. package/dist/roll.d.ts.map +1 -0
  87. package/dist/roll.js +442 -0
  88. package/dist/roll.js.map +1 -0
  89. package/dist/scroll-linked.d.ts +10 -0
  90. package/dist/scroll-linked.d.ts.map +1 -0
  91. package/dist/scroll-linked.js +58 -0
  92. package/dist/scroll-linked.js.map +1 -0
  93. package/dist/sequence.d.ts +10 -5
  94. package/dist/sequence.d.ts.map +1 -1
  95. package/dist/sequence.js +202 -14
  96. package/dist/sequence.js.map +1 -1
  97. package/dist/shimmer.d.ts.map +1 -1
  98. package/dist/shimmer.js +2 -1
  99. package/dist/shimmer.js.map +1 -1
  100. package/dist/svg-path.d.ts +14 -0
  101. package/dist/svg-path.d.ts.map +1 -0
  102. package/dist/svg-path.js +281 -0
  103. package/dist/svg-path.js.map +1 -0
  104. package/dist/symbol-effect.d.ts.map +1 -1
  105. package/dist/symbol-effect.js +4 -3
  106. package/dist/symbol-effect.js.map +1 -1
  107. package/dist/text-layout.d.ts +8 -0
  108. package/dist/text-layout.d.ts.map +1 -1
  109. package/dist/text-layout.js +8 -2
  110. package/dist/text-layout.js.map +1 -1
  111. package/dist/text.d.ts +1 -1
  112. package/dist/text.d.ts.map +1 -1
  113. package/dist/text.js +60 -64
  114. package/dist/text.js.map +1 -1
  115. package/dist/track.d.ts +2 -0
  116. package/dist/track.d.ts.map +1 -1
  117. package/dist/track.js +62 -15
  118. package/dist/track.js.map +1 -1
  119. package/dist/types.d.ts +7 -1
  120. package/dist/types.d.ts.map +1 -1
  121. package/dist/vue/index.d.ts +89 -0
  122. package/dist/vue/index.d.ts.map +1 -0
  123. package/dist/vue/index.js +134 -0
  124. package/dist/vue/index.js.map +1 -0
  125. package/dist/zoom-native.d.ts +30 -0
  126. package/dist/zoom-native.d.ts.map +1 -0
  127. package/dist/zoom-native.js +56 -0
  128. package/dist/zoom-native.js.map +1 -0
  129. package/dist/zoomable.d.ts +48 -0
  130. package/dist/zoomable.d.ts.map +1 -0
  131. package/dist/zoomable.js +249 -0
  132. package/dist/zoomable.js.map +1 -0
  133. package/package.json +16 -3
  134. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  135. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  136. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  137. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  138. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  139. package/platforms/android/proguard-rules.pro +10 -0
  140. package/platforms/ios/build.xcconfig +1 -0
  141. package/platforms/ios/src/JoltImageEffect.h +25 -0
  142. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  143. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  144. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  145. package/platforms/ios/src/module.modulemap +5 -0
  146. package/src/android-plugin.d.ts +142 -0
  147. package/src/animate.ts +80 -30
  148. package/src/config.ts +46 -0
  149. package/src/driver/android.ts +14 -0
  150. package/src/driver/index.ts +2 -0
  151. package/src/driver/ios-color.ts +1 -1
  152. package/src/driver/ios-decoration.ts +1 -1
  153. package/src/driver/ios-playback.ts +15 -1
  154. package/src/driver/ios.ts +2 -0
  155. package/src/engine.ts +47 -2
  156. package/src/image-effect-native.ts +129 -0
  157. package/src/image-effect.ts +336 -0
  158. package/src/image-owner.ts +12 -0
  159. package/src/index.ts +20 -1
  160. package/src/ios-plugin.d.ts +59 -0
  161. package/src/items.ts +140 -0
  162. package/src/keyboard.ts +132 -0
  163. package/src/label.ts +76 -0
  164. package/src/liquid.ts +6 -1
  165. package/src/loader.ts +2 -1
  166. package/src/particles.ts +4 -4
  167. package/src/path.ts +124 -0
  168. package/src/playback-group.ts +34 -1
  169. package/src/press.ts +3 -5
  170. package/src/reveal.ts +3 -3
  171. package/src/roll-diff.ts +57 -0
  172. package/src/roll.ts +515 -0
  173. package/src/scroll-linked.ts +73 -0
  174. package/src/sequence.ts +199 -14
  175. package/src/shimmer.ts +2 -1
  176. package/src/svg-path.ts +355 -0
  177. package/src/symbol-effect.ts +4 -3
  178. package/src/text-layout.ts +14 -2
  179. package/src/text.ts +62 -81
  180. package/src/track.ts +89 -24
  181. package/src/types.ts +8 -1
  182. package/src/vue/index.ts +189 -0
  183. package/src/zoom-native.ts +124 -0
  184. package/src/zoomable.ts +330 -0
@@ -234,13 +234,13 @@ export function playIOSDecoration(
234
234
  animation.values = NSArray.arrayWithArray(values)
235
235
  animation.keyTimes = NSArray.arrayWithArray(times)
236
236
  animation.duration = Math.max(1, track.duration) / 1000
237
- timing.configure(animation, layer)
238
237
  if (track.repeat > 0) {
239
238
  animation.autoreverses = track.reverse
240
239
  animation.repeatCount = track.reverse
241
240
  ? (track.repeat + 1) / 2
242
241
  : track.repeat + 1
243
242
  }
243
+ timing.configure(animation, layer)
244
244
  if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
245
245
  const max = Math.min(
246
246
  120,
@@ -21,7 +21,7 @@ export function iosPlayback(track: Track, startedAt: number) {
21
21
  anchor = CACurrentMediaTime() * 1000
22
22
  paused = hold
23
23
  },
24
- configure(animation: CAAnimation, layer: CALayer) {
24
+ configure(animation: CAKeyframeAnimation, layer: CALayer) {
25
25
  const now = CACurrentMediaTime()
26
26
  const position = elapsed()
27
27
  animation.speed = paused ? 0 : 1
@@ -32,6 +32,20 @@ export function iosPlayback(track: Track, startedAt: number) {
32
32
  )
33
33
  animation.fillMode = paused ? 'both' : kCAFillModeBackwards
34
34
  animation.removedOnCompletion = !paused && track.repeat !== Infinity
35
+ // With speed 0, CA folds an exact duration boundary back to the first
36
+ // frame. Hold the actual endpoint with a constant copy, also when a
37
+ // shorter property waits at its end while another one is being scrubbed.
38
+ if (paused && Number.isFinite(track.end) && position >= track.end) {
39
+ const values = animation.values!
40
+ const index = track.reverse && track.repeat % 2 === 1 ? 0 : values.count - 1
41
+ const value = values.objectAtIndex(index)
42
+ animation.values = NSArray.arrayWithArray([value, value])
43
+ animation.keyTimes = NSArray.arrayWithArray([0, 1])
44
+ animation.duration = 1
45
+ animation.repeatCount = 0
46
+ animation.autoreverses = false
47
+ animation.timeOffset = 0
48
+ }
35
49
  },
36
50
  }
37
51
  }
package/src/driver/ios.ts CHANGED
@@ -153,5 +153,7 @@ export function createIOSDriver(): Driver {
153
153
  : undefined,
154
154
 
155
155
  after,
156
+
157
+ reducedMotion: () => UIAccessibilityIsReduceMotionEnabled(),
156
158
  }
157
159
  }
package/src/engine.ts CHANGED
@@ -12,12 +12,13 @@ import { readValue, writeValue, type Property } from './properties.js'
12
12
  import { createTrack, type Track } from './track.js'
13
13
  import type { ResolvedTransition } from './transition.js'
14
14
  import { isColorProperty } from './color.js'
15
+ import type { PreparedPath, PathProperty } from './path.js'
15
16
 
16
17
  export interface Animation extends PromiseLike<boolean> {
17
18
  /**
18
19
  * Resolves with `true` once the animation played to the end, or with `false`
19
20
  * if it was stopped, superseded or skipped.
20
- * Never rejects.
21
+ * Playback cancellation never rejects; sequences can reject setup/callback errors.
21
22
  */
22
23
  readonly finished: Promise<boolean>
23
24
  /** Stops playback. Each effect documents whether it preserves or restores its visual state. */
@@ -30,6 +31,7 @@ export interface Plan {
30
31
  keyframes: readonly (number | null)[]
31
32
  transition: ResolvedTransition
32
33
  relative?: boolean
34
+ path?: PreparedPath
33
35
  }
34
36
 
35
37
  interface Entry {
@@ -49,6 +51,21 @@ const revisions = new WeakMap<View, Map<Property, symbol>>()
49
51
  const running = new WeakMap<View, Map<Property, Entry>>()
50
52
  const arrivals = new WeakMap<Animation, Promise<unknown>>()
51
53
 
54
+ /** Internal forecast for starting a timeline step before another one arrives. */
55
+ export interface ArrivalTiming {
56
+ read(): { remaining: number | undefined; paused: boolean; arrived: boolean }
57
+ subscribe(callback: () => void): () => void
58
+ }
59
+ const arrivalTimings = new WeakMap<Animation, ArrivalTiming>()
60
+
61
+ export function arrivalTimingOf(animation: Animation) {
62
+ return arrivalTimings.get(animation)
63
+ }
64
+
65
+ export function setArrivalTiming(animation: Animation, timing: ArrivalTiming) {
66
+ arrivalTimings.set(animation, timing)
67
+ }
68
+
52
69
  /**
53
70
  * Resolves once the animation looks done, which for a spring is well before
54
71
  * `finished`. Sequences move on to the next step then.
@@ -83,6 +100,10 @@ export class ViewAnimation implements PlaybackAnimation {
83
100
  private idleListener: (() => void) | undefined
84
101
  private readyListener: (() => void) | undefined
85
102
  private readonly endless: boolean
103
+ private readonly timingListeners = new Set<() => void>()
104
+ private timingChanged() {
105
+ for (const callback of [...this.timingListeners]) callback()
106
+ }
86
107
 
87
108
  get paused() {
88
109
  return this._paused
@@ -136,6 +157,7 @@ export class ViewAnimation implements PlaybackAnimation {
136
157
  this.cancelArrival?.()
137
158
  this.haptics?.pause()
138
159
  if (this.started) this.controlTracks(this.time(), 'pause')
160
+ this.timingChanged()
139
161
  }
140
162
  play() {
141
163
  if (this.settled || !this._paused) return
@@ -148,6 +170,7 @@ export class ViewAnimation implements PlaybackAnimation {
148
170
  this.haptics?.play()
149
171
  if (this.pending === 0) this.becameIdle()
150
172
  }
173
+ this.timingChanged()
151
174
  }
152
175
  seekTime(time: number) {
153
176
  if (this.settled) return
@@ -173,6 +196,7 @@ export class ViewAnimation implements PlaybackAnimation {
173
196
  this.scheduleArrival()
174
197
  this.haptics?.seek()
175
198
  if (this.pending === 0) this.becameIdle()
199
+ this.timingChanged()
176
200
  }
177
201
  elapsed(entry: Entry, now: number) {
178
202
  return this.controlled
@@ -203,10 +227,12 @@ export class ViewAnimation implements PlaybackAnimation {
203
227
  if (remaining <= 0) {
204
228
  this.arrived = true
205
229
  this.arrive()
230
+ this.timingChanged()
206
231
  } else
207
232
  this.cancelArrival = getDriver().after(remaining, () => {
208
233
  this.arrived = true
209
234
  this.arrive()
235
+ this.timingChanged()
210
236
  })
211
237
  }
212
238
  private becameIdle() {
@@ -248,6 +274,17 @@ export class ViewAnimation implements PlaybackAnimation {
248
274
  )
249
275
  this.finished = new Promise((resolve) => (this.resolve = resolve))
250
276
  setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
277
+ setArrivalTiming(this, {
278
+ read: () => ({
279
+ remaining: this.started ? Math.max(0, this.arrival - this.time()) : undefined,
280
+ paused: this._paused,
281
+ arrived: this.arrived || this.settled,
282
+ }),
283
+ subscribe: (callback) => {
284
+ this.timingListeners.add(callback)
285
+ return () => this.timingListeners.delete(callback)
286
+ },
287
+ })
251
288
  if (view.isLoaded && view.nativeViewProtected) this.start(plans)
252
289
  else this.waitForLoad(plans)
253
290
  }
@@ -285,7 +322,7 @@ export class ViewAnimation implements PlaybackAnimation {
285
322
  const now = driver.now()
286
323
  const entries = entriesOf(this.view)
287
324
 
288
- const tracks = plans.map(({ property, keyframes, transition, relative }) => {
325
+ const tracks = plans.map(({ property, keyframes, transition, relative, path }) => {
289
326
  const previous = entries.get(property)
290
327
  let current: number
291
328
  let velocity = 0
@@ -300,6 +337,12 @@ export class ViewAnimation implements PlaybackAnimation {
300
337
  ? driver.presented(this.view, property)
301
338
  : undefined) ?? readValue(this.view, property)
302
339
  }
340
+ if (path)
341
+ return path.track(
342
+ property as PathProperty,
343
+ relative ? current : 0,
344
+ transition.delay,
345
+ )
303
346
  return createTrack(
304
347
  property,
305
348
  keyframes.map((value) => {
@@ -357,6 +400,7 @@ export class ViewAnimation implements PlaybackAnimation {
357
400
  } else if (this._paused) this.controlTracks(0, 'pause')
358
401
  this.scheduleArrival()
359
402
  this.readyListener?.()
403
+ this.timingChanged()
360
404
  if (this.pending === 0) this.becameIdle()
361
405
  }
362
406
 
@@ -388,6 +432,7 @@ export class ViewAnimation implements PlaybackAnimation {
388
432
  this.haptics = undefined
389
433
  this.arrive()
390
434
  this.resolve(this.complete)
435
+ this.timingChanged()
391
436
  }
392
437
  }
393
438
 
@@ -0,0 +1,129 @@
1
+ import { Utils, isAndroid, isIOS, type Image, type ImageSource } from '@nativescript/core'
2
+
3
+ export interface ImageEffectSettings {
4
+ mode: number
5
+ x: number
6
+ y: number
7
+ amplitude: number
8
+ radius: number
9
+ duration: number
10
+ seed: number
11
+ to?: ImageSource
12
+ }
13
+
14
+ export interface ImageRenderer {
15
+ readonly backend: 'metal' | 'agsl'
16
+ readonly progress: number
17
+ readonly frames: number
18
+ readonly textureBytes: number
19
+ readonly longestFrameMs: number
20
+ play(endpoint: number): void
21
+ pause(): void
22
+ resume(): void
23
+ seek(progress: number): void
24
+ finish(endpoint: number): void
25
+ stop(preserveImage?: boolean): void
26
+ }
27
+
28
+ export function createImageRenderer(
29
+ image: Image,
30
+ settings: ImageEffectSettings,
31
+ ended: (completed: boolean, progress: number) => void,
32
+ ): ImageRenderer | undefined {
33
+ const { mode, x, y, amplitude, radius, duration, seed, to } = settings
34
+ if (isIOS && typeof JoltImageEffect !== 'undefined') {
35
+ const native =
36
+ JoltImageEffect.createWithViewDestinationModeOriginXOriginYAmplitudeRadiusDurationSeedListener(
37
+ image.nativeViewProtected,
38
+ to?.ios ?? null,
39
+ mode,
40
+ x,
41
+ y,
42
+ amplitude,
43
+ radius,
44
+ duration,
45
+ seed,
46
+ ended,
47
+ )
48
+ if (!native) return undefined
49
+ return {
50
+ backend: 'metal',
51
+ get progress() {
52
+ return native.progress
53
+ },
54
+ get frames() {
55
+ return native.frames
56
+ },
57
+ get textureBytes() {
58
+ return native.textureBytes
59
+ },
60
+ get longestFrameMs() {
61
+ return native.longestFrame * 1000
62
+ },
63
+ play: (p) => native.play(p),
64
+ pause: () => native.pause(),
65
+ resume: () => native.resume(),
66
+ seek: (p) => native.seek(p),
67
+ finish: (p) => native.finish(p),
68
+ stop: (preserveImage) =>
69
+ preserveImage ? native.stopPreservingImage() : native.stop(),
70
+ }
71
+ }
72
+ if (isAndroid && android.os.Build.VERSION.SDK_INT >= 33) {
73
+ const density = Utils.layout.getDisplayDensity()
74
+ const background = image.nativeViewProtected.getBackground()
75
+ // NativeScript draws bitmap pixels itself: ImageView.getImageMatrix() does
76
+ // not describe its stretch, EXIF rotation or inner border clipping.
77
+ const geometry = Array.create('float', 6)
78
+ geometry[0] = image.imageSource?.rotationAngle ?? 0
79
+ geometry[1] = to?.rotationAngle ?? 0
80
+ const corners = ['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft']
81
+ corners.forEach((corner, i) => {
82
+ geometry[i + 2] = background?.[`getBorder${corner}Radius`]?.() ?? 0
83
+ })
84
+ const native = new org.rafaelaugusto.jolt.ImageEffect(
85
+ image.nativeViewProtected,
86
+ image.imageSource?.android ?? null,
87
+ to?.android ?? null,
88
+ geometry,
89
+ mode,
90
+ x * density,
91
+ y * density,
92
+ amplitude * density,
93
+ radius * density,
94
+ duration,
95
+ seed,
96
+ (image as Image & { touchListener?: android.view.View.OnTouchListener })
97
+ .touchListener ?? null,
98
+ new org.rafaelaugusto.jolt.ImageEffect.Listener({ onEnd: ended }),
99
+ )
100
+ if (!native.isSupported()) {
101
+ const reason = native.getFailureReason()
102
+ native.stop()
103
+ throw new Error(reason)
104
+ }
105
+ return {
106
+ backend: 'agsl',
107
+ get progress() {
108
+ return native.getProgress()
109
+ },
110
+ get frames() {
111
+ return native.getFrames()
112
+ },
113
+ get textureBytes() {
114
+ return native.getTextureBytes()
115
+ },
116
+ get longestFrameMs() {
117
+ return native.getLongestFrameNanos() / 1e6
118
+ },
119
+ play: (p) => native.play(p),
120
+ pause: () => native.pause(),
121
+ resume: () => native.resume(),
122
+ seek: (p) => native.seek(p),
123
+ finish: (p) => native.finish(p),
124
+ stop: (preserveImage) =>
125
+ preserveImage ? native.stopPreservingImage() : native.stop(),
126
+ }
127
+ }
128
+ return undefined
129
+ }
@@ -0,0 +1,336 @@
1
+ import {
2
+ Application,
3
+ Image,
4
+ ImageSource,
5
+ isIOS,
6
+ type EventData,
7
+ } from '@nativescript/core'
8
+ import { toView } from './animate.js'
9
+ import { reducedMotion, timeScale } from './config.js'
10
+ import type { Animation } from './engine.js'
11
+ import { ownImage } from './image-owner.js'
12
+ import { createImageRenderer, type ImageRenderer } from './image-effect-native.js'
13
+ import type { Target } from './types.js'
14
+
15
+ export type ImageEffectName = 'ripple' | 'distortion' | 'dissolve'
16
+ export interface ImageEffectOptions {
17
+ /** Origin in target-local DIPs; defaults to the center. */
18
+ origin?: { x: number; y: number }
19
+ /** Maximum displacement in DIPs, 0..64. Defaults to 8. */
20
+ amplitude?: number
21
+ /** Influence radius in DIPs, greater than zero. Defaults to 96. */
22
+ radius?: number
23
+ /** Milliseconds for a finite effect (or drag release). Defaults to 650. */
24
+ duration?: number
25
+ /** Deterministic dissolve noise seed, 0..65535. Defaults to 1. */
26
+ seed?: number
27
+ /** Loaded destination raster for dissolve. */
28
+ to?: ImageSource
29
+ /** Prepare at progress zero without starting. */
30
+ paused?: boolean
31
+ }
32
+ export interface ImageEffectDiagnostics {
33
+ backend: 'waiting' | 'metal' | 'agsl' | 'fallback'
34
+ reason?: 'reduced-motion' | 'unsupported' | 'not-visible'
35
+ /** Native setup error, when available. Intended for developer diagnostics. */
36
+ detail?: string
37
+ frames: number
38
+ textureBytes: number
39
+ peakTextureBytes: number
40
+ preparationMs: number
41
+ longestFrameMs: number
42
+ }
43
+ export interface ImageEffect extends Animation {
44
+ readonly effect: ImageEffectName
45
+ readonly paused: boolean
46
+ /** Finite progress; distortion rejects seeking because it follows native touch input. */
47
+ progress: number
48
+ readonly diagnostics: Readonly<ImageEffectDiagnostics>
49
+ pause(): void
50
+ play(): void
51
+ /** Reverse a finite effect from its current pixels. Terminal handles remain terminal. */
52
+ reverse(): void
53
+ /** Complete immediately at the current direction's endpoint. */
54
+ finish(): void
55
+ }
56
+
57
+ const names: readonly ImageEffectName[] = ['ripple', 'distortion', 'dissolve']
58
+ const surfaceChanges = [
59
+ 'tintColorChange',
60
+ ...['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft'].map(
61
+ (corner) => `border${corner}RadiusChange`,
62
+ ),
63
+ ...['Top', 'Right', 'Bottom', 'Left'].flatMap((edge) => [
64
+ `padding${edge}Change`,
65
+ `border${edge}WidthChange`,
66
+ ]),
67
+ ]
68
+
69
+ /** Optional native image effects. Stop restores the source; dissolve completion commits its destination. */
70
+ export function imageEffect(
71
+ target: Target,
72
+ effect: ImageEffectName,
73
+ options: ImageEffectOptions = {},
74
+ ): ImageEffect {
75
+ const image = toView(target)
76
+ if (!(image instanceof Image))
77
+ throw new TypeError('imageEffect() expects a NativeScript Image.')
78
+ if (!names.includes(effect))
79
+ throw new TypeError(`Unknown image effect: ${String(effect)}.`)
80
+ if (!options || typeof options !== 'object')
81
+ throw new TypeError('Image effect options must be an object.')
82
+ const amplitude = options.amplitude ?? 8,
83
+ radius = options.radius ?? 96
84
+ const duration = options.duration ?? 650,
85
+ seed = options.seed ?? 1
86
+ if (!Number.isFinite(amplitude) || amplitude < 0 || amplitude > 64)
87
+ throw new RangeError('Image displacement must be between 0 and 64 DIPs.')
88
+ if (
89
+ !Number.isFinite(radius) ||
90
+ radius <= 0 ||
91
+ !Number.isFinite(duration) ||
92
+ duration <= 0
93
+ )
94
+ throw new RangeError('Image radius and duration must be positive finite numbers.')
95
+ if (!Number.isInteger(seed) || seed < 0 || seed > 65535)
96
+ throw new RangeError('Image seed must be an integer between 0 and 65535.')
97
+ if (
98
+ options.origin &&
99
+ (!Number.isFinite(options.origin.x) || !Number.isFinite(options.origin.y))
100
+ )
101
+ throw new RangeError('Image origin must contain finite local coordinates.')
102
+ if (options.paused !== undefined && typeof options.paused !== 'boolean')
103
+ throw new TypeError('paused must be a boolean.')
104
+ if (
105
+ effect === 'dissolve' &&
106
+ (!(options.to instanceof ImageSource) ||
107
+ !Number.isFinite(options.to.width) ||
108
+ !Number.isFinite(options.to.height) ||
109
+ options.to.width <= 0 ||
110
+ options.to.height <= 0)
111
+ )
112
+ throw new TypeError('Dissolve requires a loaded destination ImageSource.')
113
+ return new Effect(image, effect, {
114
+ ...options,
115
+ amplitude,
116
+ radius,
117
+ duration: duration / timeScale(),
118
+ seed,
119
+ })
120
+ }
121
+
122
+ class Effect implements ImageEffect {
123
+ readonly finished: Promise<boolean>
124
+ private resolve!: (completed: boolean) => void
125
+ private renderer: ImageRenderer | undefined
126
+ private releaseOwnership: () => void = () => {}
127
+ private terminal = false
128
+ private queued = false
129
+ private committing = false
130
+ private userPaused: boolean
131
+ private suspended = false
132
+ private position = 0
133
+ private direction: 0 | 1 = 1
134
+ private dimensions = ''
135
+ private stats: ImageEffectDiagnostics = {
136
+ backend: 'waiting',
137
+ frames: 0,
138
+ textureBytes: 0,
139
+ peakTextureBytes: 0,
140
+ preparationMs: 0,
141
+ longestFrameMs: 0,
142
+ }
143
+ private reduceObserver:
144
+ ReturnType<typeof Application.ios.addNotificationObserver> | undefined
145
+
146
+ constructor(
147
+ private readonly image: Image,
148
+ readonly effect: ImageEffectName,
149
+ private readonly options: ImageEffectOptions &
150
+ Required<Pick<ImageEffectOptions, 'amplitude' | 'radius' | 'duration' | 'seed'>>,
151
+ ) {
152
+ this.userPaused = options.paused ?? false
153
+ this.finished = new Promise((resolve) => {
154
+ this.resolve = resolve
155
+ })
156
+ this.releaseOwnership = ownImage(image, this.stop)
157
+ for (const event of ['loaded', 'layoutChanged']) image.on(event, this.schedule)
158
+ for (const event of ['imageSourceChange', 'stretchChange'])
159
+ image.on(event, this.changed)
160
+ image.on('unloaded', this.stop)
161
+ image.style.on('visibilityChange', this.visible)
162
+ for (const event of surfaceChanges) image.style.on(event, this.changed)
163
+ Application.on(Application.suspendEvent, this.suspend)
164
+ Application.on(Application.resumeEvent, this.resume)
165
+ if (isIOS)
166
+ this.reduceObserver = Application.ios.addNotificationObserver(
167
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
168
+ this.preferences,
169
+ )
170
+ this.schedule()
171
+ }
172
+ then<TResult1 = boolean, TResult2 = never>(
173
+ fulfilled?: ((value: boolean) => TResult1 | PromiseLike<TResult1>) | null,
174
+ rejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
175
+ ): PromiseLike<TResult1 | TResult2> {
176
+ return this.finished.then(fulfilled, rejected)
177
+ }
178
+ get paused() {
179
+ return this.userPaused
180
+ }
181
+ get progress() {
182
+ return this.renderer?.progress ?? this.position
183
+ }
184
+ set progress(value: number) {
185
+ if (this.effect === 'distortion')
186
+ throw new TypeError('Distortion follows touch input and cannot be scrubbed.')
187
+ if (!Number.isFinite(value) || value < 0 || value > 1)
188
+ throw new RangeError('Image progress must be between 0 and 1.')
189
+ if (this.terminal) return
190
+ this.position = value
191
+ this.renderer?.seek(value)
192
+ }
193
+ get diagnostics(): Readonly<ImageEffectDiagnostics> {
194
+ return {
195
+ ...this.stats,
196
+ ...(this.renderer
197
+ ? {
198
+ frames: this.renderer.frames,
199
+ textureBytes: this.renderer.textureBytes,
200
+ longestFrameMs: this.renderer.longestFrameMs,
201
+ }
202
+ : {}),
203
+ }
204
+ }
205
+ pause = () => {
206
+ if (this.terminal) return
207
+ this.userPaused = true
208
+ this.renderer?.pause()
209
+ }
210
+ play = () => {
211
+ if (this.terminal) return
212
+ this.userPaused = false
213
+ if (this.suspended) return
214
+ if (this.renderer) {
215
+ if (this.effect === 'distortion') this.renderer.resume()
216
+ else this.renderer.play(this.direction)
217
+ } else this.schedule()
218
+ }
219
+ reverse = () => {
220
+ if (this.effect === 'distortion')
221
+ throw new TypeError('Distortion has no finite playback direction.')
222
+ if (this.terminal) return
223
+ this.direction = this.direction === 1 ? 0 : 1
224
+ this.play()
225
+ }
226
+ finish = () => {
227
+ if (this.terminal) return
228
+ if (this.effect === 'distortion') return this.stop()
229
+ this.complete(true, this.direction)
230
+ }
231
+ stop = () => this.complete(false, this.progress)
232
+ private complete = (completed: boolean, position: number, preserveImage = false) => {
233
+ if (this.terminal) return
234
+ this.terminal = true
235
+ this.position = position
236
+ this.stats = { ...this.diagnostics, textureBytes: 0 }
237
+ this.renderer?.stop(preserveImage)
238
+ this.renderer = undefined
239
+ this.releaseOwnership()
240
+ for (const event of ['loaded', 'layoutChanged']) this.image.off(event, this.schedule)
241
+ for (const event of ['imageSourceChange', 'stretchChange'])
242
+ this.image.off(event, this.changed)
243
+ this.image.off('unloaded', this.stop)
244
+ this.image.style.off('visibilityChange', this.visible)
245
+ for (const event of surfaceChanges) this.image.style.off(event, this.changed)
246
+ Application.off(Application.suspendEvent, this.suspend)
247
+ Application.off(Application.resumeEvent, this.resume)
248
+ if (this.reduceObserver)
249
+ Application.ios.removeNotificationObserver(
250
+ this.reduceObserver,
251
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
252
+ )
253
+ this.reduceObserver = undefined
254
+ if (completed && this.effect === 'dissolve' && position === 1) {
255
+ this.committing = true
256
+ // The view may clear its ImageSource on disposal. Keep the caller's
257
+ // destination reusable by giving the view its own wrapper.
258
+ const destination = new ImageSource(this.options.to!.getNativeSource())
259
+ destination.rotationAngle = this.options.to!.rotationAngle
260
+ this.image.imageSource = destination
261
+ this.committing = false
262
+ }
263
+ this.resolve(completed)
264
+ }
265
+ private changed = (event?: EventData) => {
266
+ if (this.committing) return
267
+ if (this.renderer)
268
+ this.complete(false, this.progress, event?.eventName === 'imageSourceChange')
269
+ else this.schedule()
270
+ }
271
+ private suspend = () => {
272
+ this.suspended = true
273
+ this.renderer?.pause()
274
+ }
275
+ private resume = () => {
276
+ this.suspended = false
277
+ this.schedule()
278
+ if (!this.userPaused) this.play()
279
+ }
280
+ private visible = () => {
281
+ if (this.image.visibility !== 'visible') this.stop()
282
+ }
283
+ private preferences = () => {
284
+ if (reducedMotion()) this.fallback('reduced-motion')
285
+ }
286
+ private fallback(reason: ImageEffectDiagnostics['reason']) {
287
+ this.stats = { ...this.stats, backend: 'fallback', reason }
288
+ // Reduced/unsupported rendering still performs a requested content change.
289
+ if (this.effect === 'dissolve' && this.direction === 1) {
290
+ this.complete(true, 1)
291
+ } else this.complete(false, 0)
292
+ }
293
+ private schedule = () => {
294
+ if (this.queued || this.terminal) return
295
+ this.queued = true
296
+ void Promise.resolve().then(() => {
297
+ this.queued = false
298
+ if (this.terminal || this.suspended || !this.image.isLoaded) return
299
+ if (reducedMotion()) return this.fallback('reduced-motion')
300
+ if (this.image.visibility !== 'visible') return this.fallback('not-visible')
301
+ const { width, height } = this.image.getActualSize()
302
+ if (width <= 0 || height <= 0) return
303
+ const dimensions = `${width}:${height}`
304
+ if (this.renderer) {
305
+ if (this.dimensions !== dimensions) this.stop()
306
+ return
307
+ }
308
+ if (!this.image.imageSource) return
309
+ this.dimensions = dimensions
310
+ const started = Date.now()
311
+ try {
312
+ this.renderer = createImageRenderer(
313
+ this.image,
314
+ {
315
+ ...this.options,
316
+ mode: names.indexOf(this.effect),
317
+ x: this.options.origin?.x ?? width / 2,
318
+ y: this.options.origin?.y ?? height / 2,
319
+ },
320
+ this.complete,
321
+ )
322
+ } catch (error) {
323
+ this.stats.preparationMs = Date.now() - started
324
+ this.stats.detail = String(error)
325
+ return this.fallback('unsupported')
326
+ }
327
+ if (!this.renderer) return this.fallback('unsupported')
328
+ this.stats.backend = this.renderer.backend
329
+ this.stats.preparationMs = Date.now() - started
330
+ this.stats.peakTextureBytes = this.renderer.textureBytes
331
+ this.renderer.pause()
332
+ if (this.position) this.renderer.seek(this.position)
333
+ if (!this.userPaused && !this.suspended) this.play()
334
+ })
335
+ }
336
+ }
@@ -0,0 +1,12 @@
1
+ import type { Image } from '@nativescript/core'
2
+
3
+ /** Rendering effects share one image surface; ordinary view transforms stay independent. */
4
+ const owners = new WeakMap<Image, () => void>()
5
+
6
+ export function ownImage(image: Image, stop: () => void): () => void {
7
+ owners.get(image)?.()
8
+ owners.set(image, stop)
9
+ return () => {
10
+ if (owners.get(image) === stop) owners.delete(image)
11
+ }
12
+ }