@rafaelaugusto/jolt 0.1.0-beta.1 → 0.1.0-beta.3
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 +115 -8
- package/dist/animate.d.ts +3 -1
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +83 -6
- package/dist/animate.js.map +1 -1
- package/dist/decay.d.ts +11 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +22 -0
- package/dist/decay.js.map +1 -0
- package/dist/driver/android.d.ts +1 -1
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +95 -5
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +3 -1
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +7 -0
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +6 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +21 -5
- package/dist/engine.js.map +1 -1
- package/dist/haptics.d.ts +18 -0
- package/dist/haptics.d.ts.map +1 -0
- package/dist/haptics.js +96 -0
- package/dist/haptics.js.map +1 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +19 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +172 -0
- package/dist/loader.js.map +1 -0
- package/dist/particles.d.ts +39 -0
- package/dist/particles.d.ts.map +1 -0
- package/dist/particles.js +264 -0
- package/dist/particles.js.map +1 -0
- package/dist/properties.d.ts +1 -1
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +1 -1
- package/dist/properties.js.map +1 -1
- package/dist/reveal.d.ts +25 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +293 -0
- package/dist/reveal.js.map +1 -0
- package/dist/shimmer.d.ts +12 -0
- package/dist/shimmer.d.ts.map +1 -0
- package/dist/shimmer.js +160 -0
- package/dist/shimmer.js.map +1 -0
- package/dist/symbol-effect.d.ts +23 -0
- package/dist/symbol-effect.d.ts.map +1 -0
- package/dist/symbol-effect.js +186 -0
- package/dist/symbol-effect.js.map +1 -0
- package/dist/text-layout.d.ts +22 -0
- package/dist/text-layout.d.ts.map +1 -0
- package/dist/text-layout.js +179 -0
- package/dist/text-layout.js.map +1 -0
- package/dist/text.d.ts +17 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +264 -0
- package/dist/text.js.map +1 -0
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +25 -3
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts +5 -0
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/animate.ts +96 -4
- package/src/decay.ts +45 -0
- package/src/driver/android.ts +112 -6
- package/src/driver/index.ts +3 -1
- package/src/driver/ios.ts +11 -0
- package/src/engine.ts +22 -6
- package/src/haptics.ts +125 -0
- package/src/index.ts +18 -1
- package/src/loader.ts +201 -0
- package/src/particles.ts +323 -0
- package/src/properties.ts +1 -1
- package/src/reveal.ts +374 -0
- package/src/shimmer.ts +179 -0
- package/src/symbol-effect.ts +225 -0
- package/src/text-layout.ts +216 -0
- package/src/text.ts +296 -0
- package/src/track.ts +42 -3
- package/src/transition.ts +6 -0
- package/src/types.ts +16 -0
package/src/reveal.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { Application, isIOS, type View } from '@nativescript/core'
|
|
2
|
+
import { toView } from './animate.js'
|
|
3
|
+
import { resolveEasing } from './easing.js'
|
|
4
|
+
import type { Animation } from './engine.js'
|
|
5
|
+
import type { Target } from './types.js'
|
|
6
|
+
|
|
7
|
+
export interface RevealOptions {
|
|
8
|
+
/** Circle origin in DIPs from the target's top-left corner. Defaults to its center. */
|
|
9
|
+
from?: { x: number; y: number }
|
|
10
|
+
/** Milliseconds. Defaults to 400; zero changes visibility immediately. */
|
|
11
|
+
duration?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ConcealOptions {
|
|
15
|
+
/** Circle destination in DIPs from the target's top-left corner. Defaults to its center. */
|
|
16
|
+
to?: { x: number; y: number }
|
|
17
|
+
/** Milliseconds. Defaults to 400; zero changes visibility immediately. */
|
|
18
|
+
duration?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Circle = { x: number; y: number; radius: number }
|
|
22
|
+
type MaskedView = UIView & { originalMask?: CALayer | null; maskType?: unknown }
|
|
23
|
+
type Snapshot = { circle: Circle; native: UIView; bounds: CGRect }
|
|
24
|
+
const running = new WeakMap<View, CircularAnimation>()
|
|
25
|
+
const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
|
|
26
|
+
const ease = resolveEasing('easeInOut')
|
|
27
|
+
const key = 'jolt:reveal'
|
|
28
|
+
let Delegate: typeof NSObject | undefined
|
|
29
|
+
|
|
30
|
+
/** Reveals an iOS view through an expanding circle, then removes the temporary mask. */
|
|
31
|
+
export function reveal(target: Target, options: RevealOptions = {}): Animation {
|
|
32
|
+
return start(target, true, options.from, options.duration ?? 400)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Contracts an iOS view to a point, then sets visibility to 'hidden' without collapsing layout. */
|
|
36
|
+
export function conceal(target: Target, options: ConcealOptions = {}): Animation {
|
|
37
|
+
return start(target, false, options.to, options.duration ?? 400)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function start(
|
|
41
|
+
target: Target,
|
|
42
|
+
opening: boolean,
|
|
43
|
+
point: { x: number; y: number } | undefined,
|
|
44
|
+
duration: number,
|
|
45
|
+
): Animation {
|
|
46
|
+
if (!Number.isFinite(duration) || duration < 0)
|
|
47
|
+
throw new RangeError('Reveal duration must be a nonnegative finite number.')
|
|
48
|
+
if (
|
|
49
|
+
point !== undefined &&
|
|
50
|
+
(!point || !Number.isFinite(point.x) || !Number.isFinite(point.y))
|
|
51
|
+
)
|
|
52
|
+
throw new TypeError('Reveal origin must have finite x and y coordinates.')
|
|
53
|
+
const view = toView(target)
|
|
54
|
+
const previous = running.get(view)
|
|
55
|
+
const snapshot = previous?.snapshot()
|
|
56
|
+
const original = previous?.originalVisibility ?? view.visibility
|
|
57
|
+
previous?.stop()
|
|
58
|
+
return new CircularAnimation(
|
|
59
|
+
view,
|
|
60
|
+
opening,
|
|
61
|
+
point && { ...point },
|
|
62
|
+
duration,
|
|
63
|
+
original,
|
|
64
|
+
snapshot,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sameBounds(a: CGRect, b: CGRect) {
|
|
69
|
+
return (
|
|
70
|
+
a.origin.x === b.origin.x &&
|
|
71
|
+
a.origin.y === b.origin.y &&
|
|
72
|
+
a.size.width === b.size.width &&
|
|
73
|
+
a.size.height === b.size.height
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function path(circle: Circle): CAShapeLayer['path'] {
|
|
78
|
+
// Keep the same oval topology even at the closed endpoint.
|
|
79
|
+
const radius = Math.max(0.01, circle.radius)
|
|
80
|
+
return UIBezierPath.bezierPathWithOvalInRect(
|
|
81
|
+
CGRectMake(circle.x - radius, circle.y - radius, radius * 2, radius * 2),
|
|
82
|
+
).CGPath
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function completion(
|
|
86
|
+
callback: (finished: boolean) => void,
|
|
87
|
+
): NSObject & CAAnimationDelegate {
|
|
88
|
+
if (!Delegate) {
|
|
89
|
+
// Use the runtime subclass API so published JS needs no NativeClass transform.
|
|
90
|
+
const base = NSObject as typeof NSObject & {
|
|
91
|
+
extend(
|
|
92
|
+
methods: object,
|
|
93
|
+
options: { protocols: unknown[]; name: string },
|
|
94
|
+
): typeof NSObject
|
|
95
|
+
}
|
|
96
|
+
Delegate = base.extend(
|
|
97
|
+
{
|
|
98
|
+
animationDidStopFinished(
|
|
99
|
+
this: NSObject,
|
|
100
|
+
_animation: CAAnimation,
|
|
101
|
+
finished: boolean,
|
|
102
|
+
) {
|
|
103
|
+
callbacks.get(this)?.(finished)
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{ protocols: [CAAnimationDelegate], name: 'JoltCircularRevealDelegate' },
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
const delegate = Delegate.new()
|
|
110
|
+
callbacks.set(delegate, callback)
|
|
111
|
+
return delegate
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class CircularAnimation implements Animation {
|
|
115
|
+
readonly finished: Promise<boolean>
|
|
116
|
+
private resolve!: (finished: boolean) => void
|
|
117
|
+
private settled = false
|
|
118
|
+
private queued = false
|
|
119
|
+
private changingVisibility = false
|
|
120
|
+
private waitingForLoad: boolean
|
|
121
|
+
private native: MaskedView | undefined
|
|
122
|
+
private bounds: CGRect | undefined
|
|
123
|
+
private wrapper: CALayer | undefined
|
|
124
|
+
private shape: CAShapeLayer | undefined
|
|
125
|
+
private originalMask: CALayer | null = null
|
|
126
|
+
private savedMask: CALayer | null = null
|
|
127
|
+
private delegate: NSObject | undefined
|
|
128
|
+
private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
|
|
129
|
+
private from: Circle | undefined
|
|
130
|
+
private to: Circle | undefined
|
|
131
|
+
private startedAt = 0
|
|
132
|
+
|
|
133
|
+
constructor(
|
|
134
|
+
private readonly view: View,
|
|
135
|
+
private readonly opening: boolean,
|
|
136
|
+
private readonly point: { x: number; y: number } | undefined,
|
|
137
|
+
private readonly duration: number,
|
|
138
|
+
readonly originalVisibility: View['visibility'],
|
|
139
|
+
private readonly inherited?: Snapshot,
|
|
140
|
+
) {
|
|
141
|
+
this.finished = new Promise((resolve) => {
|
|
142
|
+
this.resolve = resolve
|
|
143
|
+
})
|
|
144
|
+
this.waitingForLoad = !view.isLoaded
|
|
145
|
+
running.set(view, this)
|
|
146
|
+
if (!isIOS) {
|
|
147
|
+
this.end(false)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
view.on('loaded', this.loaded)
|
|
151
|
+
view.on('layoutChanged', this.layoutChanged)
|
|
152
|
+
view.on('unloaded', this.stop)
|
|
153
|
+
view.style.on('backgroundInternalChange', this.stop)
|
|
154
|
+
view.style.on('visibilityChange', this.visibilityChanged)
|
|
155
|
+
if (opening && view.visibility === 'collapse') this.visibility('hidden')
|
|
156
|
+
this.schedule()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
then<A = boolean, B = never>(
|
|
160
|
+
onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
|
|
161
|
+
onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
|
|
162
|
+
): Promise<A | B> {
|
|
163
|
+
return this.finished.then(onfulfilled, onrejected)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Cancels and restores visibility from before the interrupted reveal/conceal chain. */
|
|
167
|
+
stop = () => this.end(false)
|
|
168
|
+
|
|
169
|
+
snapshot(): Snapshot | undefined {
|
|
170
|
+
if (!this.shape || !this.native || !this.bounds || !this.from || !this.to) return
|
|
171
|
+
const presented = this.shape.presentationLayer() as CAShapeLayer | null
|
|
172
|
+
let circle: Circle
|
|
173
|
+
if (presented?.path) {
|
|
174
|
+
const box = CGPathGetBoundingBox(presented.path)
|
|
175
|
+
circle = {
|
|
176
|
+
x: box.origin.x + box.size.width / 2,
|
|
177
|
+
y: box.origin.y + box.size.height / 2,
|
|
178
|
+
radius: box.size.width / 2,
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
const progress = ease(
|
|
182
|
+
Math.max(
|
|
183
|
+
0,
|
|
184
|
+
Math.min(1, ((CACurrentMediaTime() - this.startedAt) * 1000) / this.duration),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
const lerp = (a: number, b: number) => a + (b - a) * progress
|
|
188
|
+
circle = {
|
|
189
|
+
x: lerp(this.from.x, this.to.x),
|
|
190
|
+
y: lerp(this.from.y, this.to.y),
|
|
191
|
+
radius: lerp(this.from.radius, this.to.radius),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { circle, native: this.native, bounds: this.bounds }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private visibility(value: View['visibility']) {
|
|
198
|
+
this.changingVisibility = true
|
|
199
|
+
try {
|
|
200
|
+
this.view.visibility = value
|
|
201
|
+
} finally {
|
|
202
|
+
this.changingVisibility = false
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private visibilityChanged = () => {
|
|
207
|
+
if (!this.changingVisibility) this.end(false, true)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private loaded = () => {
|
|
211
|
+
this.waitingForLoad = false
|
|
212
|
+
this.schedule()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private layoutChanged = () => {
|
|
216
|
+
if (this.shape) {
|
|
217
|
+
const native = this.view.nativeViewProtected as UIView | undefined
|
|
218
|
+
if (
|
|
219
|
+
native !== this.native ||
|
|
220
|
+
!native?.window ||
|
|
221
|
+
!sameBounds(native.layer.bounds, this.bounds!)
|
|
222
|
+
) {
|
|
223
|
+
this.stop()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.schedule()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private schedule = () => {
|
|
231
|
+
if (this.settled || this.queued) return
|
|
232
|
+
this.queued = true
|
|
233
|
+
void Promise.resolve().then(() => {
|
|
234
|
+
this.queued = false
|
|
235
|
+
if (this.settled) return
|
|
236
|
+
try {
|
|
237
|
+
this.update()
|
|
238
|
+
} catch (error) {
|
|
239
|
+
this.stop()
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private motionChanged = () => {
|
|
246
|
+
if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private update() {
|
|
250
|
+
if (!this.opening && !this.inherited && this.view.visibility !== 'visible')
|
|
251
|
+
return this.end(true, true)
|
|
252
|
+
if (this.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
|
|
253
|
+
return this.end(true)
|
|
254
|
+
if (this.waitingForLoad || !this.view.isLoaded) return
|
|
255
|
+
const native = this.view.nativeViewProtected as MaskedView | undefined
|
|
256
|
+
if (this.shape) {
|
|
257
|
+
if (native !== this.native || native?.layer.mask !== this.wrapper) this.stop()
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (!native?.window) return
|
|
261
|
+
const bounds = native.layer.bounds
|
|
262
|
+
if (bounds.size.width <= 0 || bounds.size.height <= 0) return
|
|
263
|
+
const inherited =
|
|
264
|
+
this.inherited?.native === native && sameBounds(this.inherited.bounds, bounds)
|
|
265
|
+
? this.inherited.circle
|
|
266
|
+
: undefined
|
|
267
|
+
const x = this.point
|
|
268
|
+
? bounds.origin.x + this.point.x
|
|
269
|
+
: (inherited?.x ?? bounds.origin.x + bounds.size.width / 2)
|
|
270
|
+
const y = this.point
|
|
271
|
+
? bounds.origin.y + this.point.y
|
|
272
|
+
: (inherited?.y ?? bounds.origin.y + bounds.size.height / 2)
|
|
273
|
+
const radius =
|
|
274
|
+
Math.hypot(
|
|
275
|
+
Math.max(
|
|
276
|
+
Math.abs(x - bounds.origin.x),
|
|
277
|
+
Math.abs(x - bounds.origin.x - bounds.size.width),
|
|
278
|
+
),
|
|
279
|
+
Math.max(
|
|
280
|
+
Math.abs(y - bounds.origin.y),
|
|
281
|
+
Math.abs(y - bounds.origin.y - bounds.size.height),
|
|
282
|
+
),
|
|
283
|
+
) + 1
|
|
284
|
+
this.from = inherited ?? { x, y, radius: this.opening ? 0 : radius }
|
|
285
|
+
this.to = { x, y, radius: this.opening ? radius : 0 }
|
|
286
|
+
this.native = native
|
|
287
|
+
this.bounds = bounds
|
|
288
|
+
this.originalMask = native.layer.mask
|
|
289
|
+
this.savedMask =
|
|
290
|
+
native.maskType != null ? (native.originalMask ?? null) : this.originalMask
|
|
291
|
+
|
|
292
|
+
CATransaction.begin()
|
|
293
|
+
CATransaction.setDisableActions(true)
|
|
294
|
+
try {
|
|
295
|
+
// A plain container prevents Core from mistaking our circle for its own clip path.
|
|
296
|
+
const wrapper = (this.wrapper = CALayer.new())
|
|
297
|
+
const shape = (this.shape = CAShapeLayer.new())
|
|
298
|
+
wrapper.name = key
|
|
299
|
+
for (const layer of [wrapper, shape]) {
|
|
300
|
+
layer.bounds = bounds
|
|
301
|
+
layer.position = CGPointMake(
|
|
302
|
+
bounds.origin.x + bounds.size.width / 2,
|
|
303
|
+
bounds.origin.y + bounds.size.height / 2,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
shape.fillColor = UIColor.blackColor.CGColor
|
|
307
|
+
shape.path = path(this.to)
|
|
308
|
+
wrapper.addSublayer(shape)
|
|
309
|
+
// Same coordinate space as the original host, including bounds origins.
|
|
310
|
+
native.layer.mask = null
|
|
311
|
+
wrapper.mask = this.originalMask
|
|
312
|
+
native.layer.mask = wrapper
|
|
313
|
+
const animation = CABasicAnimation.animationWithKeyPath('path')
|
|
314
|
+
animation.fromValue = path(this.from)
|
|
315
|
+
animation.toValue = shape.path
|
|
316
|
+
animation.duration = this.duration / 1000
|
|
317
|
+
animation.timingFunction = CAMediaTimingFunction.functionWithName(
|
|
318
|
+
kCAMediaTimingFunctionEaseInEaseOut,
|
|
319
|
+
)
|
|
320
|
+
this.delegate = animation.delegate = completion((finished) => this.end(finished))
|
|
321
|
+
this.startedAt = CACurrentMediaTime()
|
|
322
|
+
shape.addAnimationForKey(animation, key)
|
|
323
|
+
this.observer = Application.ios.addNotificationObserver(
|
|
324
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
325
|
+
this.motionChanged,
|
|
326
|
+
)
|
|
327
|
+
this.visibility('visible')
|
|
328
|
+
} finally {
|
|
329
|
+
CATransaction.commit()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private end(finished: boolean, keepVisibility = false) {
|
|
334
|
+
if (this.settled) return
|
|
335
|
+
this.settled = true
|
|
336
|
+
const { view, native, wrapper, shape } = this
|
|
337
|
+
view.off('loaded', this.loaded)
|
|
338
|
+
view.off('layoutChanged', this.layoutChanged)
|
|
339
|
+
view.off('unloaded', this.stop)
|
|
340
|
+
view.style.off('backgroundInternalChange', this.stop)
|
|
341
|
+
view.style.off('visibilityChange', this.visibilityChanged)
|
|
342
|
+
if (running.get(view) === this) running.delete(view)
|
|
343
|
+
if (this.observer)
|
|
344
|
+
Application.ios.removeNotificationObserver(
|
|
345
|
+
this.observer,
|
|
346
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
347
|
+
)
|
|
348
|
+
if (this.delegate) callbacks.delete(this.delegate)
|
|
349
|
+
if (native && wrapper) {
|
|
350
|
+
CATransaction.begin()
|
|
351
|
+
CATransaction.setDisableActions(true)
|
|
352
|
+
try {
|
|
353
|
+
// Hide before removing a closed mask so conceal never flashes its content.
|
|
354
|
+
if (!keepVisibility)
|
|
355
|
+
this.visibility(
|
|
356
|
+
finished ? (this.opening ? 'visible' : 'hidden') : this.originalVisibility,
|
|
357
|
+
)
|
|
358
|
+
shape?.removeAnimationForKey(key)
|
|
359
|
+
wrapper.mask = null
|
|
360
|
+
if (native.layer.mask === wrapper) native.layer.mask = this.originalMask
|
|
361
|
+
// Core can save the temporary mask while installing a new clip/border mask.
|
|
362
|
+
if (native.originalMask === wrapper) native.originalMask = this.savedMask
|
|
363
|
+
} finally {
|
|
364
|
+
CATransaction.commit()
|
|
365
|
+
}
|
|
366
|
+
} else if (isIOS && !keepVisibility) {
|
|
367
|
+
this.visibility(
|
|
368
|
+
finished ? (this.opening ? 'visible' : 'hidden') : this.originalVisibility,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
this.native = this.wrapper = this.shape = this.delegate = this.observer = undefined
|
|
372
|
+
this.resolve(finished)
|
|
373
|
+
}
|
|
374
|
+
}
|
package/src/shimmer.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Application, isIOS, type View } from '@nativescript/core'
|
|
2
|
+
import { toView } from './animate.js'
|
|
3
|
+
import type { Target } from './types.js'
|
|
4
|
+
|
|
5
|
+
export interface ShimmerOptions {
|
|
6
|
+
/** Milliseconds per sweep. Defaults to 1500. */
|
|
7
|
+
duration?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const running = new WeakMap<View, () => void>()
|
|
11
|
+
const key = 'jolt:shimmer'
|
|
12
|
+
|
|
13
|
+
// NativeScript saves a mask here while applying clip-path or non-uniform borders.
|
|
14
|
+
type MaskedView = UIView & { originalMask?: CALayer | null }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sweeps a native gradient mask across an iOS placeholder until stopped.
|
|
18
|
+
* Waits for layout, respects Reduce Motion and pauses while the view is unloaded.
|
|
19
|
+
* Android is a no-op. Returns an idempotent cleanup function.
|
|
20
|
+
*/
|
|
21
|
+
export function shimmer(target: Target, options: ShimmerOptions = {}): () => void {
|
|
22
|
+
const view = toView(target)
|
|
23
|
+
const duration = options.duration ?? 1500
|
|
24
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
25
|
+
throw new RangeError('Shimmer duration must be a positive finite number.')
|
|
26
|
+
running.get(view)?.()
|
|
27
|
+
if (!isIOS) return () => {}
|
|
28
|
+
return new Shimmer(view, duration).stop
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class Shimmer {
|
|
32
|
+
private stopped = false
|
|
33
|
+
private queued = false
|
|
34
|
+
private waitingForLoad: boolean
|
|
35
|
+
private native: MaskedView | undefined
|
|
36
|
+
private owner: CALayer | undefined
|
|
37
|
+
private gradient: CAGradientLayer | undefined
|
|
38
|
+
private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly view: View,
|
|
42
|
+
private readonly duration: number,
|
|
43
|
+
) {
|
|
44
|
+
this.waitingForLoad = !view.isLoaded
|
|
45
|
+
running.set(view, this.stop)
|
|
46
|
+
view.on('loaded', this.loaded)
|
|
47
|
+
view.on('layoutChanged', this.schedule)
|
|
48
|
+
view.on('unloaded', this.unloaded)
|
|
49
|
+
view.style.on('backgroundInternalChange', this.schedule)
|
|
50
|
+
this.update()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
stop = () => {
|
|
54
|
+
if (this.stopped) return
|
|
55
|
+
this.stopped = true
|
|
56
|
+
const { view } = this
|
|
57
|
+
view.off('loaded', this.loaded)
|
|
58
|
+
view.off('layoutChanged', this.schedule)
|
|
59
|
+
view.off('unloaded', this.unloaded)
|
|
60
|
+
view.style.off('backgroundInternalChange', this.schedule)
|
|
61
|
+
if (running.get(view) === this.stop) running.delete(view)
|
|
62
|
+
this.unloaded()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private loaded = () => {
|
|
66
|
+
this.waitingForLoad = false
|
|
67
|
+
this.schedule()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private unloaded = () => {
|
|
71
|
+
this.waitingForLoad = true
|
|
72
|
+
if (this.observer) {
|
|
73
|
+
Application.ios.removeNotificationObserver(
|
|
74
|
+
this.observer,
|
|
75
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
76
|
+
)
|
|
77
|
+
this.observer = undefined
|
|
78
|
+
}
|
|
79
|
+
this.remove()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private schedule = () => {
|
|
83
|
+
if (this.stopped || this.queued) return
|
|
84
|
+
this.queued = true
|
|
85
|
+
// Core raises layoutChanged before redrawing the background and its mask.
|
|
86
|
+
// Attach after that pass, coalescing events from the same layout.
|
|
87
|
+
void Promise.resolve().then(() => {
|
|
88
|
+
this.queued = false
|
|
89
|
+
this.update()
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private update = () => {
|
|
94
|
+
if (this.stopped || this.waitingForLoad || !this.view.isLoaded) return
|
|
95
|
+
const native = this.view.nativeViewProtected as MaskedView | undefined
|
|
96
|
+
if (!native) return
|
|
97
|
+
this.observer ??= Application.ios.addNotificationObserver(
|
|
98
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
99
|
+
this.update,
|
|
100
|
+
)
|
|
101
|
+
if (UIAccessibilityIsReduceMotionEnabled()) return this.remove()
|
|
102
|
+
const bounds = native.layer.bounds
|
|
103
|
+
if (bounds.size.width <= 0 || bounds.size.height <= 0) return this.remove()
|
|
104
|
+
if (this.native && this.native !== native) this.remove()
|
|
105
|
+
|
|
106
|
+
CATransaction.begin()
|
|
107
|
+
CATransaction.setDisableActions(true)
|
|
108
|
+
try {
|
|
109
|
+
this.native = native
|
|
110
|
+
const gradient = (this.gradient ??= this.createGradient())
|
|
111
|
+
// Leave Core's mask at the root so it can keep updating its path on resize.
|
|
112
|
+
let owner = native.layer
|
|
113
|
+
while (owner.mask && owner.mask !== gradient) owner = owner.mask
|
|
114
|
+
if (this.owner && this.owner !== owner && this.owner.mask === gradient)
|
|
115
|
+
this.owner.mask = null
|
|
116
|
+
this.owner = owner
|
|
117
|
+
owner.mask = gradient
|
|
118
|
+
// Don't let a later background redraw resurrect our mask after cleanup.
|
|
119
|
+
if (native.originalMask === gradient) native.originalMask = null
|
|
120
|
+
|
|
121
|
+
// Core's CAShapeLayer masks draw a path with zero bounds. In that case the
|
|
122
|
+
// path uses the view's coordinates; other masks have their own bounds.
|
|
123
|
+
const area =
|
|
124
|
+
owner.bounds.size.width > 0 && owner.bounds.size.height > 0
|
|
125
|
+
? owner.bounds
|
|
126
|
+
: bounds
|
|
127
|
+
gradient.frame = CGRectMake(
|
|
128
|
+
area.origin.x - area.size.width,
|
|
129
|
+
area.origin.y,
|
|
130
|
+
area.size.width * 3,
|
|
131
|
+
area.size.height,
|
|
132
|
+
)
|
|
133
|
+
} finally {
|
|
134
|
+
CATransaction.commit()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private createGradient(): CAGradientLayer {
|
|
139
|
+
const gradient = CAGradientLayer.new()
|
|
140
|
+
const dim = UIColor.colorWithWhiteAlpha(1, 0.35).CGColor
|
|
141
|
+
gradient.colors = NSArray.arrayWithArray([dim, UIColor.whiteColor.CGColor, dim])
|
|
142
|
+
gradient.startPoint = CGPointMake(0, 0.5)
|
|
143
|
+
gradient.endPoint = CGPointMake(1, 0.5)
|
|
144
|
+
|
|
145
|
+
// A three-width mask lets the band enter and leave while every stop stays
|
|
146
|
+
// within the documented 0...1 range for CAGradientLayer.locations.
|
|
147
|
+
const from = NSArray.arrayWithArray([0, 0.125, 0.25])
|
|
148
|
+
const to = NSArray.arrayWithArray([0.75, 0.875, 1])
|
|
149
|
+
gradient.locations = from
|
|
150
|
+
const animation = CABasicAnimation.animationWithKeyPath('locations')
|
|
151
|
+
animation.fromValue = from
|
|
152
|
+
animation.toValue = to
|
|
153
|
+
animation.duration = this.duration / 1000
|
|
154
|
+
animation.repeatCount = Infinity
|
|
155
|
+
animation.removedOnCompletion = false
|
|
156
|
+
animation.timingFunction = CAMediaTimingFunction.functionWithName(
|
|
157
|
+
kCAMediaTimingFunctionLinear,
|
|
158
|
+
)
|
|
159
|
+
gradient.addAnimationForKey(animation, key)
|
|
160
|
+
return gradient
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private remove() {
|
|
164
|
+
const { gradient, owner, native } = this
|
|
165
|
+
if (!gradient) return
|
|
166
|
+
CATransaction.begin()
|
|
167
|
+
CATransaction.setDisableActions(true)
|
|
168
|
+
try {
|
|
169
|
+
gradient.removeAnimationForKey(key)
|
|
170
|
+
if (owner?.mask === gradient) owner.mask = null
|
|
171
|
+
if (native?.originalMask === gradient) native.originalMask = null
|
|
172
|
+
} finally {
|
|
173
|
+
CATransaction.commit()
|
|
174
|
+
}
|
|
175
|
+
this.owner = undefined
|
|
176
|
+
this.gradient = undefined
|
|
177
|
+
this.native = undefined
|
|
178
|
+
}
|
|
179
|
+
}
|