@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.
- package/README.md +122 -5
- package/dist/animate.d.ts +4 -2
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +48 -11
- package/dist/animate.js.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -0
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +7 -0
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +2 -0
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.js +1 -1
- package/dist/driver/ios-color.js.map +1 -1
- package/dist/driver/ios-decoration.js +1 -1
- package/dist/driver/ios-decoration.js.map +1 -1
- package/dist/driver/ios-playback.d.ts +1 -1
- package/dist/driver/ios-playback.d.ts.map +1 -1
- package/dist/driver/ios-playback.js +14 -0
- package/dist/driver/ios-playback.js.map +1 -1
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +1 -0
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +16 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +33 -1
- package/dist/engine.js.map +1 -1
- package/dist/image-effect-native.d.ts +26 -0
- package/dist/image-effect-native.d.ts.map +1 -0
- package/dist/image-effect-native.js +73 -0
- package/dist/image-effect-native.js.map +1 -0
- package/dist/image-effect.d.ts +50 -0
- package/dist/image-effect.d.ts.map +1 -0
- package/dist/image-effect.js +290 -0
- package/dist/image-effect.js.map +1 -0
- package/dist/image-owner.d.ts +3 -0
- package/dist/image-owner.d.ts.map +1 -0
- package/dist/image-owner.js +11 -0
- package/dist/image-owner.js.map +1 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/items.d.ts +20 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/items.js +109 -0
- package/dist/items.js.map +1 -0
- package/dist/keyboard.d.ts +17 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +116 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/label.d.ts +7 -0
- package/dist/label.d.ts.map +1 -0
- package/dist/label.js +60 -0
- package/dist/label.js.map +1 -0
- package/dist/liquid.d.ts.map +1 -1
- package/dist/liquid.js +6 -1
- package/dist/liquid.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +2 -1
- package/dist/loader.js.map +1 -1
- package/dist/particles.d.ts.map +1 -1
- package/dist/particles.js +4 -3
- package/dist/particles.js.map +1 -1
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +90 -0
- package/dist/path.js.map +1 -0
- package/dist/playback-group.d.ts.map +1 -1
- package/dist/playback-group.js +29 -1
- package/dist/playback-group.js.map +1 -1
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +3 -3
- package/dist/press.js.map +1 -1
- package/dist/reveal.d.ts.map +1 -1
- package/dist/reveal.js +3 -2
- package/dist/reveal.js.map +1 -1
- package/dist/roll-diff.d.ts +22 -0
- package/dist/roll-diff.d.ts.map +1 -0
- package/dist/roll-diff.js +36 -0
- package/dist/roll-diff.js.map +1 -0
- package/dist/roll.d.ts +29 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/roll.js +442 -0
- package/dist/roll.js.map +1 -0
- package/dist/scroll-linked.d.ts +10 -0
- package/dist/scroll-linked.d.ts.map +1 -0
- package/dist/scroll-linked.js +58 -0
- package/dist/scroll-linked.js.map +1 -0
- package/dist/sequence.d.ts +10 -5
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +202 -14
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts.map +1 -1
- package/dist/shimmer.js +2 -1
- package/dist/shimmer.js.map +1 -1
- package/dist/svg-path.d.ts +14 -0
- package/dist/svg-path.d.ts.map +1 -0
- package/dist/svg-path.js +281 -0
- package/dist/svg-path.js.map +1 -0
- package/dist/symbol-effect.d.ts.map +1 -1
- package/dist/symbol-effect.js +4 -3
- package/dist/symbol-effect.js.map +1 -1
- package/dist/text-layout.d.ts +8 -0
- package/dist/text-layout.d.ts.map +1 -1
- package/dist/text-layout.js +8 -2
- package/dist/text-layout.js.map +1 -1
- package/dist/text.d.ts +1 -1
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +60 -64
- package/dist/text.js.map +1 -1
- package/dist/track.d.ts +2 -0
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +62 -15
- package/dist/track.js.map +1 -1
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +89 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +134 -0
- package/dist/vue/index.js.map +1 -0
- package/dist/zoom-native.d.ts +30 -0
- package/dist/zoom-native.d.ts.map +1 -0
- package/dist/zoom-native.js +56 -0
- package/dist/zoom-native.js.map +1 -0
- package/dist/zoomable.d.ts +48 -0
- package/dist/zoomable.d.ts.map +1 -0
- package/dist/zoomable.js +249 -0
- package/dist/zoomable.js.map +1 -0
- package/package.json +16 -3
- package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
- package/platforms/android/proguard-rules.pro +10 -0
- package/platforms/ios/build.xcconfig +1 -0
- package/platforms/ios/src/JoltImageEffect.h +25 -0
- package/platforms/ios/src/JoltImageEffect.mm +242 -0
- package/platforms/ios/src/JoltPhotoZoom.h +23 -0
- package/platforms/ios/src/JoltPhotoZoom.m +229 -0
- package/platforms/ios/src/module.modulemap +5 -0
- package/src/android-plugin.d.ts +142 -0
- package/src/animate.ts +80 -30
- package/src/config.ts +46 -0
- package/src/driver/android.ts +14 -0
- package/src/driver/index.ts +2 -0
- package/src/driver/ios-color.ts +1 -1
- package/src/driver/ios-decoration.ts +1 -1
- package/src/driver/ios-playback.ts +15 -1
- package/src/driver/ios.ts +2 -0
- package/src/engine.ts +47 -2
- package/src/image-effect-native.ts +129 -0
- package/src/image-effect.ts +336 -0
- package/src/image-owner.ts +12 -0
- package/src/index.ts +20 -1
- package/src/ios-plugin.d.ts +59 -0
- package/src/items.ts +140 -0
- package/src/keyboard.ts +132 -0
- package/src/label.ts +76 -0
- package/src/liquid.ts +6 -1
- package/src/loader.ts +2 -1
- package/src/particles.ts +4 -4
- package/src/path.ts +124 -0
- package/src/playback-group.ts +34 -1
- package/src/press.ts +3 -5
- package/src/reveal.ts +3 -3
- package/src/roll-diff.ts +57 -0
- package/src/roll.ts +515 -0
- package/src/scroll-linked.ts +73 -0
- package/src/sequence.ts +199 -14
- package/src/shimmer.ts +2 -1
- package/src/svg-path.ts +355 -0
- package/src/symbol-effect.ts +4 -3
- package/src/text-layout.ts +14 -2
- package/src/text.ts +62 -81
- package/src/track.ts +89 -24
- package/src/types.ts +8 -1
- package/src/vue/index.ts +189 -0
- package/src/zoom-native.ts +124 -0
- 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:
|
|
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
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
|
-
*
|
|
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
|
+
}
|