@rafaelaugusto/jolt 0.1.0-beta.2 → 0.1.0-beta.4

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 (105) hide show
  1. package/README.md +112 -16
  2. package/dist/animate.d.ts +3 -1
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +89 -3
  5. package/dist/animate.js.map +1 -1
  6. package/dist/color.d.ts +11 -0
  7. package/dist/color.d.ts.map +1 -0
  8. package/dist/color.js +73 -0
  9. package/dist/color.js.map +1 -0
  10. package/dist/decay.d.ts +11 -0
  11. package/dist/decay.d.ts.map +1 -0
  12. package/dist/decay.js +22 -0
  13. package/dist/decay.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +17 -0
  15. package/dist/driver/android-color.d.ts.map +1 -0
  16. package/dist/driver/android-color.js +134 -0
  17. package/dist/driver/android-color.js.map +1 -0
  18. package/dist/driver/android.d.ts +1 -1
  19. package/dist/driver/android.d.ts.map +1 -1
  20. package/dist/driver/android.js +101 -5
  21. package/dist/driver/android.js.map +1 -1
  22. package/dist/driver/index.d.ts +4 -2
  23. package/dist/driver/index.d.ts.map +1 -1
  24. package/dist/driver/index.js.map +1 -1
  25. package/dist/driver/ios-color.d.ts +14 -0
  26. package/dist/driver/ios-color.d.ts.map +1 -0
  27. package/dist/driver/ios-color.js +381 -0
  28. package/dist/driver/ios-color.js.map +1 -0
  29. package/dist/driver/ios.d.ts.map +1 -1
  30. package/dist/driver/ios.js +9 -1
  31. package/dist/driver/ios.js.map +1 -1
  32. package/dist/engine.d.ts +1 -1
  33. package/dist/engine.d.ts.map +1 -1
  34. package/dist/engine.js +15 -8
  35. package/dist/engine.js.map +1 -1
  36. package/dist/index.d.ts +10 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/liquid-grid.d.ts +3 -0
  41. package/dist/liquid-grid.d.ts.map +1 -0
  42. package/dist/liquid-grid.js +24 -0
  43. package/dist/liquid-grid.js.map +1 -0
  44. package/dist/liquid.d.ts +18 -0
  45. package/dist/liquid.d.ts.map +1 -0
  46. package/dist/liquid.js +229 -0
  47. package/dist/liquid.js.map +1 -0
  48. package/dist/loader.d.ts +19 -0
  49. package/dist/loader.d.ts.map +1 -0
  50. package/dist/loader.js +172 -0
  51. package/dist/loader.js.map +1 -0
  52. package/dist/press.d.ts.map +1 -1
  53. package/dist/press.js +6 -1
  54. package/dist/press.js.map +1 -1
  55. package/dist/properties.d.ts +5 -3
  56. package/dist/properties.d.ts.map +1 -1
  57. package/dist/properties.js +13 -2
  58. package/dist/properties.js.map +1 -1
  59. package/dist/reveal.d.ts +25 -0
  60. package/dist/reveal.d.ts.map +1 -0
  61. package/dist/reveal.js +293 -0
  62. package/dist/reveal.js.map +1 -0
  63. package/dist/text-layout.d.ts +22 -0
  64. package/dist/text-layout.d.ts.map +1 -0
  65. package/dist/text-layout.js +179 -0
  66. package/dist/text-layout.js.map +1 -0
  67. package/dist/text.d.ts +17 -0
  68. package/dist/text.d.ts.map +1 -0
  69. package/dist/text.js +267 -0
  70. package/dist/text.js.map +1 -0
  71. package/dist/track.d.ts +1 -1
  72. package/dist/track.d.ts.map +1 -1
  73. package/dist/track.js +63 -5
  74. package/dist/track.js.map +1 -1
  75. package/dist/transition.d.ts +5 -0
  76. package/dist/transition.d.ts.map +1 -1
  77. package/dist/transition.js +8 -2
  78. package/dist/transition.js.map +1 -1
  79. package/dist/types.d.ts +19 -0
  80. package/dist/types.d.ts.map +1 -1
  81. package/package.json +11 -2
  82. package/platforms/android/include.gradle +7 -0
  83. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  84. package/platforms/android/proguard-rules.pro +6 -0
  85. package/src/animate.ts +105 -5
  86. package/src/color.ts +101 -0
  87. package/src/decay.ts +45 -0
  88. package/src/driver/android-color.ts +168 -0
  89. package/src/driver/android.ts +120 -10
  90. package/src/driver/index.ts +9 -2
  91. package/src/driver/ios-color.ts +432 -0
  92. package/src/driver/ios.ts +13 -3
  93. package/src/engine.ts +17 -9
  94. package/src/index.ts +10 -1
  95. package/src/liquid-grid.ts +28 -0
  96. package/src/liquid.ts +282 -0
  97. package/src/loader.ts +201 -0
  98. package/src/press.ts +8 -2
  99. package/src/properties.ts +16 -6
  100. package/src/reveal.ts +374 -0
  101. package/src/text-layout.ts +216 -0
  102. package/src/text.ts +298 -0
  103. package/src/track.ts +105 -5
  104. package/src/transition.ts +16 -2
  105. package/src/types.ts +18 -0
