@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.
- package/README.md +78 -13
- package/dist/animate.d.ts +3 -2
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +35 -5
- package/dist/animate.js.map +1 -1
- package/dist/color.d.ts +11 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +73 -0
- package/dist/color.js.map +1 -0
- package/dist/decoration.d.ts +18 -0
- package/dist/decoration.d.ts.map +1 -0
- package/dist/decoration.js +155 -0
- package/dist/decoration.js.map +1 -0
- package/dist/driver/android-color.d.ts +20 -0
- package/dist/driver/android-color.d.ts.map +1 -0
- package/dist/driver/android-color.js +136 -0
- package/dist/driver/android-color.js.map +1 -0
- package/dist/driver/android-decoration.d.ts +22 -0
- package/dist/driver/android-decoration.d.ts.map +1 -0
- package/dist/driver/android-decoration.js +139 -0
- package/dist/driver/android-decoration.js.map +1 -0
- package/dist/driver/android-playback.d.ts +13 -0
- package/dist/driver/android-playback.d.ts.map +1 -0
- package/dist/driver/android-playback.js +141 -0
- package/dist/driver/android-playback.js.map +1 -0
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +28 -35
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +8 -1
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.d.ts +16 -0
- package/dist/driver/ios-color.d.ts.map +1 -0
- package/dist/driver/ios-color.js +392 -0
- package/dist/driver/ios-color.js.map +1 -0
- package/dist/driver/ios-decoration.d.ts +17 -0
- package/dist/driver/ios-decoration.d.ts.map +1 -0
- package/dist/driver/ios-decoration.js +271 -0
- package/dist/driver/ios-decoration.js.map +1 -0
- package/dist/driver/ios-playback.d.ts +10 -0
- package/dist/driver/ios-playback.d.ts.map +1 -0
- package/dist/driver/ios-playback.js +33 -0
- package/dist/driver/ios-playback.js.map +1 -0
- package/dist/driver/ios-shadow.d.ts +6 -0
- package/dist/driver/ios-shadow.d.ts.map +1 -0
- package/dist/driver/ios-shadow.js +52 -0
- package/dist/driver/ios-shadow.js.map +1 -0
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +78 -35
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +48 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +217 -21
- package/dist/engine.js.map +1 -1
- package/dist/haptics.d.ts +4 -1
- package/dist/haptics.d.ts.map +1 -1
- package/dist/haptics.js +27 -3
- package/dist/haptics.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/liquid-grid.d.ts +3 -0
- package/dist/liquid-grid.d.ts.map +1 -0
- package/dist/liquid-grid.js +24 -0
- package/dist/liquid-grid.js.map +1 -0
- package/dist/liquid.d.ts +18 -0
- package/dist/liquid.d.ts.map +1 -0
- package/dist/liquid.js +229 -0
- package/dist/liquid.js.map +1 -0
- package/dist/playback-group.d.ts +6 -0
- package/dist/playback-group.d.ts.map +1 -0
- package/dist/playback-group.js +113 -0
- package/dist/playback-group.js.map +1 -0
- package/dist/playback.d.ts +25 -0
- package/dist/playback.d.ts.map +1 -0
- package/dist/playback.js +35 -0
- package/dist/playback.js.map +1 -0
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +6 -1
- package/dist/press.js.map +1 -1
- package/dist/properties.d.ts +5 -2
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +25 -1
- package/dist/properties.js.map +1 -1
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +6 -0
- package/dist/sequence.js.map +1 -1
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +3 -0
- package/dist/text.js.map +1 -1
- package/dist/track.d.ts +1 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +48 -3
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js +11 -2
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -2
- package/platforms/android/include.gradle +7 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
- package/platforms/android/proguard-rules.pro +7 -0
- package/src/animate.ts +48 -9
- package/src/color.ts +101 -0
- package/src/decoration.ts +180 -0
- package/src/driver/android-color.ts +170 -0
- package/src/driver/android-decoration.ts +182 -0
- package/src/driver/android-playback.ts +157 -0
- package/src/driver/android.ts +50 -46
- package/src/driver/index.ts +19 -1
- package/src/driver/ios-color.ts +441 -0
- package/src/driver/ios-decoration.ts +332 -0
- package/src/driver/ios-playback.ts +37 -0
- package/src/driver/ios-shadow.ts +50 -0
- package/src/driver/ios.ts +91 -42
- package/src/engine.ts +227 -23
- package/src/haptics.ts +25 -2
- package/src/index.ts +4 -0
- package/src/liquid-grid.ts +28 -0
- package/src/liquid.ts +282 -0
- package/src/playback-group.ts +110 -0
- package/src/playback.ts +62 -0
- package/src/press.ts +8 -2
- package/src/properties.ts +31 -5
- package/src/sequence.ts +5 -0
- package/src/text.ts +2 -0
- package/src/track.ts +72 -3
- package/src/transition.ts +13 -2
- 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
|
+
}
|
package/src/playback.ts
ADDED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
|
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<
|
|
23
|
+
} as const satisfies Record<NumericProperty, keyof View>
|
|
16
24
|
|
|
17
|
-
const defaults: Record<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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))
|