@rafaelaugusto/jolt 0.1.0-beta.0 → 0.1.0-beta.2
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 +134 -14
- package/dist/animate.d.ts +3 -3
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +40 -19
- package/dist/animate.js.map +1 -1
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +19 -3
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +7 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +28 -5
- package/dist/engine.js.map +1 -1
- package/dist/haptics.d.ts +18 -0
- package/dist/haptics.d.ts.map +1 -0
- package/dist/haptics.js +96 -0
- package/dist/haptics.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/particles.d.ts +39 -0
- package/dist/particles.d.ts.map +1 -0
- package/dist/particles.js +264 -0
- package/dist/particles.js.map +1 -0
- package/dist/presets.d.ts +16 -1
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +24 -6
- package/dist/presets.js.map +1 -1
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +3 -1
- package/dist/press.js.map +1 -1
- package/dist/sequence.d.ts +3 -2
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +6 -5
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts +12 -0
- package/dist/shimmer.d.ts.map +1 -0
- package/dist/shimmer.js +160 -0
- package/dist/shimmer.js.map +1 -0
- package/dist/symbol-effect.d.ts +23 -0
- package/dist/symbol-effect.d.ts.map +1 -0
- package/dist/symbol-effect.js +186 -0
- package/dist/symbol-effect.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/animate.ts +64 -19
- package/src/driver/ios.ts +23 -3
- package/src/engine.ts +26 -6
- package/src/haptics.ts +125 -0
- package/src/index.ts +13 -2
- package/src/particles.ts +323 -0
- package/src/presets.ts +31 -7
- package/src/press.ts +7 -1
- package/src/sequence.ts +8 -5
- package/src/shimmer.ts +179 -0
- package/src/symbol-effect.ts +225 -0
- package/src/types.ts +5 -0
package/src/haptics.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { isIOS, type View } from '@nativescript/core'
|
|
2
|
+
import type { Driver } from './driver/index.js'
|
|
3
|
+
import type { Track } from './track.js'
|
|
4
|
+
|
|
5
|
+
export type HapticStyle = 'light' | 'medium' | 'heavy'
|
|
6
|
+
|
|
7
|
+
export interface HapticOptions {
|
|
8
|
+
style: HapticStyle
|
|
9
|
+
/** Points from 0 to 1 over the finite animation, excluding its initial delay. Defaults to arrival. */
|
|
10
|
+
times?: readonly number[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Haptic = HapticStyle | HapticOptions | false
|
|
14
|
+
|
|
15
|
+
/** Validate before any running animation is interrupted. */
|
|
16
|
+
export function resolveHaptic(haptic: Haptic | undefined): HapticOptions | undefined {
|
|
17
|
+
if (haptic === undefined || haptic === false) return undefined
|
|
18
|
+
const options = typeof haptic === 'string' ? { style: haptic } : haptic
|
|
19
|
+
if (!options || !['light', 'medium', 'heavy'].includes(options.style))
|
|
20
|
+
throw new TypeError('Haptic style must be light, medium or heavy.')
|
|
21
|
+
const times = options.times
|
|
22
|
+
if (
|
|
23
|
+
times !== undefined &&
|
|
24
|
+
(!Array.isArray(times) ||
|
|
25
|
+
times.length === 0 ||
|
|
26
|
+
Array.from(times).some(
|
|
27
|
+
(time, i) =>
|
|
28
|
+
!Number.isFinite(time) ||
|
|
29
|
+
time <= 0 ||
|
|
30
|
+
time > 1 ||
|
|
31
|
+
(i > 0 && time <= times[i - 1]!),
|
|
32
|
+
))
|
|
33
|
+
)
|
|
34
|
+
throw new RangeError(
|
|
35
|
+
'Haptic times must be increasing points greater than 0 and at most 1.',
|
|
36
|
+
)
|
|
37
|
+
return { style: options.style, times: times && [...times] }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PREPARE_AHEAD = 100
|
|
41
|
+
const MAX_LATENESS = 100
|
|
42
|
+
const COMPLETION_TOLERANCE = 20
|
|
43
|
+
|
|
44
|
+
/** Discrete feedback only: animation playback remains entirely native. */
|
|
45
|
+
export function playHaptics(
|
|
46
|
+
view: View,
|
|
47
|
+
tracks: readonly Track[],
|
|
48
|
+
options: HapticOptions | undefined,
|
|
49
|
+
driver: Driver,
|
|
50
|
+
startedAt: number,
|
|
51
|
+
) {
|
|
52
|
+
if (!isIOS || !options || typeof UIImpactFeedbackGenerator === 'undefined') return
|
|
53
|
+
const moving = tracks.filter(
|
|
54
|
+
(track) =>
|
|
55
|
+
track.duration > 0 && track.values.some((value) => value !== track.values[0]),
|
|
56
|
+
)
|
|
57
|
+
if (!moving.length) return
|
|
58
|
+
const end = Math.max(...tracks.map((track) => track.end))
|
|
59
|
+
if (!Number.isFinite(end)) return
|
|
60
|
+
const delay = Math.min(...moving.map((track) => track.delay))
|
|
61
|
+
const offsets = options.times
|
|
62
|
+
? options.times.map((time) => delay + (end - delay) * time)
|
|
63
|
+
: [Math.max(...tracks.map((track) => track.arrival))]
|
|
64
|
+
const styles = {
|
|
65
|
+
light: 0,
|
|
66
|
+
medium: 1,
|
|
67
|
+
heavy: 2,
|
|
68
|
+
} satisfies Record<HapticStyle, UIImpactFeedbackStyle>
|
|
69
|
+
let generator: UIImpactFeedbackGenerator | undefined =
|
|
70
|
+
UIImpactFeedbackGenerator.alloc().initWithStyle(styles[options.style])
|
|
71
|
+
const native = view.nativeViewProtected as UIView
|
|
72
|
+
let next = 0
|
|
73
|
+
let cancelPrepare: (() => void) | undefined
|
|
74
|
+
let cancelImpact: (() => void) | undefined
|
|
75
|
+
|
|
76
|
+
const active = () =>
|
|
77
|
+
view.isLoaded &&
|
|
78
|
+
view.nativeViewProtected === native &&
|
|
79
|
+
!!native.window &&
|
|
80
|
+
UIApplication.sharedApplication.applicationState === 0 // UIApplicationState.Active
|
|
81
|
+
|
|
82
|
+
const cancel = () => {
|
|
83
|
+
cancelPrepare?.()
|
|
84
|
+
cancelImpact?.()
|
|
85
|
+
cancelPrepare = cancelImpact = undefined
|
|
86
|
+
generator = undefined
|
|
87
|
+
view.off('unloaded', cancel)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fire = (tolerance = 0) => {
|
|
91
|
+
const elapsed = driver.now() - startedAt
|
|
92
|
+
// A busy UI thread can delay timers. Drop stale impacts instead of replaying
|
|
93
|
+
// several old taps at once when it becomes responsive again.
|
|
94
|
+
while (next < offsets.length && offsets[next]! <= elapsed + tolerance) {
|
|
95
|
+
const offset = offsets[next++]!
|
|
96
|
+
if (active() && elapsed - offset <= MAX_LATENESS) generator?.impactOccurred()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const schedule = () => {
|
|
101
|
+
if (!generator || next === offsets.length) return cancel()
|
|
102
|
+
const remaining = startedAt + offsets[next]! - driver.now()
|
|
103
|
+
if (active()) generator.prepare()
|
|
104
|
+
if (remaining > PREPARE_AHEAD) {
|
|
105
|
+
cancelPrepare = driver.after(remaining - PREPARE_AHEAD, () => {
|
|
106
|
+
if (active()) generator?.prepare()
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
cancelImpact = driver.after(Math.max(0, remaining), () => {
|
|
110
|
+
fire()
|
|
111
|
+
schedule()
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
view.on('unloaded', cancel)
|
|
116
|
+
schedule()
|
|
117
|
+
return {
|
|
118
|
+
cancel,
|
|
119
|
+
finish() {
|
|
120
|
+
// Native completion may beat the timer at the final keyframe by a frame.
|
|
121
|
+
fire(COMPLETION_TOLERANCE)
|
|
122
|
+
cancel()
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,19 @@ export { animate, 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'
|
|
5
|
-
export {
|
|
6
|
-
export
|
|
5
|
+
export type { Haptic, HapticOptions, HapticStyle } from './haptics.js'
|
|
6
|
+
export { confetti, particles } from './particles.js'
|
|
7
|
+
export type { ConfettiOptions, ParticleOptions } from './particles.js'
|
|
8
|
+
export { shimmer } from './shimmer.js'
|
|
9
|
+
export type { ShimmerOptions } from './shimmer.js'
|
|
10
|
+
export { symbolEffect } from './symbol-effect.js'
|
|
11
|
+
export type {
|
|
12
|
+
SymbolEffect,
|
|
13
|
+
SymbolEffectName,
|
|
14
|
+
SymbolEffectOptions,
|
|
15
|
+
} from './symbol-effect.js'
|
|
16
|
+
export { definePreset, presets } from './presets.js'
|
|
17
|
+
export type { Preset, PresetName, Presets } from './presets.js'
|
|
7
18
|
export { springs } from './transition.js'
|
|
8
19
|
export type { Spring, SpringName, Timing, Transition, Tween } from './transition.js'
|
|
9
20
|
export { cubicBezier } from './easing.js'
|
package/src/particles.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
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 interface ConfettiOptions {
|
|
6
|
+
/** Approximate number of particles in the burst. Defaults to 80. */
|
|
7
|
+
count?: number
|
|
8
|
+
colors?: readonly string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ParticleOptions {
|
|
12
|
+
colors?: readonly string[]
|
|
13
|
+
shape?: 'circle' | 'rectangle' | 'ring'
|
|
14
|
+
/** Center point, or a line across the top/bottom of the target. */
|
|
15
|
+
source?: 'center' | 'top' | 'bottom'
|
|
16
|
+
/** Total particles per second, shared across all colors. Defaults to 20. */
|
|
17
|
+
rate?: number
|
|
18
|
+
/** Particle lifetime in milliseconds. Defaults to 3000. */
|
|
19
|
+
lifetime?: number
|
|
20
|
+
/** Longest side in DIPs. Defaults to 6. */
|
|
21
|
+
size?: number
|
|
22
|
+
/** DIPs per second. Defaults to 60. */
|
|
23
|
+
velocity?: number
|
|
24
|
+
velocityRange?: number
|
|
25
|
+
/** Degrees clockwise from right: -90 is up, 90 is down. Defaults to -90. */
|
|
26
|
+
angle?: number
|
|
27
|
+
/** Emission cone in degrees, from 0 to 360. Defaults to 30. */
|
|
28
|
+
spread?: number
|
|
29
|
+
/** Downward acceleration in DIPs per second squared. Defaults to 0. */
|
|
30
|
+
gravity?: number
|
|
31
|
+
/** Degrees per second. Defaults to 0. */
|
|
32
|
+
spin?: number
|
|
33
|
+
spinRange?: number
|
|
34
|
+
/** Fade each particle over its lifetime. Defaults to true. */
|
|
35
|
+
fadeOut?: boolean
|
|
36
|
+
/** Emit for this many milliseconds, then let particles expire. Omit to keep emitting. */
|
|
37
|
+
duration?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Settings = Required<Omit<ParticleOptions, 'duration'>> & { duration?: number }
|
|
41
|
+
const bursts = new WeakMap<View, () => void>()
|
|
42
|
+
const emitters = new WeakMap<View, () => void>()
|
|
43
|
+
const palette = ['#facc15', '#ef4444', '#3b82f6', '#22c55e', '#a855f7']
|
|
44
|
+
const textures = new Map<Settings['shape'], UIImage>()
|
|
45
|
+
const radians = (degrees: number) => (degrees * Math.PI) / 180
|
|
46
|
+
const BURST_DURATION = 200
|
|
47
|
+
|
|
48
|
+
/** Bursts from the target's center into its window. iOS only; returns immediate cleanup. */
|
|
49
|
+
export function confetti(target: Target, options: ConfettiOptions = {}): () => void {
|
|
50
|
+
const count = number('count', options.count ?? 80, 0, 10000)
|
|
51
|
+
if (!Number.isInteger(count)) throw new RangeError('Particle count must be an integer.')
|
|
52
|
+
const settings = resolve(
|
|
53
|
+
{
|
|
54
|
+
colors: options.colors ?? palette,
|
|
55
|
+
shape: 'rectangle',
|
|
56
|
+
rate: count / (BURST_DURATION / 1000),
|
|
57
|
+
duration: BURST_DURATION,
|
|
58
|
+
lifetime: 2200,
|
|
59
|
+
size: 8,
|
|
60
|
+
velocity: 260,
|
|
61
|
+
velocityRange: 100,
|
|
62
|
+
angle: -90,
|
|
63
|
+
spread: 100,
|
|
64
|
+
gravity: 420,
|
|
65
|
+
spinRange: 360,
|
|
66
|
+
},
|
|
67
|
+
true,
|
|
68
|
+
)
|
|
69
|
+
return start(target, settings, true)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Adds a native particle emitter to the target. iOS only; returns immediate cleanup. */
|
|
73
|
+
export function particles(target: Target, options: ParticleOptions = {}): () => void {
|
|
74
|
+
return start(target, resolve(options), false)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function start(target: Target, settings: Settings, overlay: boolean): () => void {
|
|
78
|
+
const view = toView(target)
|
|
79
|
+
const running = overlay ? bursts : emitters
|
|
80
|
+
running.get(view)?.()
|
|
81
|
+
if (!isIOS || settings.rate === 0 || UIAccessibilityIsReduceMotionEnabled())
|
|
82
|
+
return () => {}
|
|
83
|
+
return new Emitter(view, settings, overlay, running).stop
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolve(options: ParticleOptions, burst = false): Settings {
|
|
87
|
+
const colors = options.colors ?? ['#ffffff']
|
|
88
|
+
if (
|
|
89
|
+
!Array.isArray(colors) ||
|
|
90
|
+
colors.length === 0 ||
|
|
91
|
+
colors.length > 32 ||
|
|
92
|
+
Array.from(colors).some((color) => typeof color !== 'string' || !Color.isValid(color))
|
|
93
|
+
)
|
|
94
|
+
throw new TypeError('Particles need 1 to 32 valid color strings.')
|
|
95
|
+
const shape = options.shape ?? 'circle'
|
|
96
|
+
const source = options.source ?? 'center'
|
|
97
|
+
if (!['circle', 'rectangle', 'ring'].includes(shape))
|
|
98
|
+
throw new TypeError('Unknown particle shape.')
|
|
99
|
+
if (!['center', 'top', 'bottom'].includes(source))
|
|
100
|
+
throw new TypeError('Unknown particle source.')
|
|
101
|
+
const lifetime = number('lifetime', options.lifetime ?? 3000, 1, 60000)
|
|
102
|
+
const rate = number('rate', options.rate ?? 20, 0, burst ? 50000 : 10000)
|
|
103
|
+
if (!burst && (rate * lifetime) / 1000 > 10000)
|
|
104
|
+
throw new RangeError('Particle rate × lifetime must not exceed 10000 live particles.')
|
|
105
|
+
const duration =
|
|
106
|
+
options.duration === undefined
|
|
107
|
+
? undefined
|
|
108
|
+
: number('duration', options.duration, 1, 2147483647 - lifetime - 50)
|
|
109
|
+
const fadeOut = options.fadeOut ?? true
|
|
110
|
+
if (typeof fadeOut !== 'boolean')
|
|
111
|
+
throw new TypeError('Particle fadeOut must be a boolean.')
|
|
112
|
+
return {
|
|
113
|
+
colors: [...colors],
|
|
114
|
+
shape,
|
|
115
|
+
source,
|
|
116
|
+
lifetime,
|
|
117
|
+
rate,
|
|
118
|
+
duration,
|
|
119
|
+
fadeOut,
|
|
120
|
+
size: number('size', options.size ?? 6, 1, 128),
|
|
121
|
+
velocity: number('velocity', options.velocity ?? 60, 0, 10000),
|
|
122
|
+
velocityRange: number('velocityRange', options.velocityRange ?? 0, 0, 10000),
|
|
123
|
+
angle: number('angle', options.angle ?? -90, -360, 360),
|
|
124
|
+
spread: number('spread', options.spread ?? 30, 0, 360),
|
|
125
|
+
gravity: number('gravity', options.gravity ?? 0, -10000, 10000),
|
|
126
|
+
spin: number('spin', options.spin ?? 0, -3600, 3600),
|
|
127
|
+
spinRange: number('spinRange', options.spinRange ?? 0, 0, 3600),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function number(name: string, value: number, min: number, max: number): number {
|
|
132
|
+
if (!Number.isFinite(value) || value < min || value > max)
|
|
133
|
+
throw new RangeError(`Particle ${name} must be between ${min} and ${max}.`)
|
|
134
|
+
return value
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
class Emitter {
|
|
138
|
+
private stopped = false
|
|
139
|
+
private queued = false
|
|
140
|
+
private layer: CAEmitterLayer | undefined
|
|
141
|
+
private native: UIView | undefined
|
|
142
|
+
private window: UIWindow | undefined
|
|
143
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
144
|
+
private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
|
|
145
|
+
|
|
146
|
+
constructor(
|
|
147
|
+
private readonly view: View,
|
|
148
|
+
private readonly settings: Settings,
|
|
149
|
+
private readonly overlay: boolean,
|
|
150
|
+
private readonly running: WeakMap<View, () => void>,
|
|
151
|
+
) {
|
|
152
|
+
running.set(view, this.stop)
|
|
153
|
+
view.on('loaded', this.schedule)
|
|
154
|
+
view.on('layoutChanged', this.schedule)
|
|
155
|
+
view.on('unloaded', this.stop)
|
|
156
|
+
this.schedule()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
stop = () => {
|
|
160
|
+
if (this.stopped) return
|
|
161
|
+
this.stopped = true
|
|
162
|
+
this.view.off('loaded', this.schedule)
|
|
163
|
+
this.view.off('layoutChanged', this.schedule)
|
|
164
|
+
this.view.off('unloaded', this.stop)
|
|
165
|
+
if (this.running.get(this.view) === this.stop) this.running.delete(this.view)
|
|
166
|
+
if (this.timer !== undefined) clearTimeout(this.timer)
|
|
167
|
+
if (this.observer)
|
|
168
|
+
Application.ios.removeNotificationObserver(
|
|
169
|
+
this.observer,
|
|
170
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
171
|
+
)
|
|
172
|
+
this.layer?.removeAllAnimations()
|
|
173
|
+
this.layer?.removeFromSuperlayer()
|
|
174
|
+
this.layer = this.native = this.window = undefined
|
|
175
|
+
this.timer = this.observer = undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private schedule = () => {
|
|
179
|
+
if (this.stopped || this.queued) return
|
|
180
|
+
this.queued = true
|
|
181
|
+
void Promise.resolve().then(() => {
|
|
182
|
+
this.queued = false
|
|
183
|
+
if (this.stopped) return
|
|
184
|
+
try {
|
|
185
|
+
this.update()
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this.stop()
|
|
188
|
+
throw error
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private motionChanged = () => {
|
|
194
|
+
if (UIAccessibilityIsReduceMotionEnabled()) this.stop()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private update() {
|
|
198
|
+
if (!this.view.isLoaded) return
|
|
199
|
+
if (UIAccessibilityIsReduceMotionEnabled()) return this.stop()
|
|
200
|
+
const native = this.view.nativeViewProtected as UIView | undefined
|
|
201
|
+
const window = native?.window
|
|
202
|
+
if (this.layer && (!native || !window)) return this.stop()
|
|
203
|
+
if (!native || !window) return
|
|
204
|
+
if (this.native && (this.native !== native || this.window !== window))
|
|
205
|
+
return this.stop()
|
|
206
|
+
const bounds = native.layer.bounds
|
|
207
|
+
if (bounds.size.width <= 0 || bounds.size.height <= 0) {
|
|
208
|
+
if (this.layer) this.stop()
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
CATransaction.begin()
|
|
212
|
+
CATransaction.setDisableActions(true)
|
|
213
|
+
try {
|
|
214
|
+
if (!this.layer) {
|
|
215
|
+
this.native = native
|
|
216
|
+
this.window = window
|
|
217
|
+
const host = this.overlay ? window.layer : native.layer
|
|
218
|
+
const layer = (this.layer = CAEmitterLayer.new())
|
|
219
|
+
layer.name = this.overlay ? 'jolt:confetti' : 'jolt:particles'
|
|
220
|
+
layer.frame = host.bounds
|
|
221
|
+
layer.emitterShape =
|
|
222
|
+
this.settings.source === 'center' ? kCAEmitterLayerPoint : kCAEmitterLayerLine
|
|
223
|
+
layer.emitterCells = NSArray.arrayWithArray(this.cells())
|
|
224
|
+
host.addSublayer(layer)
|
|
225
|
+
// Start from an empty emitter, even if its parent has been alive for hours.
|
|
226
|
+
layer.beginTime = host.convertTimeFromLayer(CACurrentMediaTime(), null)
|
|
227
|
+
if (this.overlay)
|
|
228
|
+
layer.emitterPosition = native.convertPointToView(
|
|
229
|
+
CGPointMake(
|
|
230
|
+
bounds.origin.x + bounds.size.width / 2,
|
|
231
|
+
bounds.origin.y + bounds.size.height / 2,
|
|
232
|
+
),
|
|
233
|
+
window,
|
|
234
|
+
)
|
|
235
|
+
this.observer = Application.ios.addNotificationObserver(
|
|
236
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
237
|
+
this.motionChanged,
|
|
238
|
+
)
|
|
239
|
+
if (this.settings.duration !== undefined) {
|
|
240
|
+
// The render server stops emission even if JS is blocked. The timer
|
|
241
|
+
// only removes the empty layer after the last particles have expired.
|
|
242
|
+
layer.birthRate = 0
|
|
243
|
+
const emission = CABasicAnimation.animationWithKeyPath('birthRate')
|
|
244
|
+
emission.fromValue = 1
|
|
245
|
+
emission.toValue = 1
|
|
246
|
+
emission.duration = this.settings.duration / 1000
|
|
247
|
+
layer.addAnimationForKey(emission, 'jolt:emission')
|
|
248
|
+
this.timer = setTimeout(
|
|
249
|
+
this.stop,
|
|
250
|
+
this.settings.duration + this.settings.lifetime + 50,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!this.overlay) {
|
|
255
|
+
const layer = this.layer
|
|
256
|
+
layer.frame = bounds
|
|
257
|
+
layer.emitterSize = CGSizeMake(bounds.size.width, 0)
|
|
258
|
+
layer.emitterPosition = CGPointMake(
|
|
259
|
+
bounds.size.width / 2,
|
|
260
|
+
this.settings.source === 'top'
|
|
261
|
+
? 0
|
|
262
|
+
: this.settings.source === 'bottom'
|
|
263
|
+
? bounds.size.height
|
|
264
|
+
: bounds.size.height / 2,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
CATransaction.commit()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private cells(): CAEmitterCell[] {
|
|
273
|
+
const options = this.settings
|
|
274
|
+
const image = texture(options.shape)
|
|
275
|
+
return options.colors.map((color) => {
|
|
276
|
+
const cell = CAEmitterCell.emitterCell()
|
|
277
|
+
cell.contents = image.CGImage
|
|
278
|
+
cell.color = new Color(color).ios.CGColor
|
|
279
|
+
cell.birthRate = options.rate / options.colors.length
|
|
280
|
+
cell.lifetime = options.lifetime / 1000
|
|
281
|
+
cell.scale = options.size / 32
|
|
282
|
+
cell.velocity = options.velocity
|
|
283
|
+
cell.velocityRange = options.velocityRange
|
|
284
|
+
// Horizontal line emitters point upward at longitude zero; point
|
|
285
|
+
// emitters point right. Keep the public angle in view coordinates.
|
|
286
|
+
cell.emissionLongitude = radians(
|
|
287
|
+
options.angle + (options.source === 'center' ? 0 : 90),
|
|
288
|
+
)
|
|
289
|
+
cell.emissionRange = radians(options.spread)
|
|
290
|
+
cell.yAcceleration = options.gravity
|
|
291
|
+
cell.spin = radians(options.spin)
|
|
292
|
+
cell.spinRange = radians(options.spinRange)
|
|
293
|
+
cell.alphaSpeed = options.fadeOut ? -1 / cell.lifetime : 0
|
|
294
|
+
return cell
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function texture(shape: Settings['shape']): UIImage {
|
|
300
|
+
const cached = textures.get(shape)
|
|
301
|
+
if (cached) return cached
|
|
302
|
+
const format = UIGraphicsImageRendererFormat.defaultFormat()
|
|
303
|
+
format.scale = 1
|
|
304
|
+
format.opaque = false
|
|
305
|
+
const height = shape === 'rectangle' ? 18 : 32
|
|
306
|
+
const renderer = UIGraphicsImageRenderer.alloc().initWithSizeFormat(
|
|
307
|
+
CGSizeMake(32, height),
|
|
308
|
+
format,
|
|
309
|
+
)
|
|
310
|
+
const image = renderer.imageWithActions((context) => {
|
|
311
|
+
const cg = context.CGContext
|
|
312
|
+
CGContextSetFillColorWithColor(cg, UIColor.whiteColor.CGColor)
|
|
313
|
+
if (shape === 'rectangle') CGContextFillRect(cg, CGRectMake(0, 0, 32, height))
|
|
314
|
+
else if (shape === 'circle') CGContextFillEllipseInRect(cg, CGRectMake(0, 0, 32, 32))
|
|
315
|
+
else {
|
|
316
|
+
CGContextSetStrokeColorWithColor(cg, UIColor.whiteColor.CGColor)
|
|
317
|
+
CGContextSetLineWidth(cg, 3)
|
|
318
|
+
CGContextStrokeEllipseInRect(cg, CGRectMake(2, 2, 28, 28))
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
textures.set(shape, image)
|
|
322
|
+
return image
|
|
323
|
+
}
|
package/src/presets.ts
CHANGED
|
@@ -5,6 +5,9 @@ export interface Preset {
|
|
|
5
5
|
transition: AnimateOptions
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** Add custom preset names through module augmentation, with `true` as each value. */
|
|
9
|
+
export interface Presets {}
|
|
10
|
+
|
|
8
11
|
const fadeIn = { duration: 300, ease: 'easeOut' } as const
|
|
9
12
|
const fadeOut = { duration: 200, ease: 'easeIn' } as const
|
|
10
13
|
const enter = { type: 'spring', duration: 500, bounce: 0 } as const
|
|
@@ -77,28 +80,30 @@ export const presets = {
|
|
|
77
80
|
|
|
78
81
|
pulse: {
|
|
79
82
|
keyframes: { scale: [1, 1.06, 1] },
|
|
80
|
-
transition: { duration: 500, ease: 'easeInOut' },
|
|
83
|
+
transition: { duration: 500, ease: 'easeInOut', relative: true },
|
|
81
84
|
},
|
|
82
85
|
bounce: {
|
|
83
86
|
keyframes: { y: [0, -18, 0, -8, 0] },
|
|
84
87
|
transition: {
|
|
85
88
|
duration: 650,
|
|
89
|
+
relative: true,
|
|
86
90
|
times: [0, 0.3, 0.6, 0.8, 1],
|
|
87
91
|
ease: ['easeOut', 'easeIn', 'easeOut', 'easeIn'],
|
|
88
92
|
},
|
|
89
93
|
},
|
|
90
94
|
shake: {
|
|
91
95
|
keyframes: { x: [0, -10, 10, -8, 8, -4, 4, 0] },
|
|
92
|
-
transition: { duration: 500, ease: 'easeInOut' },
|
|
96
|
+
transition: { duration: 500, ease: 'easeInOut', relative: true },
|
|
93
97
|
},
|
|
94
98
|
wiggle: {
|
|
95
99
|
keyframes: { rotate: [0, -8, 8, -5, 5, 0] },
|
|
96
|
-
transition: { duration: 500, ease: 'easeInOut' },
|
|
100
|
+
transition: { duration: 500, ease: 'easeInOut', relative: true },
|
|
97
101
|
},
|
|
98
102
|
heartbeat: {
|
|
99
103
|
keyframes: { scale: [1, 1.15, 1, 1.15, 1, 1] },
|
|
100
104
|
transition: {
|
|
101
105
|
duration: 1000,
|
|
106
|
+
relative: true,
|
|
102
107
|
times: [0, 0.14, 0.28, 0.42, 0.7, 1],
|
|
103
108
|
ease: 'easeInOut',
|
|
104
109
|
},
|
|
@@ -110,25 +115,44 @@ export const presets = {
|
|
|
110
115
|
},
|
|
111
116
|
transition: {
|
|
112
117
|
duration: 800,
|
|
118
|
+
relative: true,
|
|
113
119
|
times: [0, 0.3, 0.4, 0.5, 0.65, 0.75, 1],
|
|
114
120
|
ease: 'easeInOut',
|
|
115
121
|
},
|
|
116
122
|
},
|
|
117
123
|
flash: {
|
|
118
124
|
keyframes: { opacity: [1, 0, 1, 0, 1] },
|
|
119
|
-
transition: { duration: 800, ease: 'easeInOut' },
|
|
125
|
+
transition: { duration: 800, ease: 'easeInOut', relative: true },
|
|
120
126
|
},
|
|
121
127
|
tada: {
|
|
122
128
|
keyframes: {
|
|
123
129
|
scale: [1, 0.9, 0.9, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1],
|
|
124
130
|
rotate: [0, -3, -3, 3, -3, 3, -3, 3, -3, 3, 0],
|
|
125
131
|
},
|
|
126
|
-
transition: { duration: 1000, ease: 'easeInOut' },
|
|
132
|
+
transition: { duration: 1000, ease: 'easeInOut', relative: true },
|
|
127
133
|
},
|
|
128
134
|
spin: {
|
|
129
135
|
keyframes: { rotate: [0, 360] },
|
|
130
|
-
transition: { duration: 1000, ease: 'linear' },
|
|
136
|
+
transition: { duration: 1000, ease: 'linear', relative: true },
|
|
131
137
|
},
|
|
132
138
|
} satisfies Record<string, Preset>
|
|
133
139
|
|
|
134
|
-
export type PresetName = keyof typeof presets
|
|
140
|
+
export type PresetName = keyof typeof presets | Extract<keyof Presets, string>
|
|
141
|
+
|
|
142
|
+
const customPresets = new Map<string, Preset>()
|
|
143
|
+
|
|
144
|
+
/** Registers a custom preset. Registering it again replaces it for future animations. */
|
|
145
|
+
export function definePreset(name: PresetName, preset: Preset): void {
|
|
146
|
+
if (Object.hasOwn(presets, name)) {
|
|
147
|
+
throw new Error(`Preset "${name}" is built in and cannot be replaced.`)
|
|
148
|
+
}
|
|
149
|
+
customPresets.set(name, preset)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function getPreset(name: PresetName): Preset {
|
|
153
|
+
const preset =
|
|
154
|
+
customPresets.get(name) ??
|
|
155
|
+
(Object.hasOwn(presets, name) ? presets[name as keyof typeof presets] : undefined)
|
|
156
|
+
if (!preset) throw new Error(`Unknown preset "${name}".`)
|
|
157
|
+
return preset
|
|
158
|
+
}
|
package/src/press.ts
CHANGED
|
@@ -33,7 +33,13 @@ export function press(
|
|
|
33
33
|
rest = Object.fromEntries(properties.map((p) => [p, readValue(view, p)]))
|
|
34
34
|
animate(view, pressed, options ?? pressIn)
|
|
35
35
|
} else if ((action === 'up' || action === 'cancel') && rest) {
|
|
36
|
-
animate(
|
|
36
|
+
animate(
|
|
37
|
+
view,
|
|
38
|
+
rest,
|
|
39
|
+
typeof options === 'object'
|
|
40
|
+
? { ...options, relative: false }
|
|
41
|
+
: (options ?? release),
|
|
42
|
+
)
|
|
37
43
|
rest = undefined
|
|
38
44
|
}
|
|
39
45
|
}
|
package/src/sequence.ts
CHANGED
|
@@ -17,8 +17,9 @@ export type Step =
|
|
|
17
17
|
/**
|
|
18
18
|
* Plays steps one after the other. A step starts once the previous one looks
|
|
19
19
|
* done: a spring is close to its target, even if it hasn't fully settled.
|
|
20
|
-
* Views in later steps take their first keyframe right away, so a title
|
|
21
|
-
* fades in third doesn't show up before its turn.
|
|
20
|
+
* Views in later steps take their first absolute keyframe right away, so a title
|
|
21
|
+
* that fades in third doesn't show up before its turn. Relative and null keyframes
|
|
22
|
+
* read the current value when their step starts.
|
|
22
23
|
*
|
|
23
24
|
* ```ts
|
|
24
25
|
* await sequence([
|
|
@@ -64,14 +65,16 @@ function applyStartingValues(steps: readonly Step[]) {
|
|
|
64
65
|
const claimed = new Map<View, Set<Property>>()
|
|
65
66
|
for (const step of steps) {
|
|
66
67
|
if (typeof step === 'function') continue
|
|
67
|
-
const [target, animation] = step
|
|
68
|
-
const starts = startingValues(animation)
|
|
68
|
+
const [target, animation, options] = step
|
|
69
|
+
const starts = startingValues(animation, options)
|
|
69
70
|
|
|
70
71
|
for (const view of toViews(target)) {
|
|
71
72
|
let properties = claimed.get(view)
|
|
72
73
|
if (!properties) claimed.set(view, (properties = new Set()))
|
|
73
74
|
|
|
74
|
-
const fresh = [...starts].filter(
|
|
75
|
+
const fresh = [...starts].filter(
|
|
76
|
+
([property, value]) => !properties.has(property) && value !== null,
|
|
77
|
+
)
|
|
75
78
|
stopView(
|
|
76
79
|
view,
|
|
77
80
|
fresh.map(([property]) => property),
|