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

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 (132) hide show
  1. package/README.md +78 -13
  2. package/dist/animate.d.ts +3 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +35 -5
  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/decoration.d.ts +18 -0
  11. package/dist/decoration.d.ts.map +1 -0
  12. package/dist/decoration.js +155 -0
  13. package/dist/decoration.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +20 -0
  15. package/dist/driver/android-color.d.ts.map +1 -0
  16. package/dist/driver/android-color.js +136 -0
  17. package/dist/driver/android-color.js.map +1 -0
  18. package/dist/driver/android-decoration.d.ts +22 -0
  19. package/dist/driver/android-decoration.d.ts.map +1 -0
  20. package/dist/driver/android-decoration.js +139 -0
  21. package/dist/driver/android-decoration.js.map +1 -0
  22. package/dist/driver/android-playback.d.ts +13 -0
  23. package/dist/driver/android-playback.d.ts.map +1 -0
  24. package/dist/driver/android-playback.js +141 -0
  25. package/dist/driver/android-playback.js.map +1 -0
  26. package/dist/driver/android.d.ts.map +1 -1
  27. package/dist/driver/android.js +28 -35
  28. package/dist/driver/android.js.map +1 -1
  29. package/dist/driver/index.d.ts +8 -1
  30. package/dist/driver/index.d.ts.map +1 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/ios-color.d.ts +16 -0
  33. package/dist/driver/ios-color.d.ts.map +1 -0
  34. package/dist/driver/ios-color.js +392 -0
  35. package/dist/driver/ios-color.js.map +1 -0
  36. package/dist/driver/ios-decoration.d.ts +17 -0
  37. package/dist/driver/ios-decoration.d.ts.map +1 -0
  38. package/dist/driver/ios-decoration.js +271 -0
  39. package/dist/driver/ios-decoration.js.map +1 -0
  40. package/dist/driver/ios-playback.d.ts +10 -0
  41. package/dist/driver/ios-playback.d.ts.map +1 -0
  42. package/dist/driver/ios-playback.js +33 -0
  43. package/dist/driver/ios-playback.js.map +1 -0
  44. package/dist/driver/ios-shadow.d.ts +6 -0
  45. package/dist/driver/ios-shadow.d.ts.map +1 -0
  46. package/dist/driver/ios-shadow.js +52 -0
  47. package/dist/driver/ios-shadow.js.map +1 -0
  48. package/dist/driver/ios.d.ts.map +1 -1
  49. package/dist/driver/ios.js +78 -35
  50. package/dist/driver/ios.js.map +1 -1
  51. package/dist/engine.d.ts +48 -2
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +217 -21
  54. package/dist/engine.js.map +1 -1
  55. package/dist/haptics.d.ts +4 -1
  56. package/dist/haptics.d.ts.map +1 -1
  57. package/dist/haptics.js +27 -3
  58. package/dist/haptics.js.map +1 -1
  59. package/dist/index.d.ts +4 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/liquid-grid.d.ts +3 -0
  64. package/dist/liquid-grid.d.ts.map +1 -0
  65. package/dist/liquid-grid.js +24 -0
  66. package/dist/liquid-grid.js.map +1 -0
  67. package/dist/liquid.d.ts +18 -0
  68. package/dist/liquid.d.ts.map +1 -0
  69. package/dist/liquid.js +229 -0
  70. package/dist/liquid.js.map +1 -0
  71. package/dist/playback-group.d.ts +6 -0
  72. package/dist/playback-group.d.ts.map +1 -0
  73. package/dist/playback-group.js +113 -0
  74. package/dist/playback-group.js.map +1 -0
  75. package/dist/playback.d.ts +25 -0
  76. package/dist/playback.d.ts.map +1 -0
  77. package/dist/playback.js +35 -0
  78. package/dist/playback.js.map +1 -0
  79. package/dist/press.d.ts.map +1 -1
  80. package/dist/press.js +6 -1
  81. package/dist/press.js.map +1 -1
  82. package/dist/properties.d.ts +5 -2
  83. package/dist/properties.d.ts.map +1 -1
  84. package/dist/properties.js +25 -1
  85. package/dist/properties.js.map +1 -1
  86. package/dist/sequence.d.ts.map +1 -1
  87. package/dist/sequence.js +6 -0
  88. package/dist/sequence.js.map +1 -1
  89. package/dist/text.d.ts.map +1 -1
  90. package/dist/text.js +3 -0
  91. package/dist/text.js.map +1 -1
  92. package/dist/track.d.ts +1 -1
  93. package/dist/track.d.ts.map +1 -1
  94. package/dist/track.js +48 -3
  95. package/dist/track.js.map +1 -1
  96. package/dist/transition.d.ts.map +1 -1
  97. package/dist/transition.js +11 -2
  98. package/dist/transition.js.map +1 -1
  99. package/dist/types.d.ts +20 -0
  100. package/dist/types.d.ts.map +1 -1
  101. package/package.json +11 -2
  102. package/platforms/android/include.gradle +7 -0
  103. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  104. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  105. package/platforms/android/proguard-rules.pro +7 -0
  106. package/src/animate.ts +48 -9
  107. package/src/color.ts +101 -0
  108. package/src/decoration.ts +180 -0
  109. package/src/driver/android-color.ts +170 -0
  110. package/src/driver/android-decoration.ts +182 -0
  111. package/src/driver/android-playback.ts +157 -0
  112. package/src/driver/android.ts +50 -46
  113. package/src/driver/index.ts +19 -1
  114. package/src/driver/ios-color.ts +441 -0
  115. package/src/driver/ios-decoration.ts +332 -0
  116. package/src/driver/ios-playback.ts +37 -0
  117. package/src/driver/ios-shadow.ts +50 -0
  118. package/src/driver/ios.ts +91 -42
  119. package/src/engine.ts +227 -23
  120. package/src/haptics.ts +25 -2
  121. package/src/index.ts +4 -0
  122. package/src/liquid-grid.ts +28 -0
  123. package/src/liquid.ts +282 -0
  124. package/src/playback-group.ts +110 -0
  125. package/src/playback.ts +62 -0
  126. package/src/press.ts +8 -2
  127. package/src/properties.ts +31 -5
  128. package/src/sequence.ts +5 -0
  129. package/src/text.ts +2 -0
  130. package/src/track.ts +72 -3
  131. package/src/transition.ts +13 -2
  132. package/src/types.ts +20 -0
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
+ }
@@ -0,0 +1,110 @@
1
+ import { arrivalOf, setArrival, type ViewAnimation } from './engine.js'
2
+ import { getDriver } from './driver/index.js'
3
+ import { createClock, type PlaybackClock } from './playback.js'
4
+ import { validateProgress, type PlaybackAnimation } from './playback.js'
5
+
6
+ /** Staggered views share a finite time range. Shorter children remain seekable
7
+ * until the whole group settles, unless another animation has taken ownership. */
8
+ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnimation {
9
+ let paused = false
10
+ let settled = false
11
+ let finalProgress: number | undefined
12
+ let clock: PlaybackClock | undefined
13
+ let pendingProgress: number | undefined
14
+ const duration = () => Math.max(0, ...children.map((child) => child.duration))
15
+ const ensureClock = () => {
16
+ if (clock || settled || children.some((child) => !child.ready)) return
17
+ const driver = getDriver()
18
+ clock = driver.clock?.(duration()) ?? createClock(() => driver.now())
19
+ clock.seek(Math.max(0, ...children.map((child) => child.currentTime)))
20
+ if (paused) clock.pause()
21
+ }
22
+ const finishIfIdle = () => {
23
+ if (settled || paused || children.some((child) => !child.idle || child.paused)) return
24
+ settled = true
25
+ clock?.cancel()
26
+ for (const child of children) child.releaseGroup()
27
+ }
28
+ const seek = (value: number) => {
29
+ const total = duration()
30
+ validateProgress(value, total)
31
+ if (settled) return
32
+ if (children.some((child) => !child.ready)) {
33
+ pendingProgress = value
34
+ return
35
+ }
36
+ pendingProgress = undefined
37
+ ensureClock()
38
+ clock?.seek(total * value)
39
+ for (const child of children) child.seekTime(total * value)
40
+ finishIfIdle()
41
+ }
42
+ for (const child of children) {
43
+ child.onIdle(finishIfIdle)
44
+ child.onReady(() => {
45
+ ensureClock()
46
+ if (pendingProgress !== undefined && children.every((child) => child.ready))
47
+ seek(pendingProgress)
48
+ finishIfIdle()
49
+ })
50
+ }
51
+ const finished = Promise.all(children.map((child) => child.finished)).then(
52
+ (results) => {
53
+ finalProgress ??= results.every(Boolean)
54
+ ? 1
55
+ : duration()
56
+ ? Math.min(1, (clock?.time() ?? 0) / duration())
57
+ : 0
58
+ settled = true
59
+ clock?.cancel()
60
+ return results.every(Boolean)
61
+ },
62
+ )
63
+ const animation: PlaybackAnimation = {
64
+ finished,
65
+ get duration() {
66
+ return duration()
67
+ },
68
+ get paused() {
69
+ return paused
70
+ },
71
+ get progress() {
72
+ if (!Number.isFinite(duration()))
73
+ throw new RangeError('Progress requires a finite animation.')
74
+ if (pendingProgress !== undefined) return pendingProgress
75
+ return (
76
+ finalProgress ??
77
+ (settled ? 1 : duration() ? Math.min(1, (clock?.time() ?? 0) / duration()) : 0)
78
+ )
79
+ },
80
+ set progress(value) {
81
+ seek(value)
82
+ },
83
+ pause() {
84
+ if (!settled && !paused) {
85
+ paused = true
86
+ clock?.pause()
87
+ children.forEach((child) => child.pause())
88
+ }
89
+ },
90
+ play() {
91
+ if (!settled && paused) {
92
+ paused = false
93
+ clock?.play()
94
+ children.forEach((child) => child.play())
95
+ finishIfIdle()
96
+ }
97
+ },
98
+ stop() {
99
+ finalProgress = duration() ? Math.min(1, (clock?.time() ?? 0) / duration()) : 0
100
+ settled = true
101
+ clock?.cancel()
102
+ children.forEach((child) => child.stop())
103
+ },
104
+ then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
105
+ }
106
+ setArrival(animation, Promise.all(children.map(arrivalOf)))
107
+ ensureClock()
108
+ finishIfIdle()
109
+ return animation
110
+ }
@@ -0,0 +1,62 @@
1
+ import type { Animation } from './engine.js'
2
+
3
+ /** Controls returned by animate() and decay(). Effects expose the base Animation. */
4
+ export interface PlaybackAnimation extends Animation {
5
+ /** Pauses only the properties owned by this animation. */
6
+ pause(): void
7
+ /** Continues from the current position. Completed/stopped animations remain terminal. */
8
+ play(): void
9
+ readonly paused: boolean
10
+ /** Total milliseconds, including delays and repeats. Infinity for endless loops. */
11
+ readonly duration: number
12
+ /** Finite timeline position, from 0 to 1. Seeking preserves the playing/paused state. */
13
+ progress: number
14
+ }
15
+
16
+ export type PlaybackCommand = 'pause' | 'play' | 'seek'
17
+
18
+ export interface PlaybackClock {
19
+ time(): number
20
+ pause(): void
21
+ play(): void
22
+ seek(ms: number): void
23
+ cancel(): void
24
+ }
25
+
26
+ /** Read only on user commands; there is no frame loop in JavaScript. */
27
+ export function createClock(now: () => number, initial = 0): PlaybackClock {
28
+ let offset = initial
29
+ let anchor = now()
30
+ let paused = false
31
+ const time = () => offset + (paused ? 0 : now() - anchor)
32
+ return {
33
+ time,
34
+ pause() {
35
+ offset = time()
36
+ paused = true
37
+ },
38
+ play() {
39
+ if (paused) {
40
+ anchor = now()
41
+ paused = false
42
+ }
43
+ },
44
+ seek(ms) {
45
+ offset = ms
46
+ anchor = now()
47
+ },
48
+ cancel() {
49
+ offset = time()
50
+ paused = true
51
+ },
52
+ }
53
+ }
54
+
55
+ export function validateProgress(value: number, duration: number) {
56
+ if (!Number.isFinite(value) || value < 0 || value > 1)
57
+ throw new RangeError('Animation progress must be between 0 and 1.')
58
+ if (!Number.isFinite(duration))
59
+ throw new RangeError(
60
+ 'Progress requires a finite animation; endless loops can pause and play.',
61
+ )
62
+ }
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(
package/src/properties.ts CHANGED
@@ -1,7 +1,15 @@
1
- import type { View } from '@nativescript/core'
1
+ import { Color, type View } from '@nativescript/core'
2
+ import {
3
+ isDecorationProperty,
4
+ readDecoration,
5
+ writeDecoration,
6
+ type DecorationProperty,
7
+ } from './decoration.js'
8
+ import { isColorProperty, readColor, type ColorProperty } from './color.js'
2
9
 
3
- export type Property =
10
+ export type NumericProperty =
4
11
  'opacity' | 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotate' | 'rotateX' | 'rotateY'
12
+ export type Property = NumericProperty | ColorProperty | DecorationProperty
5
13
 
6
14
  const viewKeys = {
7
15
  opacity: 'opacity',
@@ -12,9 +20,9 @@ const viewKeys = {
12
20
  rotate: 'rotate',
13
21
  rotateX: 'rotateX',
14
22
  rotateY: 'rotateY',
15
- } as const satisfies Record<Property, keyof View>
23
+ } as const satisfies Record<NumericProperty, keyof View>
16
24
 
17
- const defaults: Record<Property, number> = {
25
+ const defaults: Record<NumericProperty, number> = {
18
26
  opacity: 1,
19
27
  x: 0,
20
28
  y: 0,
@@ -27,6 +35,17 @@ const defaults: Record<Property, number> = {
27
35
 
28
36
  /** How close to its target a spring or decay has to be to count as settled. */
29
37
  export const precision: Record<Property, number> = {
38
+ borderRadius: 0.1,
39
+ borderWidth: 0.1,
40
+ shadowOpacity: 0.002,
41
+ shadowRadius: 0.1,
42
+ shadowOffsetX: 0.1,
43
+ shadowOffsetY: 0.1,
44
+ elevation: 0.1,
45
+ translationZ: 0.1,
46
+ backgroundColor: 0.002,
47
+ borderColor: 0.002,
48
+ color: 0.002,
30
49
  opacity: 0.002,
31
50
  x: 0.25,
32
51
  y: 0.25,
@@ -38,14 +57,21 @@ export const precision: Record<Property, number> = {
38
57
  }
39
58
 
40
59
  export function isProperty(key: string): key is Property {
41
- return key in viewKeys
60
+ return Object.hasOwn(viewKeys, key) || isColorProperty(key) || isDecorationProperty(key)
42
61
  }
43
62
 
44
63
  export function readValue(view: View, property: Property): number {
64
+ if (isColorProperty(property)) return readColor(view, property)
65
+ if (isDecorationProperty(property)) return readDecoration(view, property)
45
66
  const value = view[viewKeys[property]]
46
67
  return typeof value === 'number' && !Number.isNaN(value) ? value : defaults[property]
47
68
  }
48
69
 
49
70
  export function writeValue(view: View, property: Property, value: number) {
71
+ if (isColorProperty(property)) {
72
+ view[property] = new Color(value)
73
+ return
74
+ }
75
+ if (isDecorationProperty(property)) return writeDecoration(view, property, value)
50
76
  view[viewKeys[property]] = value
51
77
  }
package/src/sequence.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { getDriver } from './driver/index.js'
2
+ import { isDecorationProperty, validateDecorationTarget } from './decoration.js'
1
3
  import type { View } from '@nativescript/core'
2
4
  import { animate, startingValues, toViews } from './animate.js'
3
5
  import { arrivalOf, setArrival, stopView, type Animation } from './engine.js'
@@ -69,6 +71,8 @@ function applyStartingValues(steps: readonly Step[]) {
69
71
  const starts = startingValues(animation, options)
70
72
 
71
73
  for (const view of toViews(target)) {
74
+ for (const property of starts.keys())
75
+ if (isDecorationProperty(property)) validateDecorationTarget(view, property)
72
76
  let properties = claimed.get(view)
73
77
  if (!properties) claimed.set(view, (properties = new Set()))
74
78
 
@@ -82,6 +86,7 @@ function applyStartingValues(steps: readonly Step[]) {
82
86
  for (const [property, value] of fresh) {
83
87
  if (value !== null) writeValue(view, property, value)
84
88
  }
89
+ getDriver().sync?.(view)
85
90
  for (const property of starts.keys()) properties.add(property)
86
91
  }
87
92
  }
package/src/text.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { stopIOSColorText } from './driver/ios-color.js'
1
2
  import { Application, Label, isIOS } from '@nativescript/core'
2
3
  import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
3
4
  import { toView } from './animate.js'
@@ -119,6 +120,7 @@ class TextAnimation implements Animation {
119
120
  this.finished = new Promise((resolve) => {
120
121
  this.resolve = resolve
121
122
  })
123
+ if (isIOS) stopIOSColorText(view)
122
124
  this.formatted = view.formattedText
123
125
  running.set(view, this)
124
126
  if (!isIOS) {
package/src/track.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { clampDecoration, isDecorationProperty } from './decoration.js'
1
2
  import { linear } from './easing.js'
2
3
  import { solveDecay } from './decay.js'
3
4
  import { precision, type Property } from './properties.js'
4
5
  import { solveSpring, springPhysics } from './spring.js'
5
6
  import type { Curve, ResolvedTransition } from './transition.js'
7
+ import { colorMixer, isColorProperty, type ColorProperty } from './color.js'
6
8
 
7
9
  /**
8
10
  * One property animated over time. The curve is computed here and sampled,
@@ -44,9 +46,11 @@ const ARRIVAL_TOLERANCE = 0.05
44
46
  export function createTrack(
45
47
  property: Property,
46
48
  keyframes: readonly number[],
47
- { curve, delay, repeat, reverse }: ResolvedTransition,
49
+ transition: ResolvedTransition,
48
50
  velocity: number,
49
51
  ): Track {
52
+ if (isColorProperty(property)) return colorTrack(property, keyframes, transition)
53
+ const { curve, delay, repeat, reverse } = transition
50
54
  const motion =
51
55
  curve.type === 'spring'
52
56
  ? spring(property, keyframes, curve, velocity)
@@ -69,7 +73,7 @@ export function createTrack(
69
73
  return reverse && iteration % 2 === 1 ? [duration - local, -1] : [local, 1]
70
74
  }
71
75
 
72
- return {
76
+ const track: Track = {
73
77
  property,
74
78
  delay,
75
79
  duration,
@@ -91,6 +95,65 @@ export function createTrack(
91
95
  return motion.velocity(time) * direction
92
96
  },
93
97
  }
98
+ if (!isDecorationProperty(property)) return track
99
+ return {
100
+ ...track,
101
+ values: track.values.map((value) => clampDecoration(property, value)),
102
+ to: clampDecoration(property, track.to),
103
+ valueAt: (elapsed) => clampDecoration(property, track.valueAt(elapsed)),
104
+ }
105
+ }
106
+
107
+ function colorTrack(
108
+ property: ColorProperty,
109
+ colors: readonly number[],
110
+ transition: ResolvedTransition,
111
+ ): Track {
112
+ const progress = createTrack(
113
+ 'opacity',
114
+ colors.map((_, i) => i),
115
+ transition,
116
+ 0,
117
+ )
118
+ const mixes = colors.slice(1).map((color, i) => colorMixer(colors[i]!, color))
119
+ const colorAt = (position: number) => {
120
+ const p = Math.max(0, Math.min(colors.length - 1, position))
121
+ const segment = Math.min(mixes.length - 1, Math.floor(p))
122
+ return mixes[segment]!(p - segment)
123
+ }
124
+ // Linear timing still needs intermediate colors: native RGB interpolation is
125
+ // only the short bridge between these perceptually spaced samples.
126
+ const steps = Math.max(
127
+ 1,
128
+ Math.min(
129
+ MAX_DECAY_SAMPLES,
130
+ Math.ceil((progress.duration / 1000) * SAMPLES_PER_SECOND),
131
+ ),
132
+ )
133
+ const times = [
134
+ ...new Set([
135
+ ...progress.times,
136
+ ...Array.from({ length: steps + 1 }, (_, i) => i / steps),
137
+ ]),
138
+ ].sort((a, b) => a - b)
139
+ return {
140
+ ...progress,
141
+ property,
142
+ times,
143
+ values: times.map((time) =>
144
+ colorAt(
145
+ time === 0
146
+ ? progress.values[0]!
147
+ : time === 1
148
+ ? progress.values.at(-1)!
149
+ : progress.valueAt(progress.delay + time * progress.duration),
150
+ ),
151
+ ),
152
+ to: colorAt(progress.to),
153
+ valueAt: (elapsed) => colorAt(progress.valueAt(elapsed)),
154
+ // Packed colors have no scalar velocity to carry into another spring.
155
+ velocityAt: () => 0,
156
+ }
94
157
  }
95
158
 
96
159
  function decay(
@@ -185,7 +248,13 @@ function tween(
185
248
  const steps =
186
249
  easing === linear || from === to
187
250
  ? 1
188
- : Math.max(1, Math.ceil(((span * duration) / 1000) * SAMPLES_PER_SECOND))
251
+ : Math.max(
252
+ 1,
253
+ Math.min(
254
+ MAX_DECAY_SAMPLES,
255
+ Math.ceil(((span * duration) / 1000) * SAMPLES_PER_SECOND),
256
+ ),
257
+ )
189
258
  for (let step = 1; step <= steps; step++) {
190
259
  times.push(start + (span * step) / steps)
191
260
  values.push(step === steps ? to : from + (to - from) * easing(step / steps))