package/src/engine.ts CHANGED
@@ -4,6 +4,7 @@ import { playHaptics, type HapticOptions } from './haptics.js'
4
4
  import { readValue, writeValue, type Property } from './properties.js'
5
5
  import { createTrack, type Track } from './track.js'
6
6
  import type { ResolvedTransition } from './transition.js'
7
+ import { isColorProperty } from './color.js'
7
8
 
8
9
  export interface Animation extends PromiseLike<boolean> {
9
10
  /**
@@ -12,7 +13,7 @@ export interface Animation extends PromiseLike<boolean> {
12
13
  * Never rejects.
13
14
  */
14
15
  readonly finished: Promise<boolean>
15
- /** Stops playback. Property animations keep their current values; symbol effects are removed. */
16
+ /** Stops playback. Each effect documents whether it preserves or restores its visual state. */
16
17
  stop(): void
17
18
  }
18
19
 
@@ -107,18 +108,21 @@ export class ViewAnimation implements Animation {
107
108
  let current: number
108
109
  let velocity = 0
109
110
  if (previous) {
110
- const elapsed = now - previous.startedAt
111
+ const elapsed = elapsedOf(previous, now)
111
112
  current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
112
113
  velocity = previous.track.velocityAt(elapsed)
113
114
  release(previous, now, false)
114
115
  } else {
115
- current = readValue(this.view, property)
116
+ current =
117
+ (isColorProperty(property)
118
+ ? driver.presented(this.view, property)
119
+ : undefined) ?? readValue(this.view, property)
116
120
  }
117
121
  return createTrack(
118
122
  property,
119
123
  keyframes.map((value) => {
120
124
  if (value === null) return current
121
- if (!relative) return value
125
+ if (!relative || isColorProperty(property)) return value
122
126
  return property === 'opacity' || property.startsWith('scale')
123
127
  ? current * value
124
128
  : current + value
@@ -147,24 +151,24 @@ export class ViewAnimation implements Animation {
147
151
  this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now)
148
152
 
149
153
  for (const entry of this.entries) {
150
- entry.handle = driver.play(this.view, entry.track, now, () => {
154
+ entry.handle = driver.play(this.view, entry.track, now, (finished = true) => {
151
155
  if (entry.done) return
152
156
  entry.done = true
153
157
  forget(entry)
154
- this.ended(true)
158
+ this.ended(finished)
155
159
  })
156
160
  }
157
161
 
158
162
  const arrival = Math.max(...this.entries.map((entry) => entry.track.arrival))
159
163
  if (this.pending === 0) this.settle()
160
- else if (Number.isFinite(arrival)) {
164
+ else if (Number.isFinite(arrival) && tracks.some((track) => track.end > arrival)) {
161
165
  this.cancelArrival = driver.after(arrival, this.arrive)
162
166
  }
163
167
  }
164
168
 
165
169
  private waitForLoad(plans: readonly Plan[]) {
166
170
  for (const { property, keyframes, relative } of plans) {
167
- if (relative) continue
171
+ if (relative && !isColorProperty(property)) continue
168
172
  const first = keyframes[0]
169
173
  if (first !== null && first !== undefined) writeValue(this.view, property, first)
170
174
  }
@@ -220,9 +224,13 @@ function release(entry: Entry, now: number, commit: boolean) {
220
224
  forget(entry)
221
225
  if (commit) {
222
226
  const value =
223
- driver.presented(view, property) ?? entry.track.valueAt(now - entry.startedAt)
227
+ driver.presented(view, property) ?? entry.track.valueAt(elapsedOf(entry, now))
224
228
  writeValue(view, property, value)
225
229
  }
226
230
  driver.stop(view, entry.handle)
227
231
  entry.owner.ended(false)
228
232
  }
233
+
234
+ function elapsedOf(entry: Entry, now: number) {
235
+ return getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt
236
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { animate, stop } from './animate.js'
1
+ export { animate, decay, stop } from './animate.js'
2
2
  export { sequence } from './sequence.js'
3
3
  export type { Step } from './sequence.js'
4
4
  export { press } from './press.js'
@@ -7,6 +7,14 @@ export { confetti, particles } from './particles.js'
7
7
  export type { ConfettiOptions, ParticleOptions } from './particles.js'
8
8
  export { shimmer } from './shimmer.js'
9
9
  export type { ShimmerOptions } from './shimmer.js'
10
+ export { liquid } from './liquid.js'
11
+ export type { LiquidOptions, LiquidEffect } from './liquid.js'
12
+ export { loader } from './loader.js'
13
+ export type { LoaderOptions, LoaderStyle } from './loader.js'
14
+ export { reveal, conceal } from './reveal.js'
15
+ export type { RevealOptions, ConcealOptions } from './reveal.js'
16
+ export { animateText } from './text.js'
17
+ export type { TextOptions } from './text.js'
10
18
  export { symbolEffect } from './symbol-effect.js'
11
19
  export type {
12
20
  SymbolEffect,
@@ -20,4 +28,5 @@ export type { Spring, SpringName, Timing, Transition, Tween } from './transition
20
28
  export { cubicBezier } from './easing.js'
21
29
  export type { CubicBezier, Easing, EasingFunction, EasingName } from './easing.js'
22
30
  export type { Animation } from './engine.js'
31
+ export type { ColorValue, ColorKeyframes } from './color.js'
23
32
  export type * from './types.js'
@@ -0,0 +1,28 @@
1
+ /** A periodic displacement field. The fixed boundary keeps transparent edges in bounds. */
2
+ export function liquidGrid(
3
+ columns: number,
4
+ rows: number,
5
+ phase: number,
6
+ intensity: number,
7
+ ) {
8
+ const positions = new Float32Array((columns + 1) * (rows + 1) * 2)
9
+ const t = phase * Math.PI * 2
10
+ let index = 0
11
+ for (let row = 0; row <= rows; row++) {
12
+ const y = row / rows
13
+ for (let column = 0; column <= columns; column++) {
14
+ const x = column / columns
15
+ const edge = Math.sin(Math.PI * x) ** 2 * Math.sin(Math.PI * y) ** 2
16
+ const amount = 0.055 * intensity * edge
17
+ positions[index++] =
18
+ x +
19
+ amount *
20
+ (Math.sin(Math.PI * 2 * y + t) + 0.4 * Math.sin(Math.PI * 2 * (x + y) - 2 * t))
21
+ positions[index++] =
22
+ y +
23
+ amount *
24
+ (Math.cos(Math.PI * 2 * x - t) + 0.4 * Math.cos(Math.PI * 2 * (x - y) + 2 * t))
25
+ }
26
+ }
27
+ return positions
28
+ }
package/src/liquid.ts ADDED
@@ -0,0 +1,282 @@
1
+ import { Application, Image, isIOS } from '@nativescript/core'
2
+ import { tintColorProperty } from '@nativescript/core/ui/image'
3
+ import { toView } from './animate.js'
4
+ import { liquidGrid } from './liquid-grid.js'
5
+ import type { Target } from './types.js'
6
+
7
+ export interface LiquidOptions {
8
+ /** Amount of internal deformation, from 0 to 1. Defaults to 0.7. */
9
+ intensity?: number
10
+ /** Milliseconds per seamless cycle. Defaults to 8000. */
11
+ duration?: number
12
+ }
13
+
14
+ export interface LiquidEffect {
15
+ /** Freeze the current deformation. */
16
+ pause(): void
17
+ /** Continue from the paused deformation. */
18
+ resume(): void
19
+ /** Remove the effect and restore the image. Idempotent. */
20
+ stop(): void
21
+ }
22
+
23
+ const running = new WeakMap<Image, LiquidEffect>()
24
+ const key = 'jolt:liquid'
25
+ const columns = 24
26
+ const rows = 24
27
+ const steps = 64
28
+ const noop = () => {}
29
+
30
+ /** Deforms a raster image with native iOS texture warps. Android leaves it unchanged. */
31
+ export function liquid(target: Target, options: LiquidOptions = {}): LiquidEffect {
32
+ const settings = {
33
+ intensity: options.intensity ?? 0.7,
34
+ duration: options.duration ?? 8000,
35
+ }
36
+ if (
37
+ !Number.isFinite(settings.intensity) ||
38
+ settings.intensity < 0 ||
39
+ settings.intensity > 1
40
+ )
41
+ throw new RangeError('Liquid intensity must be between zero and one.')
42
+ if (!Number.isFinite(settings.duration) || settings.duration <= 0)
43
+ throw new RangeError('Liquid duration must be a positive finite number.')
44
+ const image = toView(target)
45
+ if (!(image instanceof Image)) throw new TypeError('Expected a NativeScript Image.')
46
+ running.get(image)?.stop()
47
+ if (!isIOS || settings.intensity === 0) return { pause: noop, resume: noop, stop: noop }
48
+ return new Liquid(image, settings)
49
+ }
50
+
51
+ class Liquid implements LiquidEffect {
52
+ private stopped = false
53
+ private queued = false
54
+ private pausedByUser = false
55
+ private active = UIApplication.sharedApplication.applicationState === 0 // UIApplicationStateActive
56
+ private native: UIImageView | undefined
57
+ private original: UIImage | undefined
58
+ private source: Image['imageSource'] | undefined
59
+ private renderer: SKView | undefined
60
+ private scene: SKScene | undefined
61
+ private sprite: SKSpriteNode | undefined
62
+ private observers: [
63
+ string,
64
+ ReturnType<typeof Application.ios.addNotificationObserver>,
65
+ ][] = []
66
+
67
+ constructor(
68
+ private readonly image: Image,
69
+ private readonly settings: Required<LiquidOptions>,
70
+ ) {
71
+ running.set(image, this)
72
+ for (const event of ['loaded', 'layoutChanged', 'imageSourceChange', 'stretchChange'])
73
+ image.on(event, this.schedule)
74
+ image.on('unloaded', this.unloaded)
75
+ image.style.on('visibilityChange', this.schedule)
76
+ image.style.on('tintColorChange', this.schedule)
77
+ this.observe(UIAccessibilityReduceMotionStatusDidChangeNotification, this.schedule)
78
+ this.observe(UIApplicationWillResignActiveNotification, this.suspend)
79
+ this.observe(UIApplicationDidBecomeActiveNotification, this.activate)
80
+ this.schedule()
81
+ }
82
+
83
+ pause = () => {
84
+ this.pausedByUser = true
85
+ if (this.renderer) this.renderer.paused = true
86
+ }
87
+ resume = () => {
88
+ if (this.stopped) return
89
+ this.pausedByUser = false
90
+ this.schedule()
91
+ }
92
+ stop = () => {
93
+ if (this.stopped) return
94
+ this.stopped = true
95
+ for (const event of ['loaded', 'layoutChanged', 'imageSourceChange', 'stretchChange'])
96
+ this.image.off(event, this.schedule)
97
+ this.image.off('unloaded', this.unloaded)
98
+ this.image.style.off('visibilityChange', this.schedule)
99
+ this.image.style.off('tintColorChange', this.schedule)
100
+ for (const [name, observer] of this.observers)
101
+ Application.ios.removeNotificationObserver(observer, name)
102
+ this.observers = []
103
+ if (running.get(this.image) === this) running.delete(this.image)
104
+ this.remove()
105
+ }
106
+ private observe(name: string, callback: () => void) {
107
+ this.observers.push([name, Application.ios.addNotificationObserver(name, callback)])
108
+ }
109
+ private suspend = () => {
110
+ this.active = false
111
+ if (this.renderer) this.renderer.paused = true
112
+ }
113
+ private activate = () => {
114
+ this.active = true
115
+ this.schedule()
116
+ }
117
+ private unloaded = () => this.remove()
118
+ private schedule = () => {
119
+ if (this.stopped || this.queued) return
120
+ this.queued = true
121
+ void Promise.resolve().then(() => {
122
+ this.queued = false
123
+ if (this.stopped) return
124
+ try {
125
+ this.update()
126
+ } catch (error) {
127
+ this.stop()
128
+ throw error
129
+ }
130
+ })
131
+ }
132
+ private update() {
133
+ if (
134
+ !this.image.isLoaded ||
135
+ this.image.visibility !== 'visible' ||
136
+ this.image.tintColor ||
137
+ UIAccessibilityIsReduceMotionEnabled()
138
+ )
139
+ return this.remove()
140
+ const native = this.image.nativeViewProtected as UIImageView | undefined
141
+ if (!(native instanceof UIImageView) || !native.window) return this.remove()
142
+ if (
143
+ this.native &&
144
+ (this.native !== native ||
145
+ native.image !== null ||
146
+ this.image.imageSource !== this.source)
147
+ )
148
+ this.remove()
149
+ const source = this.original ?? native.image
150
+ if (
151
+ !source?.CGImage ||
152
+ source.images?.count ||
153
+ source.renderingMode === 2 /* AlwaysTemplate */
154
+ )
155
+ return this.remove()
156
+ if (source.size.width * source.size.height * source.scale ** 2 > 16_777_216)
157
+ return this.remove()
158
+ const bounds = native.bounds
159
+ if (
160
+ bounds.size.width <= 0 ||
161
+ bounds.size.height <= 0 ||
162
+ source.size.width <= 0 ||
163
+ source.size.height <= 0
164
+ )
165
+ return this.remove()
166
+ if (!this.renderer) {
167
+ // A paused SKView cannot draw its first frame. Keep the original visible
168
+ // until rendering can start, including source changes while paused.
169
+ if (this.pausedByUser || !this.active) return
170
+ this.create(native, source)
171
+ }
172
+ const renderer = this.renderer!
173
+ renderer.frame = CGRectMake(
174
+ bounds.origin.x,
175
+ bounds.origin.y,
176
+ bounds.size.width,
177
+ bounds.size.height,
178
+ )
179
+ this.scene!.size = CGSizeMake(bounds.size.width, bounds.size.height)
180
+ const iw = source.size.width
181
+ const ih = source.size.height
182
+ const { width, height } = bounds.size
183
+ const stretch = this.image.stretch
184
+ const scale =
185
+ stretch === 'aspectFill'
186
+ ? Math.max(width / iw, height / ih)
187
+ : Math.min(width / iw, height / ih)
188
+ this.sprite!.size = CGSizeMake(
189
+ stretch === 'fill' ? width : stretch === 'none' ? iw : iw * scale,
190
+ stretch === 'fill' ? height : stretch === 'none' ? ih : ih * scale,
191
+ )
192
+ this.sprite!.position = CGPointMake(width / 2, height / 2)
193
+ renderer.paused = this.pausedByUser || !this.active
194
+ }
195
+ private create(native: UIImageView, source: UIImage) {
196
+ const renderer = SKView.alloc().initWithFrame(native.bounds)
197
+ this.renderer = renderer
198
+ this.native = native
199
+ this.original = source
200
+ this.source = this.image.imageSource
201
+ renderer.opaque = false
202
+ renderer.backgroundColor = UIColor.clearColor
203
+ renderer.allowsTransparency = true
204
+ renderer.asynchronous = true
205
+ renderer.userInteractionEnabled = false
206
+ renderer.accessibilityElementsHidden = true
207
+ renderer.clipsToBounds = true
208
+ renderer.preferredFramesPerSecond = Math.min(
209
+ 120,
210
+ native.window?.screen?.maximumFramesPerSecond ?? 60,
211
+ )
212
+ renderer.paused = true
213
+ const scene = SKScene.sceneWithSize(native.bounds.size)
214
+ this.scene = scene
215
+ scene.backgroundColor = UIColor.clearColor
216
+ scene.scaleMode = 3 // SKSceneScaleModeResizeFill
217
+ const texture = SKTexture.textureWithImage(source)
218
+ const sprite = SKSpriteNode.spriteNodeWithTexture(texture)
219
+ this.sprite = sprite
220
+ const warps: SKWarpGeometryGrid[] = []
221
+ const times: number[] = []
222
+ for (let step = 0; step <= steps; step++) {
223
+ const positions = liquidGrid(
224
+ columns,
225
+ rows,
226
+ step === steps ? 0 : step / steps,
227
+ this.settings.intensity,
228
+ )
229
+ warps.push(
230
+ SKWarpGeometryGrid.gridWithColumnsRowsSourcePositionsDestPositions(
231
+ columns,
232
+ rows,
233
+ null,
234
+ positions,
235
+ ),
236
+ )
237
+ times.push(((step / steps) * this.settings.duration) / 1000)
238
+ }
239
+ const loop = SKAction.animateWithWarpsTimes(warps, times)
240
+ if (!loop) throw new Error('Unable to create the liquid warp action.')
241
+ sprite.warpGeometry = SKWarpGeometryGrid.gridWithColumnsRows(columns, rows)
242
+ const entrance = SKAction.warpToDuration(
243
+ warps[0]!,
244
+ Math.min(0.35, this.settings.duration / 10000),
245
+ )
246
+ if (!entrance) throw new Error('Unable to create the liquid entrance.')
247
+ sprite.runActionWithKey(
248
+ SKAction.sequence([entrance, SKAction.repeatActionForever(loop)]),
249
+ key,
250
+ )
251
+ scene.addChild(sprite)
252
+ renderer.presentScene(scene)
253
+ native.addSubview(renderer)
254
+ native.image = null
255
+ }
256
+ private remove() {
257
+ const { renderer, sprite, native, original } = this
258
+ if (renderer) {
259
+ renderer.paused = true
260
+ sprite?.removeAllActions()
261
+ sprite?.removeFromParent()
262
+ renderer.presentScene(null)
263
+ renderer.removeFromSuperview()
264
+ }
265
+ if (
266
+ native &&
267
+ native.image === null &&
268
+ original &&
269
+ this.image.imageSource === this.source
270
+ ) {
271
+ native.image = original
272
+ // Core couldn't turn the suppressed UIImage into a template while it was
273
+ // hidden. Reapply its public native setter when a tint takes over.
274
+ if (this.image.tintColor)
275
+ (this.image as unknown as Record<symbol, (value: unknown) => void>)[
276
+ tintColorProperty.setNative
277
+ ]!(this.image.tintColor)
278
+ }
279
+ this.renderer = this.scene = this.sprite = this.native = this.original = undefined
280
+ this.source = undefined
281
+ }
282
+ }
package/src/loader.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { Application, Color, isIOS, type View } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import type { Target } from './types.js'
4
+
5
+ export type LoaderStyle = 'dots' | 'bars' | 'ring'
6
+
7
+ export interface LoaderOptions {
8
+ /** Defaults to 'dots'. */
9
+ style?: LoaderStyle
10
+ /** A NativeScript color string. Defaults to '#71717a'. */
11
+ color?: string
12
+ /** Maximum width and height in DIPs, fitted inside the target. Defaults to 48. */
13
+ size?: number
14
+ /** Milliseconds per cycle. Defaults to 1200. */
15
+ duration?: number
16
+ }
17
+
18
+ const running = new WeakMap<View, () => void>()
19
+ const key = 'jolt:loader'
20
+
21
+ /**
22
+ * Adds a centered native iOS loading indicator. Returns an idempotent cleanup.
23
+ * Unloading releases native resources; loading resumes until explicitly stopped.
24
+ * Reduce Motion keeps the indicator static. Android is a no-op.
25
+ */
26
+ export function loader(target: Target, options: LoaderOptions = {}): () => void {
27
+ const settings: Required<LoaderOptions> = {
28
+ style: options.style ?? 'dots',
29
+ color: options.color ?? '#71717a',
30
+ size: options.size ?? 48,
31
+ duration: options.duration ?? 1200,
32
+ }
33
+ if (!['dots', 'bars', 'ring'].includes(settings.style))
34
+ throw new TypeError('Unknown loader style.')
35
+ if (typeof settings.color !== 'string' || !Color.isValid(settings.color))
36
+ throw new TypeError('Loader color must be a valid color string.')
37
+ for (const name of ['size', 'duration'] as const)
38
+ if (!Number.isFinite(settings[name]) || settings[name] <= 0)
39
+ throw new RangeError(`Loader ${name} must be a positive finite number.`)
40
+ const view = toView(target)
41
+ running.get(view)?.()
42
+ if (!isIOS) return () => {}
43
+ return new Loader(view, settings).stop
44
+ }
45
+
46
+ class Loader {
47
+ private stopped = false
48
+ private queued = false
49
+ private waitingForLoad: boolean
50
+ private native: UIView | undefined
51
+ private layer: CAReplicatorLayer | undefined
52
+ private item: CALayer | undefined
53
+ private animating = false
54
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
55
+
56
+ constructor(
57
+ private readonly view: View,
58
+ private readonly settings: Required<LoaderOptions>,
59
+ ) {
60
+ this.waitingForLoad = !view.isLoaded
61
+ running.set(view, this.stop)
62
+ view.on('loaded', this.loaded)
63
+ view.on('layoutChanged', this.schedule)
64
+ view.on('unloaded', this.unloaded)
65
+ this.schedule()
66
+ }
67
+
68
+ stop = () => {
69
+ if (this.stopped) return
70
+ this.stopped = true
71
+ this.view.off('loaded', this.loaded)
72
+ this.view.off('layoutChanged', this.schedule)
73
+ this.view.off('unloaded', this.unloaded)
74
+ if (running.get(this.view) === this.stop) running.delete(this.view)
75
+ this.unloaded()
76
+ }
77
+
78
+ private loaded = () => {
79
+ this.waitingForLoad = false
80
+ this.schedule()
81
+ }
82
+
83
+ private unloaded = () => {
84
+ this.waitingForLoad = true
85
+ if (this.observer) {
86
+ Application.ios.removeNotificationObserver(
87
+ this.observer,
88
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
89
+ )
90
+ this.observer = undefined
91
+ }
92
+ this.remove()
93
+ }
94
+
95
+ private schedule = () => {
96
+ if (this.stopped || this.queued) return
97
+ this.queued = true
98
+ void Promise.resolve().then(() => {
99
+ this.queued = false
100
+ if (this.stopped) return
101
+ try {
102
+ this.update()
103
+ } catch (error) {
104
+ this.stop()
105
+ throw error
106
+ }
107
+ })
108
+ }
109
+
110
+ private update() {
111
+ if (this.waitingForLoad || !this.view.isLoaded) return
112
+ const native = this.view.nativeViewProtected as UIView | undefined
113
+ if (!native?.window) return this.remove()
114
+ const bounds = native.layer.bounds
115
+ const size = Math.min(this.settings.size, bounds.size.width, bounds.size.height)
116
+ if (size <= 0) return this.remove()
117
+ if (this.native && this.native !== native) this.remove()
118
+ this.observer ??= Application.ios.addNotificationObserver(
119
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
120
+ this.schedule,
121
+ )
122
+
123
+ CATransaction.begin()
124
+ CATransaction.setDisableActions(true)
125
+ try {
126
+ if (!this.layer) {
127
+ this.native = native
128
+ this.layer = CAReplicatorLayer.new()
129
+ this.layer.name = key
130
+ this.item = CALayer.new()
131
+ this.item.backgroundColor = new Color(this.settings.color).ios.CGColor
132
+ this.layer.addSublayer(this.item)
133
+ native.layer.addSublayer(this.layer)
134
+ }
135
+ const layer = this.layer
136
+ const item = this.item!
137
+ const { style, duration } = this.settings
138
+ layer.bounds = CGRectMake(0, 0, size, size)
139
+ layer.position = CGPointMake(
140
+ bounds.origin.x + bounds.size.width / 2,
141
+ bounds.origin.y + bounds.size.height / 2,
142
+ )
143
+ layer.instanceCount = style === 'dots' ? 3 : style === 'bars' ? 5 : 12
144
+ layer.instanceDelay = duration / 1000 / (style === 'ring' ? 12 : 5)
145
+ if (style === 'ring') {
146
+ const diameter = size * 0.16
147
+ item.bounds = CGRectMake(0, 0, diameter, diameter)
148
+ item.position = CGPointMake(size / 2, diameter / 2)
149
+ item.cornerRadius = diameter / 2
150
+ layer.instanceTransform = CATransform3DMakeRotation((2 * Math.PI) / 12, 0, 0, 1)
151
+ } else {
152
+ const width = size * (style === 'dots' ? 0.2 : 0.12)
153
+ const height = style === 'dots' ? width : size * 0.75
154
+ item.bounds = CGRectMake(0, 0, width, height)
155
+ item.position = CGPointMake(width / 2, size / 2)
156
+ item.cornerRadius = width / 2
157
+ layer.instanceTransform = CATransform3DMakeTranslation(
158
+ (size - width) / (layer.instanceCount - 1),
159
+ 0,
160
+ 0,
161
+ )
162
+ }
163
+ const reduced = UIAccessibilityIsReduceMotionEnabled()
164
+ item.opacity = style === 'ring' ? 0.35 : 1
165
+ if (reduced && this.animating) {
166
+ item.removeAnimationForKey(key)
167
+ this.animating = false
168
+ } else if (!reduced && !this.animating) {
169
+ const animation = CAKeyframeAnimation.animationWithKeyPath(
170
+ style === 'ring'
171
+ ? 'opacity'
172
+ : style === 'bars'
173
+ ? 'transform.scale.y'
174
+ : 'transform.scale',
175
+ )
176
+ animation.values = NSArray.arrayWithArray(
177
+ style === 'ring' ? [1, 0.2] : [0.35, 1, 0.35, 0.35],
178
+ )
179
+ animation.keyTimes = NSArray.arrayWithArray(
180
+ style === 'ring' ? [0, 1] : [0, 0.3, 0.6, 1],
181
+ )
182
+ animation.duration = duration / 1000
183
+ animation.repeatCount = Infinity
184
+ // Start one cycle in so every delayed copy is already participating.
185
+ animation.beginTime =
186
+ item.convertTimeFromLayer(CACurrentMediaTime(), null) - animation.duration
187
+ item.addAnimationForKey(animation, key)
188
+ this.animating = true
189
+ }
190
+ } finally {
191
+ CATransaction.commit()
192
+ }
193
+ }
194
+
195
+ private remove() {
196
+ this.item?.removeAnimationForKey(key)
197
+ this.layer?.removeFromSuperlayer()
198
+ this.item = this.layer = this.native = undefined
199
+ this.animating = false
200
+ }
201
+ }
package/src/press.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { EventData, TouchGestureEventData } from '@nativescript/core'
1
+ import { Color, type EventData, type TouchGestureEventData } from '@nativescript/core'
2
+ import { isColorProperty } from './color.js'
2
3
  import { animate, toView } from './animate.js'
3
4
  import { isProperty, readValue, type Property } from './properties.js'
4
5
  import type { SpringName, Transition } from './transition.js'
@@ -30,7 +31,12 @@ export function press(
30
31
  const onTouch = (event: EventData) => {
31
32
  const { action } = event as TouchGestureEventData
32
33
  if (action === 'down' && !rest && view.isEnabled !== false) {
33
- rest = Object.fromEntries(properties.map((p) => [p, readValue(view, p)]))
34
+ rest = Object.fromEntries(
35
+ properties.map((p) => [
36
+ p,
37
+ isColorProperty(p) ? new Color(readValue(view, p)) : readValue(view, p),
38
+ ]),
39
+ )
34
40
  animate(view, pressed, options ?? pressIn)
35
41
  } else if ((action === 'up' || action === 'cancel') && rest) {
36
42
  animate(